Compare commits

..

16 Commits

Author SHA1 Message Date
Jack Kingsman 2b5937b9e9 Always show on logged-in pane 2026-04-01 20:45:10 -07:00
Jack Kingsman 2bef62dd87 Show telemetry history within repeater view on load 2026-04-01 19:35:55 -07:00
Jack Kingsman 1d4e25d97c Add delete to not depend on FK 2026-04-01 17:29:12 -07:00
Jack Kingsman dc804d4646 Minor comment correction 2026-04-01 17:24:08 -07:00
Jack Kingsman 2d5024de8f Remove statusFetchedAt unused prop 2026-04-01 17:18:30 -07:00
Jack Kingsman 18f4abcb71 Update migrations to account for my new ones 2026-04-01 17:16:42 -07:00
Gnome Adrift 771e809c11 Prune telemetry entries, remove uplot comments, format code 2026-04-01 13:02:02 -07:00
Gnome Adrift 7ded8e1e71 Oops, remove drop table command in migration :/ 2026-04-01 12:34:23 -07:00
Gnome Adrift 4c9a2273e4 Remove reference to tracking opt-in from database migration 2026-04-01 11:59:57 -07:00
Gnome Adrift 7cad06399c Merge branch 'main' of github.com:jkingsman/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history 2026-04-01 11:55:33 -07:00
Gnome Adrift 04c8ccfa45 Remove automatic telemetry querying, remove battery pane, add telemetry history pane 2026-04-01 11:54:39 -07:00
Gnome Adrift 7ba61ef01d Merge branch 'main' of github.com:maplemesh/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history 2026-03-31 09:11:49 -07:00
Gnome Adrift cd8382f9fb Fix for telemetry polling 2026-03-30 11:38:05 -07:00
Gnome Adrift 8e48e1e817 Merge branch 'main' of github.com:maplemesh/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history 2026-03-30 10:31:28 -07:00
Gnome Adrift c393e8c03e Make battery history update when fetching telemetry 2026-03-30 10:07:20 -07:00
Gnome Adrift 7f7e8cacd1 First draft of repeater telemetry feature 2026-03-29 06:14:14 -07:00
81 changed files with 1440 additions and 2909 deletions
+2 -4
View File
@@ -327,7 +327,6 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload |
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
| POST | `/api/contacts/bulk-delete` | Delete multiple contacts |
| DELETE | `/api/contacts/{public_key}` | Delete contact |
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
@@ -351,7 +350,6 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/channels` | List channels |
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
| POST | `/api/channels` | Create channel |
| POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels |
| DELETE | `/api/channels/{key}` | Delete channel |
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
@@ -465,7 +463,7 @@ mc.subscribe(EventType.ACK, handler)
|----------|---------|-------------|
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
| `MESHCORE_TCP_PORT` | `5000` | TCP port (used with `MESHCORE_TCP_HOST`) |
| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
@@ -477,7 +475,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, and `discovery_blocked_types`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. The backend still carries `sidebar_sort_order` for compatibility and migration, but the current frontend sidebar stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in localStorage rather than treating it as one shared server-backed preference. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
-20
View File
@@ -1,23 +1,3 @@
## [3.7.1] - 2026-04-02
* Feature: Redact Apprise URLs to prevent sensitive information disclosure
## [3.7.0] - 2026-04-02
* Feature: Repeater battery tracking
* Feature: Repeater info pane just like contacts
* Feature: Make repeaters blockable
* Feature: Add new-node advert blocking
* Feature: Add bulk deletion interface
* Feature: Bulk room add on alt+click of new channel button
* Feature: More info in debug endpoint
* Bugfix: Be more conservative around radio load limits and don't exceed radio-reported capacity
* Misc: Default auto-DM decrypt to true
* Misc: Reorganize some settings panes
* Misc: Enable FK pragma
* Misc: Various performance and correctness fixes
* Misc: Correct TCP default port
## [3.6.7] - 2026-03-31
* Misc: Remove armv7 (for now)
+2 -2
View File
@@ -177,7 +177,7 @@ Only one transport may be active at a time. If multiple are set, the server will
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
| `MESHCORE_TCP_PORT` | 5000 | TCP port |
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
@@ -193,7 +193,7 @@ Common launch patterns:
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# TCP
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=5000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# BLE
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
+4 -3
View File
@@ -190,7 +190,6 @@ app/
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
- `POST /contacts`
- `POST /contacts/bulk-delete`
- `DELETE /contacts/{public_key}`
- `POST /contacts/{public_key}/mark-read`
- `POST /contacts/{public_key}/command`
@@ -215,7 +214,6 @@ app/
- `GET /channels`
- `GET /channels/{key}/detail`
- `POST /channels`
- `POST /channels/bulk-hashtag`
- `DELETE /channels/{key}`
- `POST /channels/{key}/flood-scope-override`
- `POST /channels/{key}/mark-read`
@@ -302,12 +300,15 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
- `max_radio_contacts`
- `favorites`
- `auto_decrypt_dm_on_advert`
- `sidebar_sort_order`
- `last_message_times`
- `preferences_migrated`
- `advert_interval`
- `last_advert_time`
- `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `blocked_keys`, `blocked_names`
Note: `sidebar_sort_order` remains in the backend model for compatibility and migration, but the current frontend sidebar uses per-section localStorage sort preferences instead of a single shared server-backed sort mode.
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
+1 -2
View File
@@ -14,7 +14,7 @@ class Settings(BaseSettings):
serial_port: str = "" # Empty string triggers auto-detection
serial_baudrate: int = 115200
tcp_host: str = ""
tcp_port: int = 5000
tcp_port: int = 4000
ble_address: str = ""
ble_pin: str = ""
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
@@ -26,7 +26,6 @@ class Settings(BaseSettings):
default=False,
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
)
skip_post_connect_sync: bool = False
basic_auth_username: str = ""
basic_auth_password: str = ""
+7 -59
View File
@@ -46,7 +46,7 @@ CREATE TABLE IF NOT EXISTS messages (
text TEXT NOT NULL,
sender_timestamp INTEGER,
received_at INTEGER NOT NULL,
paths TEXT,
path TEXT,
txt_type INTEGER DEFAULT 0,
signature TEXT,
outgoing INTEGER DEFAULT 0,
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS raw_packets (
data BLOB NOT NULL,
message_id INTEGER,
payload_hash BLOB,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
FOREIGN KEY (message_id) REFERENCES messages(id)
);
CREATE TABLE IF NOT EXISTS contact_advert_paths (
@@ -78,7 +78,7 @@ CREATE TABLE IF NOT EXISTS contact_advert_paths (
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(public_key, path_hex, path_len),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
);
CREATE TABLE IF NOT EXISTS contact_name_history (
@@ -88,68 +88,26 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
UNIQUE(public_key, name),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]',
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
last_message_times TEXT DEFAULT '{}',
preferences_migrated INTEGER DEFAULT 0,
advert_interval INTEGER DEFAULT 0,
last_advert_time INTEGER DEFAULT 0,
flood_scope TEXT DEFAULT '',
blocked_keys TEXT DEFAULT '[]',
blocked_names TEXT DEFAULT '[]',
discovery_blocked_types TEXT DEFAULT '[]'
);
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS fanout_configs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 0,
config TEXT NOT NULL DEFAULT '{}',
scope TEXT NOT NULL DEFAULT '{}',
sort_order INTEGER DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
);
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'CHAN';
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_incoming_priv_dedup
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'PRIV' AND outgoing = 0;
CREATE INDEX IF NOT EXISTS idx_messages_sender_key ON messages(sender_key);
CREATE INDEX IF NOT EXISTS idx_messages_pagination
ON messages(type, conversation_key, received_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_messages_unread_covering
ON messages(type, conversation_key, outgoing, received_at);
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
ON messages(type, received_at, conversation_key);
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
ON contact_advert_paths(public_key, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key
ON contact_name_history(public_key, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
ON repeater_telemetry_history(public_key, timestamp);
"""
@@ -174,12 +132,6 @@ class Database:
# migration 20 handles the one-time VACUUM to restructure the file.
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
# Foreign key enforcement: must be set per-connection (not persisted).
# Disabled during schema init and migrations to avoid issues with
# historical table-rebuild migrations that may temporarily violate
# constraints, then re-enabled for all subsequent application queries.
await self._connection.execute("PRAGMA foreign_keys = OFF")
await self._connection.executescript(SCHEMA)
await self._connection.commit()
logger.debug("Database schema initialized")
@@ -189,10 +141,6 @@ class Database:
await run_migrations(self._connection)
# Enable FK enforcement for all application queries from this point on.
await self._connection.execute("PRAGMA foreign_keys = ON")
logger.debug("Foreign key enforcement enabled")
async def disconnect(self) -> None:
if self._connection:
await self._connection.close()
+1
View File
@@ -202,6 +202,7 @@ async def on_path_update(event: "Event") -> None:
# Legacy firmware/library payloads only support 1-byte hop hashes.
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
else:
normalized_path_hash_mode = None
try:
normalized_path_hash_mode = int(path_hash_mode)
except (TypeError, ValueError):
+18
View File
@@ -80,6 +80,14 @@ _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
}
def validate_ws_event_payload(event_type: str, data: Any) -> WsEventPayload | Any:
"""Validate known WebSocket payloads; pass unknown events through unchanged."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
if adapter is None:
return data
return adapter.validate_python(data)
def dump_ws_event(event_type: str, data: Any) -> str:
"""Serialize a WebSocket event envelope with validation for known event types."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
@@ -96,3 +104,13 @@ def dump_ws_event(event_type: str, data: Any) -> str:
event_type,
)
return json.dumps({"type": event_type, "data": data})
def dump_ws_event_payload(event_type: str, data: Any) -> Any:
"""Return the JSON-serializable payload for a WebSocket event."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
if adapter is None:
return data
validated = adapter.validate_python(data)
return adapter.dump_python(validated, mode="json")
+5 -2
View File
@@ -144,8 +144,11 @@ class MapUploadModule(FanoutModule):
if advert is None:
return
# Advert Ed25519 signature verification is intentionally skipped.
# The radio validates packets before passing them to RT.
# TODO: advert Ed25519 signature verification is skipped here.
# The radio has already validated the packet before passing it to RT,
# so re-verification is redundant in practice. If added, verify that
# nacl.bindings.crypto_sign_open(sig + (pubkey_bytes || timestamp_bytes),
# advert.public_key_bytes) succeeds before proceeding.
# Only process repeaters (2) and rooms (3) — any other role is rejected
if advert.device_role not in _ALLOWED_DEVICE_ROLES:
+12 -223
View File
@@ -367,34 +367,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 47)
applied += 1
# Migration 48: Add discovery_blocked_types column to app_settings
if version < 48:
logger.info("Applying migration 48: add discovery_blocked_types to app_settings")
await _migrate_048_discovery_blocked_types(conn)
await set_version(conn, 48)
applied += 1
# Migration 49: Enable foreign key enforcement — rebuild tables with
# CASCADE / SET NULL and clean up any orphaned rows first.
# Migration 49: Repeater telemetry history table
if version < 49:
logger.info("Applying migration 49: add foreign key cascade/set-null and clean orphans")
await _migrate_049_foreign_key_cascade(conn)
logger.info("Applying migration 49: repeater telemetry history")
await _migrate_049_repeater_telemetry_history(conn)
await set_version(conn, 49)
applied += 1
# Migration 50: Repeater telemetry history table + tracking opt-in column
if version < 50:
logger.info("Applying migration 50: repeater telemetry history")
await _migrate_050_repeater_telemetry_history(conn)
await set_version(conn, 50)
applied += 1
if version < 51:
logger.info("Applying migration 51: drop sidebar_sort_order from app_settings")
await _migrate_051_drop_sidebar_sort_order(conn)
await set_version(conn, 51)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -857,7 +836,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
id INTEGER PRIMARY KEY CHECK (id = 1),
max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]',
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
sidebar_sort_order TEXT DEFAULT 'recent',
last_message_times TEXT DEFAULT '{}',
preferences_migrated INTEGER DEFAULT 0
@@ -865,9 +844,13 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
"""
)
# Initialize with default row (use only the id column so this works
# regardless of which columns exist — defaults fill the rest).
await conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)")
# Initialize with default row
await conn.execute(
"""
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
VALUES (1, 200, '[]', 0, 'recent', '{}', 0)
"""
)
await conn.commit()
logger.debug("Created app_settings table with default values")
@@ -2935,182 +2918,7 @@ async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> Non
await conn.commit()
async def _migrate_048_discovery_blocked_types(conn: aiosqlite.Connection) -> None:
"""Add discovery_blocked_types column to app_settings.
Stores a JSON array of integer contact type codes (1=Client, 2=Repeater,
3=Room, 4=Sensor) whose advertisements should not create new contacts.
Empty list means all types are accepted.
"""
try:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN discovery_blocked_types TEXT DEFAULT '[]'"
)
except Exception as e:
error_msg = str(e).lower()
if "duplicate column" in error_msg:
logger.debug("discovery_blocked_types column already exists, skipping")
elif "no such table" in error_msg:
logger.debug("app_settings table not ready, skipping discovery_blocked_types migration")
else:
raise
await conn.commit()
async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None:
"""Rebuild FK tables with CASCADE/SET NULL and clean orphaned rows.
SQLite cannot ALTER existing FK constraints, so each table is rebuilt.
Orphaned child rows are cleaned up before the rebuild to ensure the
INSERT...SELECT into the new table (which has enforced FKs) succeeds.
"""
import shutil
from pathlib import Path
# Back up the database before table rebuilds (skip for in-memory DBs).
cursor = await conn.execute("PRAGMA database_list")
db_row = await cursor.fetchone()
db_path = db_row[2] if db_row else ""
if db_path and db_path != ":memory:" and Path(db_path).exists():
backup_path = db_path + ".pre-fk-migration.bak"
for suffix in ("", "-wal", "-shm"):
src = Path(db_path + suffix)
if src.exists():
shutil.copy2(str(src), backup_path + suffix)
logger.info("Database backed up to %s before FK migration", backup_path)
# --- Phase 1: clean orphans (guard each table's existence) ---
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
if "contact_advert_paths" in existing_tables and "contacts" in existing_tables:
await conn.execute(
"DELETE FROM contact_advert_paths "
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
)
if "contact_name_history" in existing_tables and "contacts" in existing_tables:
await conn.execute(
"DELETE FROM contact_name_history "
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
)
if "raw_packets" in existing_tables and "messages" in existing_tables:
# Guard: message_id column may not exist on very old schemas
col_cursor = await conn.execute("PRAGMA table_info(raw_packets)")
raw_cols = {row[1] for row in await col_cursor.fetchall()}
if "message_id" in raw_cols:
await conn.execute(
"UPDATE raw_packets SET message_id = NULL WHERE message_id IS NOT NULL "
"AND message_id NOT IN (SELECT id FROM messages)"
)
await conn.commit()
logger.debug("Cleaned orphaned child rows before FK rebuild")
# --- Phase 2: rebuild raw_packets with ON DELETE SET NULL ---
# Skip if raw_packets doesn't have message_id (pre-migration-18 schema)
raw_has_message_id = False
if "raw_packets" in existing_tables:
col_cursor2 = await conn.execute("PRAGMA table_info(raw_packets)")
raw_has_message_id = "message_id" in {row[1] for row in await col_cursor2.fetchall()}
if raw_has_message_id:
# Dynamically build column list based on what the old table actually has,
# since very old schemas may lack payload_hash (added in migration 28).
col_cursor3 = await conn.execute("PRAGMA table_info(raw_packets)")
old_cols = [row[1] for row in await col_cursor3.fetchall()]
new_col_defs = [
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"timestamp INTEGER NOT NULL",
"data BLOB NOT NULL",
"message_id INTEGER",
]
copy_cols = ["id", "timestamp", "data", "message_id"]
if "payload_hash" in old_cols:
new_col_defs.append("payload_hash BLOB")
copy_cols.append("payload_hash")
new_col_defs.append("FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL")
cols_sql = ", ".join(new_col_defs)
copy_sql = ", ".join(copy_cols)
await conn.execute(f"CREATE TABLE raw_packets_fk ({cols_sql})")
await conn.execute(
f"INSERT INTO raw_packets_fk ({copy_sql}) SELECT {copy_sql} FROM raw_packets"
)
await conn.execute("DROP TABLE raw_packets")
await conn.execute("ALTER TABLE raw_packets_fk RENAME TO raw_packets")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
)
if "payload_hash" in old_cols:
await conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash)"
)
await conn.commit()
logger.debug("Rebuilt raw_packets with ON DELETE SET NULL")
# --- Phase 3: rebuild contact_advert_paths with ON DELETE CASCADE ---
if "contact_advert_paths" in existing_tables:
await conn.execute(
"""
CREATE TABLE contact_advert_paths_fk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
path_hex TEXT NOT NULL,
path_len INTEGER NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(public_key, path_hex, path_len),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"INSERT INTO contact_advert_paths_fk (id, public_key, path_hex, path_len, first_seen, last_seen, heard_count) "
"SELECT id, public_key, path_hex, path_len, first_seen, last_seen, heard_count FROM contact_advert_paths"
)
await conn.execute("DROP TABLE contact_advert_paths")
await conn.execute("ALTER TABLE contact_advert_paths_fk RENAME TO contact_advert_paths")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent "
"ON contact_advert_paths(public_key, last_seen DESC)"
)
await conn.commit()
logger.debug("Rebuilt contact_advert_paths with ON DELETE CASCADE")
# --- Phase 4: rebuild contact_name_history with ON DELETE CASCADE ---
if "contact_name_history" in existing_tables:
await conn.execute(
"""
CREATE TABLE contact_name_history_fk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
name TEXT NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
UNIQUE(public_key, name),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"INSERT INTO contact_name_history_fk (id, public_key, name, first_seen, last_seen) "
"SELECT id, public_key, name, first_seen, last_seen FROM contact_name_history"
)
await conn.execute("DROP TABLE contact_name_history")
await conn.execute("ALTER TABLE contact_name_history_fk RENAME TO contact_name_history")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_contact_name_history_key "
"ON contact_name_history(public_key, last_seen DESC)"
)
await conn.commit()
logger.debug("Rebuilt contact_name_history with ON DELETE CASCADE")
async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
async def _migrate_049_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
"""Create repeater_telemetry_history table for JSON-blob telemetry snapshots."""
await conn.execute(
"""
@@ -3130,22 +2938,3 @@ async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) ->
"""
)
await conn.commit()
async def _migrate_051_drop_sidebar_sort_order(conn: aiosqlite.Connection) -> None:
"""Remove vestigial sidebar_sort_order column from app_settings."""
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "sidebar_sort_order" in columns:
try:
await conn.execute("ALTER TABLE app_settings DROP COLUMN sidebar_sort_order")
await conn.commit()
except Exception as e:
error_msg = str(e).lower()
if "syntax error" in error_msg or "drop column" in error_msg:
logger.debug(
"SQLite doesn't support DROP COLUMN, sidebar_sort_order column will remain"
)
await conn.commit()
else:
raise
+29 -8
View File
@@ -283,6 +283,30 @@ class NearestRepeater(BaseModel):
heard_count: int
class ContactDetail(BaseModel):
"""Comprehensive contact profile data."""
contact: Contact
name_history: list[ContactNameHistory] = Field(default_factory=list)
dm_message_count: int = 0
channel_message_count: int = 0
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
advert_frequency: float | None = Field(
default=None,
description="Advert observations per hour (includes multi-path arrivals of same advert)",
)
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
class NameOnlyContactDetail(BaseModel):
"""Channel activity summary for a sender name that is not tied to a known key."""
name: str
channel_message_count: int = 0
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
class ContactAnalyticsHourlyBucket(BaseModel):
"""A single hourly activity bucket for contact analytics."""
@@ -784,9 +808,13 @@ class AppSettings(BaseModel):
default_factory=list, description="List of favorited conversations"
)
auto_decrypt_dm_on_advert: bool = Field(
default=True,
default=False,
description="Whether to attempt historical DM decryption on new contact advertisement",
)
sidebar_sort_order: Literal["recent", "alpha"] = Field(
default="recent",
description="Sidebar sort order: 'recent' or 'alpha'",
)
last_message_times: dict[str, int] = Field(
default_factory=dict,
description="Map of conversation state keys to last message timestamps",
@@ -815,13 +843,6 @@ class AppSettings(BaseModel):
default_factory=list,
description="Display names whose messages are hidden from the UI",
)
discovery_blocked_types: list[int] = Field(
default_factory=list,
description=(
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
"advertisements should not create new contacts; existing contacts are still updated"
),
)
class FanoutConfig(BaseModel):
+8 -24
View File
@@ -462,19 +462,14 @@ async def _process_advertisement(
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
)
# Check discovery_blocked_types: skip new contacts whose type is blocked.
# Existing contacts are always updated (location, name, last_seen, etc.).
if existing is None and contact_type > 0:
from app.repository import AppSettingsRepository
settings = await AppSettingsRepository.get()
if contact_type in settings.discovery_blocked_types:
logger.debug(
"Skipping new contact %s: type %d is in discovery_blocked_types",
advert.public_key[:12],
contact_type,
)
return
# Keep recent unique advert paths for all contacts.
await ContactAdvertPathRepository.record_observation(
public_key=advert.public_key.lower(),
path_hex=new_path_hex,
timestamp=timestamp,
max_paths=10,
hop_count=new_path_len,
)
contact_upsert = ContactUpsert(
public_key=advert.public_key.lower(),
@@ -487,18 +482,7 @@ async def _process_advertisement(
first_seen=timestamp, # COALESCE in upsert preserves existing value
)
# Upsert the contact BEFORE recording advert paths so the parent row
# exists when foreign key enforcement is enabled.
await ContactRepository.upsert(contact_upsert)
# Keep recent unique advert paths for all contacts.
await ContactAdvertPathRepository.record_observation(
public_key=advert.public_key.lower(),
path_hex=new_path_hex,
timestamp=timestamp,
max_paths=10,
hop_count=new_path_len,
)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=advert.public_key,
log=logger,
+68 -35
View File
@@ -29,10 +29,7 @@ from app.repository import (
ChannelRepository,
ContactRepository,
)
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.contact_reconciliation import reconcile_contact_messages
from app.services.messages import create_fallback_channel_message
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_error, broadcast_event
@@ -66,25 +63,13 @@ async def _reconcile_contact_messages_background(
public_key: str,
contact_name: str | None,
) -> None:
"""Run prefix promotion and contact/message reconciliation outside the radio critical path."""
"""Run contact/message reconciliation outside the radio critical path."""
try:
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=public_key,
log=logger,
)
await reconcile_contact_messages(
public_key=public_key,
contact_name=contact_name,
log=logger,
)
if promoted_keys:
contact = await ContactRepository.get_by_key(public_key.lower())
if contact is not None:
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{"previous_public_key": old_key, "contact": contact.model_dump()},
)
except Exception as exc:
logger.warning(
"Background contact reconciliation failed for %s: %s",
@@ -194,22 +179,6 @@ RADIO_CONTACT_REFILL_RATIO = 0.80
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
def _effective_radio_capacity(configured: int) -> int:
"""Return the effective radio contact capacity.
Uses the lower of the user-configured ``max_radio_contacts`` and the
hardware limit reported by the radio at connect time. The existing
80% refill ratio already reserves headroom for the radio to
organically add contacts it hears via adverts, so no additional
reduction is applied here.
"""
capacity = max(1, configured)
hw_limit = radio_manager.max_contacts
if hw_limit is not None:
capacity = min(capacity, hw_limit)
return max(1, capacity)
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
capacity = max(1, max_contacts)
@@ -224,7 +193,7 @@ def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
"""Check current radio occupancy and decide whether to offload/reload."""
app_settings = await AppSettingsRepository.get()
capacity = _effective_radio_capacity(app_settings.max_radio_contacts)
capacity = app_settings.max_radio_contacts
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
result = await mc.commands.get_contacts()
@@ -253,6 +222,70 @@ async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
return False
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
"""
Sync contacts from radio to database, then remove them from radio.
Returns counts of synced and removed contacts.
"""
synced = 0
removed = 0
try:
# Get all contacts from radio
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
logger.error(
"Failed to get contacts from radio: %s. "
"If you see this repeatedly, the radio may be visible on the "
"serial/TCP/BLE port but not responding to commands. Check for "
"another process with the serial port open (other RemoteTerm "
"instances, serial monitors, etc.), verify the firmware is "
"up-to-date and in client mode (not repeater), or try a "
"power cycle.",
result,
)
return {"synced": 0, "removed": 0, "error": str(result)}
contacts = result.payload or {}
logger.info("Found %d contacts on radio", len(contacts))
# Sync each contact to database, then remove from radio
for public_key, contact_data in contacts.items():
# Save to database
await ContactRepository.upsert(
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
)
asyncio.create_task(
_reconcile_contact_messages_background(
public_key,
contact_data.get("adv_name"),
)
)
synced += 1
# Remove from radio
try:
remove_result = await mc.commands.remove_contact(contact_data)
if remove_result.type == EventType.OK:
removed += 1
_evict_removed_contact_from_library_cache(mc, public_key)
else:
logger.warning(
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
)
except Exception as e:
logger.warning("Error removing contact %s: %s", public_key[:12], e)
logger.info("Synced %d contacts, removed %d from radio", synced, removed)
except Exception as e:
logger.error("Error during contact sync: %s", e)
return {"synced": synced, "removed": removed, "error": str(e)}
return {"synced": synced, "removed": removed}
async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> dict:
"""
Sync channels from radio to database, then clear them from radio.
@@ -1268,7 +1301,7 @@ async def stop_background_contact_reconciliation() -> None:
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
"""Return the contacts that would be loaded onto the radio right now."""
app_settings = await AppSettingsRepository.get()
max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts)
max_contacts = app_settings.max_radio_contacts
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
selected_contacts: list[Contact] = []
selected_keys: set[str] = set()
+60 -66
View File
@@ -1,4 +1,3 @@
import logging
import time
from collections.abc import Mapping
from typing import Any
@@ -13,8 +12,6 @@ from app.models import (
)
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
logger = logging.getLogger(__name__)
class AmbiguousPublicKeyPrefixError(ValueError):
"""Raised when a public key prefix matches multiple contacts."""
@@ -395,9 +392,15 @@ class ContactRepository:
@staticmethod
async def delete(public_key: str) -> None:
normalized = public_key.lower()
# contact_name_history and contact_advert_paths cascade via FK.
# Messages are intentionally preserved so history re-surfaces
# if the contact is re-added later.
await db.conn.execute(
"DELETE FROM contact_name_history WHERE public_key = ?", (normalized,)
)
await db.conn.execute(
"DELETE FROM contact_advert_paths WHERE public_key = ?", (normalized,)
)
await db.conn.execute(
"DELETE FROM repeater_telemetry_history WHERE public_key = ?", (normalized,)
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
await db.conn.commit()
@@ -484,6 +487,7 @@ class ContactRepository:
return []
promoted_keys: list[str] = []
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
for row in rows:
old_key = row["public_key"]
@@ -500,70 +504,60 @@ class ContactRepository:
(old_key,),
)
match_row = await match_cursor.fetchone()
match_count = match_row["match_count"] if match_row is not None else 0
if match_count != 1:
logger.warning(
"Skipping prefix promotion for %s: %d full-key contacts match (expected 1)",
old_key,
match_count,
)
if (match_row["match_count"] if match_row is not None else 0) != 1:
continue
await migrate_child_rows(old_key, normalized_full_key)
# Merge timestamp metadata from the old prefix contact into the
# full-key contact (which all callers guarantee already exists),
# then delete the prefix placeholder.
await db.conn.execute(
"""
UPDATE contacts
SET last_seen = CASE
WHEN contacts.last_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_seen
WHEN ? > contacts.last_seen THEN ?
ELSE contacts.last_seen
END,
last_contacted = CASE
WHEN contacts.last_contacted IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ?
ELSE contacts.last_contacted
END,
first_seen = CASE
WHEN contacts.first_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.first_seen
WHEN ? < contacts.first_seen THEN ?
ELSE contacts.first_seen
END,
last_read_at = CASE
WHEN contacts.last_read_at IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_read_at
WHEN ? > contacts.last_read_at THEN ?
ELSE contacts.last_read_at
END
WHERE public_key = ?
""",
(
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["last_read_at"],
row["last_read_at"],
row["last_read_at"],
row["last_read_at"],
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
if full_exists:
await db.conn.execute(
"""
UPDATE contacts
SET last_seen = CASE
WHEN contacts.last_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_seen
WHEN ? > contacts.last_seen THEN ?
ELSE contacts.last_seen
END,
last_contacted = CASE
WHEN contacts.last_contacted IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ?
ELSE contacts.last_contacted
END,
first_seen = CASE
WHEN contacts.first_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.first_seen
WHEN ? < contacts.first_seen THEN ?
ELSE contacts.first_seen
END,
last_read_at = COALESCE(contacts.last_read_at, ?)
WHERE public_key = ?
""",
(
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["last_read_at"],
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
else:
await db.conn.execute(
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
(normalized_full_key, old_key),
)
full_exists = True
promoted_keys.append(old_key)
+16 -25
View File
@@ -29,7 +29,8 @@ class MessageRepository:
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
lower_key = public_key.lower()
return (
"((type = 'PRIV' AND conversation_key = ?) OR (type = 'CHAN' AND sender_key = ?))",
"((type = 'PRIV' AND LOWER(conversation_key) = ?)"
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
[lower_key, lower_key],
)
@@ -80,9 +81,6 @@ class MessageRepository:
entry["path_len"] = path_len
paths_json = json.dumps([entry])
# Normalize sender_key to lowercase so queries can match without LOWER().
normalized_sender_key = sender_key.lower() if sender_key else sender_key
cursor = await db.conn.execute(
"""
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
@@ -101,7 +99,7 @@ class MessageRepository:
signature,
outgoing,
sender_name,
normalized_sender_key,
sender_key,
),
)
await db.conn.commit()
@@ -160,11 +158,7 @@ class MessageRepository:
"""
lower_key = full_key.lower()
cursor = await db.conn.execute(
"""UPDATE messages SET conversation_key = ?,
sender_key = CASE
WHEN sender_key IS NOT NULL AND length(sender_key) < 64
AND ? LIKE sender_key || '%'
THEN ? ELSE sender_key END
"""UPDATE messages SET conversation_key = ?
WHERE type = 'PRIV' AND length(conversation_key) < 64
AND ? LIKE conversation_key || '%'
AND (
@@ -172,7 +166,7 @@ class MessageRepository:
WHERE length(public_key) = 64
AND public_key LIKE messages.conversation_key || '%'
) = 1""",
(lower_key, lower_key, lower_key, lower_key),
(lower_key, lower_key),
)
await db.conn.commit()
return cursor.rowcount
@@ -261,10 +255,10 @@ class MessageRepository:
if MessageRepository._looks_like_hex_prefix(value):
if len(value) == 32:
clause += " OR messages.conversation_key = ?"
clause += " OR UPPER(messages.conversation_key) = ?"
params.append(value.upper())
else:
clause += " OR messages.conversation_key LIKE ? ESCAPE '\\'"
clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'"
params.append(f"{MessageRepository._escape_like(value.upper())}%")
clause += "))"
@@ -283,13 +277,13 @@ class MessageRepository:
priv_key_clause: str
chan_key_clause: str
if len(value) == 64:
priv_key_clause = "messages.conversation_key = ?"
chan_key_clause = "sender_key = ?"
priv_key_clause = "LOWER(messages.conversation_key) = ?"
chan_key_clause = "LOWER(sender_key) = ?"
params.extend([lower_value, lower_value])
else:
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
priv_key_clause = "messages.conversation_key LIKE ? ESCAPE '\\'"
chan_key_clause = "sender_key LIKE ? ESCAPE '\\'"
priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'"
chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'"
params.extend([escaped_prefix, escaped_prefix])
clause += (
@@ -313,12 +307,12 @@ class MessageRepository:
if blocked_keys:
placeholders = ",".join("?" for _ in blocked_keys)
blocked_matchers.append(
f"({prefix}type = 'PRIV' AND {prefix}conversation_key IN ({placeholders}))"
f"({prefix}type = 'PRIV' AND LOWER({prefix}conversation_key) IN ({placeholders}))"
)
params.extend(blocked_keys)
blocked_matchers.append(
f"({prefix}type = 'CHAN' AND {prefix}sender_key IS NOT NULL"
f" AND {prefix}sender_key IN ({placeholders}))"
f" AND LOWER({prefix}sender_key) IN ({placeholders}))"
)
params.extend(blocked_keys)
@@ -385,9 +379,9 @@ class MessageRepository:
query = (
f"SELECT {MessageRepository._message_select('messages')} FROM messages "
"LEFT JOIN contacts ON messages.type = 'PRIV' "
"AND messages.conversation_key = contacts.public_key "
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
"LEFT JOIN channels ON messages.type = 'CHAN' "
"AND messages.conversation_key = channels.key "
"AND UPPER(messages.conversation_key) = UPPER(channels.key) "
"WHERE 1=1"
)
params: list[Any] = []
@@ -578,9 +572,6 @@ class MessageRepository:
@staticmethod
async def delete_by_id(message_id: int) -> None:
"""Delete a message row by ID."""
await db.conn.execute(
"UPDATE raw_packets SET message_id = NULL WHERE message_id = ?", (message_id,)
)
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
await db.conn.commit()
@@ -675,7 +666,7 @@ class MessageRepository:
ELSE 0
END) > 0 as has_mention
FROM messages m
LEFT JOIN contacts ct ON m.conversation_key = ct.public_key
JOIN contacts ct ON m.conversation_key = ct.public_key
WHERE m.type = 'PRIV' AND m.outgoing = 0
AND m.received_at > COALESCE(ct.last_read_at, 0)
{blocked_sql}
+41 -26
View File
@@ -1,4 +1,5 @@
import logging
import sqlite3
import time
from collections.abc import AsyncIterator
from hashlib import sha256
@@ -34,23 +35,46 @@ class RawPacketRepository:
# For malformed packets, hash the full data
payload_hash = sha256(data).digest()
cursor = await db.conn.execute(
"INSERT OR IGNORE INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
(ts, data, payload_hash),
)
await db.conn.commit()
if cursor.rowcount > 0:
assert cursor.lastrowid is not None
return (cursor.lastrowid, True)
# Duplicate payload — look up the existing row.
# Check if this payload already exists
cursor = await db.conn.execute(
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
)
existing = await cursor.fetchone()
assert existing is not None
return (existing["id"], False)
if existing:
# Duplicate - return existing packet ID
logger.debug(
"Duplicate payload detected (hash=%s..., existing_id=%d)",
payload_hash.hex()[:12],
existing["id"],
)
return (existing["id"], False)
# New packet - insert with hash
try:
cursor = await db.conn.execute(
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
(ts, data, payload_hash),
)
await db.conn.commit()
assert cursor.lastrowid is not None # INSERT always returns a row ID
return (cursor.lastrowid, True)
except sqlite3.IntegrityError:
# Race condition: another insert with same payload_hash happened between
# our SELECT and INSERT. This is expected for duplicate packets arriving
# close together. Query again to get the existing ID.
logger.debug(
"Duplicate packet detected via race condition (payload_hash=%s), dropping",
payload_hash.hex()[:16],
)
cursor = await db.conn.execute(
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
)
existing = await cursor.fetchone()
if existing:
return (existing["id"], False)
# This shouldn't happen, but if it does, re-raise
raise
@staticmethod
async def get_undecrypted_count() -> int:
@@ -71,22 +95,13 @@ class RawPacketRepository:
return row["oldest"] if row and row["oldest"] is not None else None
@staticmethod
async def stream_all_undecrypted(
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE,
) -> AsyncIterator[tuple[int, bytes, int]]:
"""Yield all undecrypted packets as (id, data, timestamp) in bounded batches."""
async def get_all_undecrypted() -> list[tuple[int, bytes, int]]:
"""Get all undecrypted packets as (id, data, timestamp) tuples."""
cursor = await db.conn.execute(
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
)
try:
while True:
rows = await cursor.fetchmany(batch_size)
if not rows:
break
for row in rows:
yield (row["id"], bytes(row["data"]), row["timestamp"])
finally:
await cursor.close()
rows = await cursor.fetchall()
return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
@staticmethod
async def stream_undecrypted_text_messages(
+13 -15
View File
@@ -27,9 +27,9 @@ class AppSettingsRepository:
cursor = await db.conn.execute(
"""
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
last_message_times, preferences_migrated,
sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types
blocked_keys, blocked_names
FROM app_settings WHERE id = 1
"""
)
@@ -81,18 +81,16 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError):
blocked_names = []
# Parse discovery_blocked_types JSON
discovery_blocked_types: list[int] = []
if row["discovery_blocked_types"]:
try:
discovery_blocked_types = json.loads(row["discovery_blocked_types"])
except (json.JSONDecodeError, TypeError):
discovery_blocked_types = []
# Validate sidebar_sort_order (fallback to "recent" if invalid)
sort_order = row["sidebar_sort_order"]
if sort_order not in ("recent", "alpha"):
sort_order = "recent"
return AppSettings(
max_radio_contacts=row["max_radio_contacts"],
favorites=favorites,
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
sidebar_sort_order=sort_order,
last_message_times=last_message_times,
preferences_migrated=bool(row["preferences_migrated"]),
advert_interval=row["advert_interval"] or 0,
@@ -100,7 +98,6 @@ class AppSettingsRepository:
flood_scope=row["flood_scope"] or "",
blocked_keys=blocked_keys,
blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
)
@staticmethod
@@ -108,6 +105,7 @@ class AppSettingsRepository:
max_radio_contacts: int | None = None,
favorites: list[Favorite] | None = None,
auto_decrypt_dm_on_advert: bool | None = None,
sidebar_sort_order: str | None = None,
last_message_times: dict[str, int] | None = None,
preferences_migrated: bool | None = None,
advert_interval: int | None = None,
@@ -115,7 +113,6 @@ class AppSettingsRepository:
flood_scope: str | None = None,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -134,6 +131,10 @@ class AppSettingsRepository:
updates.append("auto_decrypt_dm_on_advert = ?")
params.append(1 if auto_decrypt_dm_on_advert else 0)
if sidebar_sort_order is not None:
updates.append("sidebar_sort_order = ?")
params.append(sidebar_sort_order)
if last_message_times is not None:
updates.append("last_message_times = ?")
params.append(json.dumps(last_message_times))
@@ -162,10 +163,6 @@ class AppSettingsRepository:
updates.append("blocked_names = ?")
params.append(json.dumps(blocked_names))
if discovery_blocked_types is not None:
updates.append("discovery_blocked_types = ?")
params.append(json.dumps(discovery_blocked_types))
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params)
@@ -241,6 +238,7 @@ class AppSettingsRepository:
# Update with migrated preferences and mark as migrated
settings = await AppSettingsRepository.update(
favorites=new_favorites,
sidebar_sort_order=sort_order if sort_order in ("recent", "alpha") else "recent",
last_message_times=last_message_times,
preferences_migrated=True,
)
+47 -227
View File
@@ -1,8 +1,7 @@
import logging
import re
from hashlib import sha256
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.channel_constants import (
@@ -11,12 +10,10 @@ from app.channel_constants import (
is_public_channel_key,
is_public_channel_name,
)
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
from app.packet_processor import create_message_from_decrypted
from app.region_scope import normalize_region_scope
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
from app.websocket import broadcast_event, broadcast_success
from app.repository import ChannelRepository, MessageRepository
from app.websocket import broadcast_event
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
@@ -34,157 +31,12 @@ class CreateChannelRequest(BaseModel):
)
class BulkCreateHashtagChannelsRequest(BaseModel):
channel_names: list[str] = Field(
min_length=1,
description="List of hashtag room names. Leading # is optional per entry.",
)
try_historical: bool = Field(
default=False,
description="Attempt one background historical decrypt sweep for the newly added rooms.",
)
class BulkCreateHashtagChannelsResponse(BaseModel):
created_channels: list[Channel]
existing_count: int
invalid_names: list[str]
decrypt_started: bool = False
decrypt_total_packets: int = 0
message: str
class ChannelFloodScopeOverrideRequest(BaseModel):
flood_scope_override: str = Field(
description="Blank clears the override; non-empty values temporarily override flood scope"
)
def _derive_channel_identity(
requested_name: str,
request_key: str | None = None,
) -> tuple[str, str, bool]:
is_hashtag = requested_name.startswith("#")
if is_public_channel_name(requested_name):
if request_key:
try:
key_bytes = bytes.fromhex(request_key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)",
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
raise HTTPException(
status_code=400,
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
)
return PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME, False
if request_key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request_key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
key_hex = key_bytes.hex().upper()
if is_public_channel_key(key_hex):
raise HTTPException(
status_code=400,
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
)
return key_hex, requested_name, False
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
return key_bytes.hex().upper(), requested_name, is_hashtag
def _normalize_bulk_hashtag_name(name: str) -> str | None:
trimmed = name.strip()
if not trimmed:
return None
normalized = trimmed.lstrip("#").strip()
if not normalized:
return None
if len(normalized) > 31:
return None
if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized):
return None
return f"#{normalized}"
async def _run_historical_channel_decryption_for_channels(
channels: list[tuple[bytes, str, str]],
) -> None:
total = await RawPacketRepository.get_undecrypted_count()
decrypted_count = 0
matched_channel_names: set[str] = set()
if total == 0:
logger.info("No undecrypted packets to process for bulk channel decrypt")
return
logger.info(
"Starting bulk historical channel decryption of %d packets across %d channels",
total,
len(channels),
)
async for (
packet_id,
packet_data,
packet_timestamp,
) in RawPacketRepository.stream_all_undecrypted():
packet_info = parse_packet(packet_data)
path_hex = packet_info.path.hex() if packet_info else None
path_len = packet_info.path_length if packet_info else None
for channel_key_bytes, channel_key_hex, channel_name in channels:
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
if result is None:
continue
msg_id = await create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel_key_hex,
channel_name=channel_name,
sender=result.sender,
message_text=result.message,
timestamp=result.timestamp,
received_at=packet_timestamp,
path=path_hex,
path_len=path_len,
realtime=False,
)
if msg_id is not None:
decrypted_count += 1
matched_channel_names.add(channel_name)
break
logger.info(
"Bulk historical channel decryption complete: %d/%d packets decrypted across %d channels",
decrypted_count,
total,
len(matched_channel_names),
)
if decrypted_count > 0:
broadcast_success(
"Bulk historical decrypt complete",
(
f"Decrypted {decrypted_count} message{'s' if decrypted_count != 1 else ''} "
f"across {len(matched_channel_names)} room"
f"{'s' if len(matched_channel_names) != 1 else ''}"
),
)
@router.get("", response_model=list[Channel])
async def list_channels() -> list[Channel]:
"""List all channels from the database."""
@@ -217,7 +69,50 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
automatically when sending a message (see messages.py send_channel_message).
"""
requested_name = request.name
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key)
is_hashtag = requested_name.startswith("#")
# Reserve the canonical Public channel so it cannot drift to another key,
# and the well-known Public key cannot be renamed to something else.
if is_public_channel_name(requested_name):
if request.key:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)",
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
raise HTTPException(
status_code=400,
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
)
key_hex = PUBLIC_CHANNEL_KEY
channel_name = PUBLIC_CHANNEL_NAME
is_hashtag = False
elif request.key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
key_hex = key_bytes.hex().upper()
if is_public_channel_key(key_hex):
raise HTTPException(
status_code=400,
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
)
channel_name = requested_name
else:
# Derive key from name hash (same as meshcore library does)
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
key_hex = key_bytes.hex().upper()
channel_name = requested_name
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
@@ -237,81 +132,6 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
return stored
@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse)
async def bulk_create_hashtag_channels(
request: BulkCreateHashtagChannelsRequest,
background_tasks: BackgroundTasks,
response: Response,
) -> BulkCreateHashtagChannelsResponse:
created_channels: list[Channel] = []
existing_count = 0
invalid_names: list[str] = []
decrypt_started = False
decrypt_total_packets = 0
decrypt_targets: list[tuple[bytes, str, str]] = []
for raw_name in request.channel_names:
normalized_name = _normalize_bulk_hashtag_name(raw_name)
if normalized_name is None:
invalid_names.append(raw_name)
continue
key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name)
existing = await ChannelRepository.get_by_key(key_hex)
if existing is not None:
existing_count += 1
continue
await ChannelRepository.upsert(
key=key_hex,
name=channel_name,
is_hashtag=is_hashtag,
on_radio=False,
)
stored = await ChannelRepository.get_by_key(key_hex)
if stored is None:
raise HTTPException(
status_code=500,
detail="Channel was created but could not be reloaded",
)
created_channels.append(stored)
decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name))
_broadcast_channel_update(stored)
if request.try_historical and decrypt_targets:
decrypt_total_packets = await RawPacketRepository.get_undecrypted_count()
if decrypt_total_packets > 0:
background_tasks.add_task(
_run_historical_channel_decryption_for_channels, decrypt_targets
)
decrypt_started = True
response.status_code = status.HTTP_202_ACCEPTED
message = (
f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}"
if created_channels
else "No new rooms were added"
)
if request.try_historical and decrypt_targets:
if decrypt_started:
message += (
f" and started background decrypt of {decrypt_total_packets} packet"
f"{'s' if decrypt_total_packets != 1 else ''}"
)
else:
message += "; no undecrypted packets were available"
return BulkCreateHashtagChannelsResponse(
created_channels=created_channels,
existing_count=existing_count,
invalid_names=invalid_names,
decrypt_started=decrypt_started,
decrypt_total_packets=decrypt_total_packets,
message=message,
)
@router.post("/{key}/mark-read")
async def mark_channel_read(key: str) -> dict:
"""Mark a channel as read (update last_read_at timestamp)."""
+3 -50
View File
@@ -1,12 +1,10 @@
import asyncio
import logging
import random
import time
from contextlib import suppress
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import (
@@ -33,7 +31,7 @@ from app.repository import (
)
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
reconcile_contact_messages,
)
from app.services.radio_runtime import radio_runtime as radio_manager
@@ -279,18 +277,12 @@ async def create_contact(
# Check if contact already exists
existing = await ContactRepository.get_by_key(request.public_key)
if existing:
# Update name if provided and record name history
# Update name if provided
if request.name:
await ContactRepository.upsert(existing.to_upsert(name=request.name))
refreshed = await ContactRepository.get_by_key(request.public_key)
if refreshed is not None:
existing = refreshed
await record_contact_name_and_reconcile(
public_key=request.public_key,
contact_name=request.name,
timestamp=int(time.time()),
log=logger,
)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=request.public_key,
@@ -325,10 +317,9 @@ async def create_contact(
log=logger,
)
await record_contact_name_and_reconcile(
await reconcile_contact_messages(
public_key=lower_key,
contact_name=request.name,
timestamp=int(time.time()),
log=logger,
)
@@ -356,44 +347,6 @@ async def mark_contact_read(public_key: str) -> dict:
return {"status": "ok", "public_key": contact.public_key}
class BulkDeleteRequest(BaseModel):
public_keys: list[str] = Field(description="Public keys to delete")
@router.post("/bulk-delete")
async def bulk_delete_contacts(request: BulkDeleteRequest) -> dict:
"""Delete multiple contacts from the database (and radio if present)."""
from app.websocket import broadcast_event
# Resolve all contacts first
contacts_to_delete: list[Contact] = []
for key in request.public_keys:
contact = await ContactRepository.get_by_key(key.lower())
if contact:
contacts_to_delete.append(contact)
# Remove from radio in a single locked operation (blocks until radio is free)
if radio_manager.is_connected and contacts_to_delete:
try:
async with radio_manager.radio_operation("bulk_delete_contacts_from_radio") as mc:
for contact in contacts_to_delete:
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
await mc.commands.remove_contact(radio_contact)
except Exception as e:
logger.warning("Radio removal during bulk delete failed: %s", e)
# Delete from database and broadcast events
deleted = 0
for contact in contacts_to_delete:
await ContactRepository.delete(contact.public_key)
broadcast_event("contact_deleted", {"public_key": contact.public_key})
deleted += 1
logger.info("Bulk deleted %d/%d contacts", deleted, len(request.public_keys))
return {"deleted": deleted}
@router.delete("/{public_key}")
async def delete_contact(public_key: str) -> dict:
"""Delete a contact from the database (and radio if present)."""
+18 -84
View File
@@ -5,7 +5,7 @@ import platform
import struct
import sys
from datetime import datetime, timezone
from typing import Any, Literal
from typing import Any
from fastapi import APIRouter
from meshcore import EventType
@@ -15,7 +15,7 @@ from app.config import get_recent_log_lines, settings
from app.models import AppSettings
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository
from app.routers.health import FanoutStatusResponse, build_health_data
from app.routers.health import HealthResponse, build_health_data
from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output
@@ -61,6 +61,8 @@ class DebugRuntimeInfo(BaseModel):
setup_in_progress: bool
setup_complete: bool
channels_with_incoming_messages: int
max_channels: int
path_hash_mode: int
path_hash_mode_supported: bool
channel_slot_reuse_enabled: bool
channel_send_cache_capacity: int
@@ -87,6 +89,7 @@ class DebugChannelAudit(BaseModel):
class DebugRadioProbe(BaseModel):
performed: bool
errors: list[str] = Field(default_factory=list)
multi_acks_enabled: bool | None = None
self_info: dict[str, Any] | None = None
device_info: dict[str, Any] | None = None
stats_core: dict[str, Any] | None = None
@@ -101,15 +104,6 @@ class DebugDatabaseInfo(BaseModel):
total_outgoing: int
class DebugHealthSummary(BaseModel):
radio_state: str
database_size_mb: float
oldest_undecrypted_timestamp: int | None
fanouts_with_errors: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
bots_disabled_source: Literal["env", "until_restart"] | None = None
basic_auth_enabled: bool = False
class DebugAppSettings(BaseModel):
max_radio_contacts: int
auto_decrypt_dm_on_advert: bool
@@ -123,7 +117,7 @@ class DebugSnapshotResponse(BaseModel):
captured_at: str
system: DebugSystemInfo
application: DebugApplicationInfo
health: DebugHealthSummary
health: HealthResponse
settings: DebugAppSettings
runtime: DebugRuntimeInfo
database: DebugDatabaseInfo
@@ -214,57 +208,6 @@ def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
)
def _derive_debug_radio_state(
*,
radio_connected: bool,
connection_desired: bool,
setup_in_progress: bool,
setup_complete: bool,
is_reconnecting: bool,
) -> str:
if not connection_desired:
return "paused"
if radio_connected and (setup_in_progress or not setup_complete):
return "initializing"
if radio_connected:
return "connected"
if is_reconnecting:
return "connecting"
return "disconnected"
def _build_debug_health_summary(
health_data: dict[str, Any], *, radio_state: str
) -> DebugHealthSummary:
def _fanout_last_error(status: Any) -> str | None:
if isinstance(status, dict):
value = status.get("last_error")
else:
value = getattr(status, "last_error", None)
return value if isinstance(value, str) and value else None
fanouts_with_errors = {
config_id: status
for config_id, status in health_data["fanout_statuses"].items()
if _fanout_last_error(status)
}
return DebugHealthSummary(
radio_state=radio_state,
database_size_mb=health_data["database_size_mb"],
oldest_undecrypted_timestamp=health_data["oldest_undecrypted_timestamp"],
fanouts_with_errors=fanouts_with_errors,
bots_disabled_source=health_data["bots_disabled_source"],
basic_auth_enabled=health_data["basic_auth_enabled"],
)
def _sanitize_radio_probe_self_info(self_info: dict[str, Any] | None) -> dict[str, Any]:
sanitized = dict(self_info or {})
sanitized.pop("adv_lat", None)
sanitized.pop("adv_lon", None)
return sanitized
async def _build_contact_audit(
observed_contacts_payload: dict[str, dict[str, Any]],
) -> DebugContactAudit:
@@ -349,7 +292,10 @@ async def _probe_radio() -> DebugRadioProbe:
return DebugRadioProbe(
performed=True,
errors=errors,
self_info=_sanitize_radio_probe_self_info(mc.self_info),
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
if mc.self_info is not None
else None,
self_info=dict(mc.self_info or {}),
device_info=device_info,
stats_core=stats_core,
stats_radio=stats_radio,
@@ -368,39 +314,27 @@ async def _probe_radio() -> DebugRadioProbe:
@router.get("/debug", response_model=DebugSnapshotResponse)
async def debug_support_snapshot() -> DebugSnapshotResponse:
"""Return a support/debug snapshot with recent logs and live radio state."""
connection_info = radio_runtime.connection_info
connection_desired = radio_runtime.connection_desired
setup_in_progress = radio_runtime.is_setup_in_progress
setup_complete = radio_runtime.is_setup_complete
radio_connected = radio_runtime.is_connected
is_reconnecting = getattr(radio_runtime, "is_reconnecting", False)
health_data = await build_health_data(radio_connected, connection_info)
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
app_settings = await AppSettingsRepository.get()
message_totals = await StatisticsRepository.get_database_message_totals()
radio_probe = await _probe_radio()
channels_with_incoming_messages = (
await MessageRepository.count_channels_with_incoming_messages()
)
radio_state = _derive_debug_radio_state(
radio_connected=radio_connected,
connection_desired=connection_desired,
setup_in_progress=setup_in_progress,
setup_complete=setup_complete,
is_reconnecting=is_reconnecting,
)
return DebugSnapshotResponse(
captured_at=datetime.now(timezone.utc).isoformat(),
system=_build_system_info(),
application=_build_application_info(),
health=_build_debug_health_summary(health_data, radio_state=radio_state),
health=HealthResponse(**health_data),
settings=_build_debug_app_settings(app_settings),
runtime=DebugRuntimeInfo(
connection_info=connection_info,
connection_desired=connection_desired,
setup_in_progress=setup_in_progress,
setup_complete=setup_complete,
connection_info=radio_runtime.connection_info,
connection_desired=radio_runtime.connection_desired,
setup_in_progress=radio_runtime.is_setup_in_progress,
setup_complete=radio_runtime.is_setup_complete,
channels_with_incoming_messages=channels_with_incoming_messages,
max_channels=radio_runtime.max_channels,
path_hash_mode=radio_runtime.path_hash_mode,
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
+3 -6
View File
@@ -49,7 +49,8 @@ async def _run_historical_channel_decryption(
channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None
) -> None:
"""Background task to decrypt historical packets with a channel key."""
total = await RawPacketRepository.get_undecrypted_count()
packets = await RawPacketRepository.get_all_undecrypted()
total = len(packets)
decrypted_count = 0
if total == 0:
@@ -58,11 +59,7 @@ async def _run_historical_channel_decryption(
logger.info("Starting historical channel decryption of %d packets", total)
async for (
packet_id,
packet_data,
packet_timestamp,
) in RawPacketRepository.stream_all_undecrypted():
for packet_id, packet_data, packet_timestamp in packets:
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
if result is not None:
+3 -14
View File
@@ -24,10 +24,7 @@ from app.models import (
from app.radio_sync import send_advertisement as do_send_advertisement
from app.radio_sync import sync_radio_time
from app.repository import ContactRepository
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.contact_reconciliation import promote_prefix_contacts_for_contact
from app.services.radio_commands import (
KeystoreRefreshError,
PathHashModeUnsupportedError,
@@ -217,19 +214,11 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
public_key=result.public_key,
log=logger,
)
await reconcile_contact_messages(
public_key=result.public_key,
contact_name=result.name,
log=logger,
)
created = await ContactRepository.get_by_key(result.public_key)
if created is not None:
broadcast_event("contact", created.model_dump())
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{"previous_public_key": old_key, "contact": created.model_dump()},
)
for old_key in promoted_keys:
broadcast_event("contact_deleted", {"public_key": old_key})
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
+2 -5
View File
@@ -153,12 +153,9 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
return response
@router.get(
"/{public_key}/repeater/telemetry-history",
response_model=list[TelemetryHistoryEntry],
)
@router.get("/{public_key}/repeater/telemetry-history")
async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
"""Return stored telemetry history for a repeater (read-only, no radio access)."""
"""Return stored telemetry history for a repeater (no radio command needed)."""
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
+11 -18
View File
@@ -230,27 +230,20 @@ async def batch_cli_fetch(
operation_name: str,
commands: list[tuple[str, str]],
) -> dict[str, str | None]:
"""Send a batch of CLI commands to a server-capable contact and collect responses.
Each command acquires and releases the radio lock independently so that
other operations (sends, syncs) can slip in between commands.
"""
"""Send a batch of CLI commands to a server-capable contact and collect responses."""
results: dict[str, str | None] = {field: None for _, field in commands}
for index, (cmd, field) in enumerate(commands):
if index > 0:
# Yield briefly so queued operations can acquire the lock.
await asyncio.sleep(0.25)
async with radio_manager.radio_operation(
operation_name,
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
async with radio_manager.radio_operation(
operation_name,
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
# Re-ensure contact is loaded each iteration; another operation
# may have evicted it while we didn't hold the lock.
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0) # settle after add_contact
for index, (cmd, field) in enumerate(commands):
if index > 0:
await asyncio.sleep(1.0)
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
if send_result.type == EventType.ERROR:
+8 -13
View File
@@ -27,6 +27,10 @@ class AppSettingsUpdate(BaseModel):
default=None,
description="Whether to attempt historical DM decryption on new contact advertisement",
)
sidebar_sort_order: Literal["recent", "alpha"] | None = Field(
default=None,
description="Sidebar sort order: 'recent' or 'alpha'",
)
advert_interval: int | None = Field(
default=None,
ge=0,
@@ -44,13 +48,6 @@ class AppSettingsUpdate(BaseModel):
default=None,
description="Display names whose messages are hidden from the UI",
)
discovery_blocked_types: list[int] | None = Field(
default=None,
description=(
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
"advertisements should not create new contacts"
),
)
class BlockKeyRequest(BaseModel):
@@ -107,6 +104,10 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert)
kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert
if update.sidebar_sort_order is not None:
logger.info("Updating sidebar_sort_order to %s", update.sidebar_sort_order)
kwargs["sidebar_sort_order"] = update.sidebar_sort_order
if update.advert_interval is not None:
# Enforce minimum 1-hour interval; 0 means disabled
interval = update.advert_interval
@@ -121,12 +122,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
if update.blocked_names is not None:
kwargs["blocked_names"] = update.blocked_names
# Discovery blocked types
if update.discovery_blocked_types is not None:
# Only allow valid contact type codes (1-4)
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
kwargs["discovery_blocked_types"] = sorted(set(valid))
# Flood scope
flood_scope_changed = False
if update.flood_scope is not None:
+20 -28
View File
@@ -204,43 +204,35 @@ async def run_post_connect_setup(radio_manager) -> None:
finally:
reader.handle_rx = _original_handle_rx
from app.config import settings as app_settings_config
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
if app_settings_config.skip_post_connect_sync:
logger.info(
"Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)"
)
# Send advertisement to announce our presence (if enabled and not throttled)
if await send_advertisement(mc):
logger.info("Advertisement sent")
else:
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
logger.debug("Advertisement skipped (disabled or throttled)")
# Send advertisement to announce our presence (if enabled and not throttled)
if await send_advertisement(mc):
logger.info("Advertisement sent")
else:
logger.debug("Advertisement skipped (disabled or throttled)")
# Drain any messages that were queued before we connected.
# This must happen BEFORE starting auto-fetch, otherwise both
# compete on get_msg() with interleaved radio I/O.
drained = await drain_pending_messages(mc)
if drained > 0:
logger.info("Drained %d pending message(s)", drained)
radio_manager.clear_pending_message_channel_slots()
# Drain any messages that were queued before we connected.
# This must happen BEFORE starting auto-fetch, otherwise both
# compete on get_msg() with interleaved radio I/O.
drained = await drain_pending_messages(mc)
if drained > 0:
logger.info("Drained %d pending message(s)", drained)
radio_manager.clear_pending_message_channel_slots()
await mc.start_auto_message_fetching()
logger.info("Auto message fetching started")
finally:
radio_manager._release_operation_lock("post_connect_setup")
if not app_settings_config.skip_post_connect_sync:
# Start background tasks AFTER releasing the operation lock.
# These tasks acquire their own locks when they need radio access.
start_periodic_sync()
start_periodic_advert()
start_message_polling()
# Start background tasks AFTER releasing the operation lock.
# These tasks acquire their own locks when they need radio access.
start_periodic_sync()
start_periodic_advert()
start_message_polling()
radio_manager._setup_complete = True
finally:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 112 KiB

+3 -1
View File
@@ -350,13 +350,15 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `max_radio_contacts`
- `favorites`
- `auto_decrypt_dm_on_advert`
- `sidebar_sort_order`
- `last_message_times`
- `preferences_migrated`
- `advert_interval`
- `last_advert_time`
- `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types`
- `blocked_keys`, `blocked_names`
The backend still carries `sidebar_sort_order` for compatibility and old preference migration, but the current sidebar UI stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in frontend localStorage rather than treating it as one global server-backed setting.
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.7.1",
"version": "3.6.7",
"type": "module",
"scripts": {
"dev": "vite",
+7 -43
View File
@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react';
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
import { api } from './api';
import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
@@ -23,7 +23,7 @@ import type { MessageInputHandle } from './components/MessageInput';
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
import type { Conversation, Message, RawPacket } from './types';
import { CONTACT_TYPE_ROOM } from './types';
interface ChannelUnreadMarker {
@@ -85,8 +85,6 @@ export function App() {
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
useState<NewMessagePrefillRequest | null>(null);
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
@@ -192,7 +190,6 @@ export function App() {
handleCreateContact,
handleCreateChannel,
handleCreateHashtagChannel,
handleBulkCreateHashtagChannels,
handleDeleteChannel,
handleDeleteContact,
} = useContactsAndChannels({
@@ -424,25 +421,16 @@ export function App() {
[fetchUndecryptedCount, setChannels]
);
const handleOpenNewMessage = useCallback(
(event?: MouseEvent<HTMLButtonElement>) => {
setNewMessagePrefillRequest(null);
setShowBulkAddChannelTab(event?.altKey === true);
openNewMessageModal();
},
[openNewMessageModal]
);
const handleOpenNewMessage = useCallback(() => {
setNewMessagePrefillRequest(null);
openNewMessageModal();
}, [openNewMessageModal]);
const handleCloseNewMessage = useCallback(() => {
setNewMessagePrefillRequest(null);
setShowBulkAddChannelTab(false);
closeNewMessageModal();
}, [closeNewMessageModal]);
const handleCloseBulkAddResults = useCallback(() => {
setBulkAddResult(null);
}, []);
const handleChannelReferenceClick = useCallback(
(channelName: string) => {
const existingChannel = channels.find((channel) => channel.name === channelName);
@@ -456,20 +444,11 @@ export function App() {
hashtagName: channelName.slice(1),
nonce: (previous?.nonce ?? 0) + 1,
}));
setShowBulkAddChannelTab(false);
openNewMessageModal();
},
[channels, handleNavigateToChannel, openNewMessageModal]
);
const handleBulkAddChannels = useCallback(
async (channelNames: string[], tryHistorical: boolean) => {
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
setBulkAddResult(result);
},
[handleBulkCreateHashtagChannels]
);
const statusProps = {
health,
config,
@@ -490,12 +469,8 @@ export function App() {
void markAllRead();
},
favorites,
legacySortOrder: appSettings?.sidebar_sort_order,
isConversationNotificationsEnabled,
blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [],
};
const bulkAddChannelResultModalProps = {
result: bulkAddResult,
};
const conversationPaneProps = {
activeConversation,
@@ -507,7 +482,6 @@ export function App() {
health,
favorites,
messages: sortedMessages,
preSorted: activeContactIsRoom,
messagesLoading,
loadingOlder,
hasOlderMessages,
@@ -581,11 +555,6 @@ export function App() {
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
contacts,
onBulkDeleteContacts: (deletedKeys: string[]) => {
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
},
};
const crackerProps = {
packets: rawPackets,
@@ -594,12 +563,10 @@ export function App() {
};
const newMessageModalProps = {
undecryptedCount,
showBulkAddChannelTab,
prefillRequest: newMessagePrefillRequest,
onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel,
onBulkAddHashtagChannels: handleBulkAddChannels,
};
const contactInfoPaneProps = {
contactKey: infoPaneContactKey,
@@ -663,7 +630,6 @@ export function App() {
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showBulkAddResults={bulkAddResult !== null}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
@@ -674,7 +640,6 @@ export function App() {
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onCloseBulkAddResults={handleCloseBulkAddResults}
onLocalLabelChange={setLocalLabel}
statusProps={statusProps}
sidebarProps={sidebarProps}
@@ -683,7 +648,6 @@ export function App() {
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
/>
+3 -15
View File
@@ -1,7 +1,6 @@
import type {
AppSettings,
AppSettingsUpdate,
BulkCreateHashtagChannelsResult,
Channel,
ChannelDetail,
CommandResponse,
@@ -35,8 +34,8 @@ import type {
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
StatisticsResponse,
TelemetryHistoryEntry,
TraceResponse,
UnreadCounts,
} from './types';
@@ -151,12 +150,6 @@ export const api = {
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',
}),
bulkDeleteContacts: (publicKeys: string[]) =>
fetchJson<{ deleted: number }>('/contacts/bulk-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ public_keys: publicKeys }),
}),
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
fetchJson<Contact>('/contacts', {
method: 'POST',
@@ -192,11 +185,6 @@ export const api = {
method: 'POST',
body: JSON.stringify({ name, key }),
}),
bulkCreateHashtagChannels: (channelNames: string[], tryHistorical?: boolean) =>
fetchJson<BulkCreateHashtagChannelsResult>('/channels/bulk-hashtag', {
method: 'POST',
body: JSON.stringify({ channel_names: channelNames, try_historical: tryHistorical }),
}),
deleteChannel: (key: string) =>
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
@@ -387,6 +375,8 @@ export const api = {
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/repeater/status`, {
method: 'POST',
}),
repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
repeaterNeighbors: (publicKey: string) =>
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
method: 'POST',
@@ -415,8 +405,6 @@ export const api = {
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST',
}),
repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST',
-16
View File
@@ -5,7 +5,6 @@ import { StatusBar } from './StatusBar';
import { Sidebar } from './Sidebar';
import { ConversationPane } from './ConversationPane';
import { NewMessageModal } from './NewMessageModal';
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane';
import { SecurityWarningModal } from './SecurityWarningModal';
@@ -34,17 +33,12 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S
type SidebarProps = ComponentProps<typeof Sidebar>;
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
type BulkAddChannelResultModalProps = Omit<
ComponentProps<typeof BulkAddChannelResultModal>,
'open' | 'onClose'
>;
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
interface AppShellProps {
localLabel: LocalLabel;
showNewMessage: boolean;
showBulkAddResults: boolean;
showSettings: boolean;
settingsSection: SettingsSection;
sidebarOpen: boolean;
@@ -56,7 +50,6 @@ interface AppShellProps {
onToggleSettingsView: () => void;
onCloseSettingsView: () => void;
onCloseNewMessage: () => void;
onCloseBulkAddResults: () => void;
onLocalLabelChange: (label: LocalLabel) => void;
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
sidebarProps: SidebarProps;
@@ -68,7 +61,6 @@ interface AppShellProps {
>;
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
newMessageModalProps: NewMessageModalProps;
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
contactInfoPaneProps: ContactInfoPaneProps;
channelInfoPaneProps: ChannelInfoPaneProps;
}
@@ -76,7 +68,6 @@ interface AppShellProps {
export function AppShell({
localLabel,
showNewMessage,
showBulkAddResults,
showSettings,
settingsSection,
sidebarOpen,
@@ -88,7 +79,6 @@ export function AppShell({
onToggleSettingsView,
onCloseSettingsView,
onCloseNewMessage,
onCloseBulkAddResults,
onLocalLabelChange,
statusProps,
sidebarProps,
@@ -97,7 +87,6 @@ export function AppShell({
settingsProps,
crackerProps,
newMessageModalProps,
bulkAddChannelResultModalProps,
contactInfoPaneProps,
channelInfoPaneProps,
}: AppShellProps) {
@@ -317,11 +306,6 @@ export function AppShell({
open={showNewMessage}
onClose={onCloseNewMessage}
/>
<BulkAddChannelResultModal
{...bulkAddChannelResultModalProps}
open={showBulkAddResults}
onClose={onCloseBulkAddResults}
/>
<SecurityWarningModal health={statusProps.health} />
<ContactInfoPane {...contactInfoPaneProps} />
@@ -1,101 +0,0 @@
import type { BulkCreateHashtagChannelsResult, Channel } from '../types';
import { getConversationHash } from '../utils/urlHash';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
interface BulkAddChannelResultModalProps {
open: boolean;
result: BulkCreateHashtagChannelsResult | null;
onClose: () => void;
}
function getChannelHref(channel: Channel): string {
const hash = getConversationHash({
type: 'channel',
id: channel.key,
name: channel.name,
});
if (typeof window === 'undefined') {
return hash;
}
return `${window.location.origin}${window.location.pathname}${hash}`;
}
export function BulkAddChannelResultModal({
open,
result,
onClose,
}: BulkAddChannelResultModalProps) {
const createdChannels = result?.created_channels ?? [];
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Bulk Add Complete</DialogTitle>
<DialogDescription>
{result?.message ?? 'Review the newly added rooms below.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{result && (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">Created</div>
<div className="mt-1 font-medium">{createdChannels.length}</div>
</div>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Already Present
</div>
<div className="mt-1 font-medium">{result.existing_count}</div>
</div>
</div>
)}
{createdChannels.length > 0 ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Ctrl+click any room to open it in a new tab.
</p>
<div className="max-h-64 overflow-y-auto rounded-md border border-border/70">
<ul className="divide-y divide-border/70">
{createdChannels.map((channel) => (
<li key={channel.key}>
<a
href={getChannelHref(channel)}
className="block px-3 py-2 text-sm text-primary hover:bg-accent hover:text-primary"
>
{channel.name}
</a>
</li>
))}
</ul>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No new rooms were added.</p>
)}
{result && result.invalid_names.length > 0 && (
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
Ignored invalid room names: {result.invalid_names.join(', ')}
</div>
)}
</div>
<DialogFooter>
<Button onClick={onClose}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+32 -112
View File
@@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { type ReactNode, useEffect, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react';
import {
LineChart,
@@ -35,7 +35,6 @@ import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import { CONTACT_TYPE_REPEATER } from '../types';
import type {
Contact,
ContactActiveRoom,
@@ -159,7 +158,6 @@ export function ContactInfoPane({
contact !== null &&
!isPrefixOnlyResolvedContact &&
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
return (
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
@@ -442,7 +440,7 @@ export function ContactInfoPane({
</div>
)}
{!isRepeater && onSearchMessagesByKey && (
{onSearchMessagesByKey && (
<div className="px-5 py-3 border-b border-border">
<button
type="button"
@@ -455,60 +453,40 @@ export function ContactInfoPane({
</div>
)}
{/* Nearest Repeaters (Hops) — last 7 days only */}
{analytics &&
(() => {
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
const recent = analytics.nearest_repeaters.filter(
(r) => r.last_seen >= sevenDaysAgo
);
if (recent.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters Hops (last 7 days)</SectionLabel>
<div className="space-y-1">
{recent.map((r) => (
<div
key={r.public_key}
className="flex justify-between items-center text-sm"
>
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{r.path_len === 0
? 'direct'
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
· {r.heard_count}x
</span>
</div>
))}
{/* Nearest Repeaters */}
{analytics && analytics.nearest_repeaters.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters</SectionLabel>
<div className="space-y-1">
{analytics.nearest_repeaters.map((r) => (
<div key={r.public_key} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{r.path_len === 0
? 'direct'
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
· {r.heard_count}x
</span>
</div>
</div>
);
})()}
{/* Geographically nearest repeaters (repeaters only) */}
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
<NearbyRepeatersSection
contact={contact}
contacts={contacts}
distanceUnit={distanceUnit}
/>
))}
</div>
</div>
)}
{/* Advert Paths */}
{analytics && analytics.advert_paths.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Recent Advert Paths</SectionLabel>
<div className="space-y-1.5">
<div className="space-y-1">
{analytics.advert_paths.map((p) => (
<div
key={p.path + p.first_seen}
className="flex justify-between items-start gap-2 text-sm"
className="flex justify-between items-center text-sm"
>
<span className="font-mono text-xs break-all">
<span className="font-mono text-xs truncate">
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{p.heard_count}x · {formatTime(p.last_seen)}
</span>
</div>
@@ -540,21 +518,17 @@ export function ContactInfoPane({
</div>
)}
{!isRepeater && (
<>
<MessageStatsSection
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<MessageStatsSection
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<ActivityChartsSection analytics={analytics} />
<ActivityChartsSection analytics={analytics} />
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</>
)}
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -852,60 +826,6 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
);
}
function NearbyRepeatersSection({
contact,
contacts,
distanceUnit,
}: {
contact: Contact;
contacts: Contact[];
distanceUnit: import('../utils/distanceUnits').DistanceUnit;
}) {
const nearby = useMemo(() => {
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
const results: Array<{ name: string; publicKey: string; distance: number }> = [];
for (const other of contacts) {
const heardAt = Math.max(other.last_seen ?? 0, other.last_advert ?? 0);
if (
other.public_key === contact.public_key ||
other.type !== CONTACT_TYPE_REPEATER ||
!isValidLocation(other.lat, other.lon) ||
heardAt < sevenDaysAgo
) {
continue;
}
const dist = calculateDistance(contact.lat, contact.lon, other.lat, other.lon);
if (dist !== null) {
results.push({
name: getContactDisplayName(other.name, other.public_key, other.last_advert),
publicKey: other.public_key,
distance: dist,
});
}
}
results.sort((a, b) => a.distance - b.distance);
return results.slice(0, 5);
}, [contact.public_key, contact.lat, contact.lon, contacts]);
if (nearby.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters Geo (last 7 days)</SectionLabel>
<div className="space-y-1">
{nearby.map((r) => (
<div key={r.publicKey} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{formatDistance(r.distance, distanceUnit)}
</span>
</div>
))}
</div>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
return (
<div>
@@ -44,7 +44,6 @@ interface ConversationPaneProps {
notificationsPermission: NotificationPermission | 'unsupported';
favorites: Favorite[];
messages: Message[];
preSorted?: boolean;
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
@@ -115,7 +114,6 @@ export function ConversationPane({
notificationsPermission,
favorites,
messages,
preSorted,
messagesLoading,
loadingOlder,
hasOlderMessages,
@@ -235,7 +233,6 @@ export function ConversationPane({
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo}
/>
</Suspense>
);
@@ -277,7 +274,6 @@ export function ConversationPane({
<MessageList
key={activeConversation.id}
messages={messages}
preSorted={preSorted}
contacts={contacts}
channels={channels}
loading={messagesLoading}
+2 -7
View File
@@ -47,7 +47,6 @@ interface MessageListProps {
loadingNewer?: boolean;
onLoadNewer?: () => void;
onJumpToBottom?: () => void;
preSorted?: boolean;
}
// URL regex for linkifying plain text
@@ -284,7 +283,6 @@ export function MessageList({
loadingNewer = false,
onLoadNewer,
onJumpToBottom,
preSorted = false,
}: MessageListProps) {
const listRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef<number>(0);
@@ -488,11 +486,8 @@ export function MessageList({
// Note: Deduplication is handled by useConversationMessages.observeMessage()
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
const sortedMessages = useMemo(
() =>
preSorted
? messages
: [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
[messages, preSorted]
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
[messages]
);
const unreadMarkerIndex = useMemo(() => {
if (unreadMarkerLastReadAt === undefined) {
+41 -166
View File
@@ -3,29 +3,23 @@ import { Dice5 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from './ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button';
import { toast } from './ui/sonner';
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag';
interface BulkParseResult {
channelNames: string[];
invalidNames: string[];
}
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
interface NewMessageModalProps {
open: boolean;
undecryptedCount: number;
showBulkAddChannelTab?: boolean;
prefillRequest?: {
tab: 'hashtag';
hashtagName: string;
@@ -35,121 +29,53 @@ interface NewMessageModalProps {
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
}
function validateHashtagName(channelName: string): string | null {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
}
function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult {
const tokens = rawText
.split(/[\s,]+/)
.map((token) => token.trim())
.filter(Boolean);
const invalidNames: string[] = [];
const channelNames: string[] = [];
const seen = new Set<string>();
for (const token of tokens) {
const stripped = token.replace(/^#+/, '');
const validationError = validateHashtagName(stripped);
if (validationError) {
invalidNames.push(token);
continue;
}
const normalized = permitCapitals ? stripped : stripped.toLowerCase();
const channelName = `#${normalized}`;
if (seen.has(channelName)) {
continue;
}
seen.add(channelName);
channelNames.push(channelName);
}
return { channelNames, invalidNames };
}
export function NewMessageModal({
open,
undecryptedCount,
showBulkAddChannelTab = false,
prefillRequest = null,
onClose,
onCreateContact,
onCreateChannel,
onCreateHashtagChannel,
onBulkAddHashtagChannels,
}: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('new-contact');
const [name, setName] = useState('');
const [contactKey, setContactKey] = useState('');
const [channelKey, setChannelKey] = useState('');
const [bulkChannelText, setBulkChannelText] = useState('');
const [tryHistorical, setTryHistorical] = useState(false);
const [permitCapitals, setPermitCapitals] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const hashtagInputRef = useRef<HTMLInputElement>(null);
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
const resetForm = () => {
setName('');
setContactKey('');
setChannelKey('');
setBulkChannelText('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
};
useEffect(() => {
if (!open) {
if (!open || !prefillRequest) {
return;
}
if (prefillRequest) {
setTab(prefillRequest.tab);
setName(prefillRequest.hashtagName);
setContactKey('');
setChannelKey('');
setBulkChannelText('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
setLoading(false);
requestAnimationFrame(() => {
hashtagInputRef.current?.focus();
});
return;
}
if (showBulkAddChannelTab) {
setTab('bulk-hashtag');
setName('');
setContactKey('');
setChannelKey('');
setBulkChannelText('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
setLoading(false);
requestAnimationFrame(() => {
bulkTextareaRef.current?.focus();
});
return;
}
setTab('new-contact');
}, [open, prefillRequest, showBulkAddChannelTab]);
setTab(prefillRequest.tab);
setName(prefillRequest.hashtagName);
setContactKey('');
setChannelKey('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
setLoading(false);
requestAnimationFrame(() => {
hashtagInputRef.current?.focus();
});
}, [open, prefillRequest]);
const handleCreate = async () => {
setError('');
@@ -161,6 +87,7 @@ export function NewMessageModal({
setError('Name and public key are required');
return;
}
// handleCreateContact sets activeConversation with the backend-normalized key
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
} else if (tab === 'new-channel') {
if (!name.trim() || !channelKey.trim()) {
@@ -175,24 +102,10 @@ export function NewMessageModal({
setError(validationError);
return;
}
// Normalize to lowercase unless user explicitly permits capitals
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
} else {
const { channelNames, invalidNames } = parseBulkHashtagNames(
bulkChannelText,
permitCapitals
);
if (channelNames.length === 0) {
setError('Enter at least one valid room name');
return;
}
if (invalidNames.length > 0) {
setError(`Invalid room names: ${invalidNames.join(', ')}`);
return;
}
await onBulkAddHashtagChannels(channelNames, tryHistorical);
}
resetForm();
onClose();
} catch (err) {
@@ -205,6 +118,16 @@ export function NewMessageModal({
}
};
const validateHashtagName = (channelName: string): string | null => {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
};
const handleCreateAndAddAnother = async () => {
setError('');
const channelName = name.trim();
@@ -216,6 +139,7 @@ export function NewMessageModal({
setLoading(true);
try {
// Normalize to lowercase unless user explicitly permits capitals
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
setName('');
@@ -242,36 +166,28 @@ export function NewMessageModal({
}
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
<DialogDescription className="sr-only">
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
{tab === 'hashtag' && 'Join a public hashtag channel'}
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
</DialogDescription>
</DialogHeader>
<Tabs
value={tab}
onValueChange={(value) => {
setTab(value as Tab);
onValueChange={(v) => {
setTab(v as Tab);
resetForm();
}}
className="w-full"
>
<TabsList
className={
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
}
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="new-contact">Contact</TabsTrigger>
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
{showBulkAddChannelTab && (
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
)}
</TabsList>
<TabsContent value="new-contact" className="mt-4 space-y-4">
@@ -323,7 +239,7 @@ export function NewMessageModal({
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const hex = Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, '0'))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
setChannelKey(hex);
}}
@@ -352,55 +268,20 @@ export function NewMessageModal({
</div>
</div>
<div className="mt-3 space-y-1">
<label className="flex cursor-pointer items-center gap-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in channel key derivation</span>
</label>
<p className="pl-7 text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground pl-7">
Not recommended; most companions normalize to lowercase
</p>
</div>
</TabsContent>
{showBulkAddChannelTab && (
<TabsContent value="bulk-hashtag" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="bulk-hashtag-names">Bulk Add Channel</Label>
<textarea
ref={bulkTextareaRef}
id="bulk-hashtag-names"
aria-label="Bulk channel names"
value={bulkChannelText}
onChange={(e) => setBulkChannelText(e.target.value)}
placeholder={'#ops\nmesh-room\nanother-room'}
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Paste room names separated by lines, spaces, or commas. Leading # marks are
stripped automatically.
</p>
</div>
<div className="space-y-1">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in channel key derivation</span>
</label>
<p className="pl-7 text-xs text-muted-foreground">
Not recommended; most companions normalize to lowercase
</p>
</div>
</TabsContent>
)}
</Tabs>
{showHistoricalOption && (
@@ -408,7 +289,7 @@ export function NewMessageModal({
<div className="flex items-center justify-end space-x-2">
<Label
htmlFor="try-historical"
className="cursor-pointer text-sm text-muted-foreground"
className="text-sm text-muted-foreground cursor-pointer"
>
Try decrypting {undecryptedCount.toLocaleString()} stored packet
{undecryptedCount !== 1 ? 's' : ''}
@@ -420,7 +301,7 @@ export function NewMessageModal({
/>
</div>
{tryHistorical && (
<p className="text-right text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground text-right">
Messages will stream in as they decrypt in the background
</p>
)}
@@ -449,13 +330,7 @@ export function NewMessageModal({
</Button>
)}
<Button onClick={handleCreate} disabled={loading}>
{loading
? tab === 'bulk-hashtag'
? 'Adding...'
: 'Creating...'
: tab === 'bulk-hashtag'
? 'Add Channels'
: 'Create'}
{loading ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
+21 -35
View File
@@ -406,12 +406,9 @@ interface HopNodeProps {
distanceUnit: DistanceUnit;
}
const AMBIGUOUS_MATCH_PREVIEW_LIMIT = 3;
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
const isAmbiguous = hop.matches.length > 1;
const isUnknown = hop.matches.length === 0;
const [expanded, setExpanded] = useState(false);
// Calculate distance from previous location for a contact
// Returns null if prev location unknown/ambiguous or contact has no valid location
@@ -450,38 +447,27 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
<div className="font-medium text-muted-foreground">&lt;UNKNOWN&gt;</div>
) : isAmbiguous ? (
<div>
{(expanded ? hop.matches : hop.matches.slice(0, AMBIGUOUS_MATCH_PREVIEW_LIMIT)).map(
(contact) => {
const dist = getDistanceForContact(contact);
const hasLocation = isValidLocation(contact.lat, contact.lon);
return (
<div key={contact.public_key} className="font-medium truncate">
{contact.name || contact.public_key.slice(0, 12)}
{dist !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(dist, distanceUnit)}
</span>
)}
{hasLocation && (
<CoordinateLink
lat={contact.lat!}
lon={contact.lon!}
publicKey={contact.public_key}
/>
)}
</div>
);
}
)}
{!expanded && hop.matches.length > AMBIGUOUS_MATCH_PREVIEW_LIMIT && (
<button
type="button"
className="text-xs text-primary hover:underline cursor-pointer"
onClick={() => setExpanded(true)}
>
(and {hop.matches.length - AMBIGUOUS_MATCH_PREVIEW_LIMIT} more)
</button>
)}
{hop.matches.map((contact) => {
const dist = getDistanceForContact(contact);
const hasLocation = isValidLocation(contact.lat, contact.lon);
return (
<div key={contact.public_key} className="font-medium truncate">
{contact.name || contact.public_key.slice(0, 12)}
{dist !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(dist, distanceUnit)}
</span>
)}
{hasLocation && (
<CoordinateLink
lat={contact.lat!}
lon={contact.lon!}
publicKey={contact.public_key}
/>
)}
</div>
);
})}
</div>
) : (
<div className="font-medium truncate">
+16 -57
View File
@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
@@ -13,13 +13,7 @@ import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo';
import type {
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
TelemetryHistoryEntry,
} from '../types';
import type { Contact, Conversation, Favorite, PathDiscoveryResponse, TelemetryHistoryEntry } from '../types';
import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
@@ -53,7 +47,6 @@ interface RepeaterDashboardProps {
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
}
export function RepeaterDashboard({
@@ -71,9 +64,9 @@ export function RepeaterDashboard({
onToggleNotifications,
onToggleFavorite,
onDeleteContact,
onOpenContactInfo,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
const hasAdvertLocation = isValidLocation(contact?.lat ?? null, contact?.lon ?? null);
const {
@@ -98,37 +91,18 @@ export function RepeaterDashboard({
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('repeater', conversation.id);
// Telemetry history: preload from stored data, refresh from live status
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
const telemetryHistorySourceRef = useRef<'none' | 'preload' | 'live'>('none');
const telemetryHistoryRequestRef = useRef(0);
// Auto-fetch stored telemetry history from DB (no mesh traffic)
useEffect(() => {
telemetryHistoryRequestRef.current += 1;
telemetryHistorySourceRef.current = 'none';
setTelemetryHistory([]);
api.repeaterTelemetryHistory(conversation.id).then(setTelemetryHistory).catch(() => {});
}, [conversation.id]);
if (!loggedIn) return;
const requestId = telemetryHistoryRequestRef.current;
api
.repeaterTelemetryHistory(conversation.id)
.then((history) => {
if (telemetryHistoryRequestRef.current !== requestId) return;
if (telemetryHistorySourceRef.current === 'live') return;
telemetryHistorySourceRef.current = 'preload';
setTelemetryHistory(history);
})
.catch(() => {});
}, [loggedIn, conversation.id]);
// When a live status fetch returns embedded telemetry_history, replace local state
// Refresh when a live status fetch returns newer data
const statusHistory = paneData.status?.telemetry_history;
useEffect(() => {
const liveHistory = paneData.status?.telemetry_history;
if (!liveHistory) return;
telemetryHistorySourceRef.current = 'live';
setTelemetryHistory(liveHistory);
}, [paneData.status?.telemetry_history]);
if (statusHistory && statusHistory.length > 0) {
setTelemetryHistory(statusHistory);
}
}, [statusHistory]);
const isFav = isFavorite(favorites, 'contact', conversation.id);
@@ -158,24 +132,9 @@ export function RepeaterDashboard({
<span className="flex min-w-0 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<h2 className="min-w-0 flex-shrink font-semibold text-base">
{onOpenContactInfo ? (
<button
type="button"
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`View info for ${conversation.name}`}
onClick={() => onOpenContactInfo(conversation.id)}
>
<span className="truncate">{conversation.name}</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span className="truncate">{conversation.name}</span>
)}
</h2>
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
{conversation.name}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
@@ -2,7 +2,6 @@ import { useState, useEffect, type ReactNode } from 'react';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
HealthStatus,
RadioAdvertMode,
RadioConfig,
@@ -48,8 +47,6 @@ interface SettingsModalBaseProps {
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
}
export type SettingsModalProps = SettingsModalBaseProps &
@@ -83,8 +80,6 @@ export function SettingsModal(props: SettingsModalProps) {
blockedNames,
onToggleBlockedKey,
onToggleBlockedName,
contacts,
onBulkDeleteContacts,
} = props;
const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -244,8 +239,6 @@ export function SettingsModal(props: SettingsModalProps) {
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts}
className={sectionContentClass}
/>
) : (
+76 -47
View File
@@ -97,7 +97,7 @@ interface SidebarProps {
channels: Channel[];
activeConversation: Conversation | null;
onSelectConversation: (conversation: Conversation) => void;
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void;
onNewMessage: () => void;
lastMessageTimes: ConversationTimes;
unreadCounts: Record<string, number>;
/** Tracks which conversations have unread messages that mention the user */
@@ -107,19 +107,34 @@ interface SidebarProps {
onToggleCracker: () => void;
onMarkAllRead: () => void;
favorites: Favorite[];
/** Legacy global sort order, used only to seed per-section local preferences. */
legacySortOrder?: SortOrder;
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
blockedKeys?: string[];
blockedNames?: string[];
}
function loadInitialSectionSortOrders(): SidebarSectionSortOrders {
type InitialSectionSortState = {
orders: SidebarSectionSortOrders;
source: 'section' | 'legacy' | 'none';
};
function loadInitialSectionSortOrders(): InitialSectionSortState {
const storedOrders = loadLocalStorageSidebarSectionSortOrders();
if (storedOrders) return storedOrders;
if (storedOrders) {
return { orders: storedOrders, source: 'section' };
}
const legacyOrder = loadLegacyLocalStorageSortOrder();
const orders = buildSidebarSectionSortOrders(legacyOrder ?? undefined);
saveLocalStorageSidebarSectionSortOrders(orders);
return orders;
if (legacyOrder) {
return {
orders: buildSidebarSectionSortOrders(legacyOrder),
source: 'legacy',
};
}
return {
orders: buildSidebarSectionSortOrders(),
source: 'none',
};
}
export function Sidebar({
@@ -136,20 +151,12 @@ export function Sidebar({
onToggleCracker,
onMarkAllRead,
favorites,
legacySortOrder,
isConversationNotificationsEnabled,
blockedKeys = [],
blockedNames = [],
}: SidebarProps) {
const isContactBlocked = useCallback(
(c: Contact) =>
blockedKeys.includes(c.public_key.toLowerCase()) ||
(c.name != null && blockedNames.includes(c.name)),
[blockedKeys, blockedNames]
);
const [searchQuery, setSearchQuery] = useState('');
const initialSectionSortOrders = useMemo(loadInitialSectionSortOrders, []);
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortOrders);
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
const initialCollapsedState = useMemo(loadCollapsedState, []);
const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools);
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
@@ -158,12 +165,29 @@ export function Sidebar({
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
const collapseSnapshotRef = useRef<CollapseState | null>(null);
const sectionSortSourceRef = useRef(initialSectionSortState.source);
useEffect(() => {
if (sectionSortSourceRef.current === 'legacy') {
saveLocalStorageSidebarSectionSortOrders(sectionSortOrders);
sectionSortSourceRef.current = 'section';
return;
}
if (sectionSortSourceRef.current !== 'none' || legacySortOrder === undefined) return;
const seededOrders = buildSidebarSectionSortOrders(legacySortOrder);
setSectionSortOrders(seededOrders);
saveLocalStorageSidebarSectionSortOrders(seededOrders);
sectionSortSourceRef.current = 'section';
}, [legacySortOrder, sectionSortOrders]);
const handleSortToggle = (section: SidebarSortableSection) => {
setSectionSortOrders((prev) => {
const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha';
const updated = { ...prev, [section]: nextOrder };
saveLocalStorageSidebarSectionSortOrders(updated);
sectionSortSourceRef.current = 'section';
return updated;
});
};
@@ -374,32 +398,38 @@ export function Sidebar({
[sortedChannels, query]
);
const filteredNonRepeaterContacts = useMemo(() => {
const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c));
return query
? visible.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: visible;
}, [sortedNonRepeaterContacts, query, isContactBlocked]);
const filteredNonRepeaterContacts = useMemo(
() =>
query
? sortedNonRepeaterContacts.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedNonRepeaterContacts,
[sortedNonRepeaterContacts, query]
);
const filteredRooms = useMemo(() => {
const visible = sortedRooms.filter((c) => !isContactBlocked(c));
return query
? visible.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: visible;
}, [sortedRooms, query, isContactBlocked]);
const filteredRooms = useMemo(
() =>
query
? sortedRooms.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedRooms,
[sortedRooms, query]
);
const filteredRepeaters = useMemo(() => {
const visible = sortedRepeaters.filter((c) => !isContactBlocked(c));
return query
? visible.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: visible;
}, [sortedRepeaters, query, isContactBlocked]);
const filteredRepeaters = useMemo(
() =>
query
? sortedRepeaters.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedRepeaters,
[sortedRepeaters, query]
);
// Expand sections while searching; restore prior collapse state when search ends.
useEffect(() => {
@@ -624,9 +654,8 @@ export function Sidebar({
}) => (
<div
key={key}
data-active={active ? 'true' : undefined}
className={cn(
'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'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',
active && 'bg-accent border-l-primary'
)}
role="button"
@@ -635,10 +664,10 @@ export function Sidebar({
onKeyDown={handleKeyboardActivate}
onClick={onClick}
>
<span className="sidebar-tool-icon" aria-hidden="true">
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
{icon}
</span>
<span className="sidebar-tool-label flex-1 truncate">{label}</span>
<span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
</div>
);
@@ -1,352 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { api } from '../../api';
import { getContactDisplayName } from '../../utils/pubkey';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
import { toast } from '../ui/sonner';
import type { Contact } from '../../types';
const CONTACT_TYPE_LABELS: Record<number, string> = {
0: 'Unknown',
1: 'Client',
2: 'Repeater',
3: 'Room',
4: 'Sensor',
};
function formatDate(ts: number): string {
return new Date(ts * 1000).toLocaleDateString([], {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
function formatDateISO(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 10);
}
function datetimeToUnix(datetimeStr: string): number {
const d = new Date(datetimeStr);
return Math.floor(d.getTime() / 1000);
}
interface BulkDeleteContactsModalProps {
open: boolean;
onClose: () => void;
contacts: Contact[];
onDeleted: (deletedKeys: string[]) => void;
}
export function BulkDeleteContactsModal({
open,
onClose,
contacts,
onDeleted,
}: BulkDeleteContactsModalProps) {
const [step, setStep] = useState<'select' | 'confirm'>('select');
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
const [deleting, setDeleting] = useState(false);
const lastClickedKeyRef = useRef<string | null>(null);
const resetAndClose = useCallback(() => {
setStep('select');
setSelectedKeys(new Set());
setStartDate('');
setEndDate('');
setTypeFilter('all');
lastClickedKeyRef.current = null;
onClose();
}, [onClose]);
const filteredContacts = useMemo(() => {
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
if (typeFilter !== 'all') {
list = list.filter((c) => c.type === typeFilter);
}
if (startDate) {
const start = datetimeToUnix(startDate);
list = list.filter((c) => (c.first_seen ?? 0) >= start);
}
if (endDate) {
const end = datetimeToUnix(endDate);
list = list.filter((c) => (c.first_seen ?? 0) <= end);
}
return list;
}, [contacts, typeFilter, startDate, endDate]);
const handleToggle = (key: string, shiftKey: boolean) => {
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
const keys = filteredContacts.map((c) => c.public_key);
const lastIdx = keys.indexOf(lastClickedKeyRef.current);
const curIdx = keys.indexOf(key);
if (lastIdx >= 0 && curIdx >= 0) {
const from = Math.min(lastIdx, curIdx);
const to = Math.max(lastIdx, curIdx);
const rangeKeys = keys.slice(from, to + 1);
setSelectedKeys((prev) => {
const next = new Set(prev);
for (const k of rangeKeys) next.add(k);
return next;
});
lastClickedKeyRef.current = key;
return;
}
}
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
lastClickedKeyRef.current = key;
};
const handleSelectAll = () => {
setSelectedKeys(new Set(filteredContacts.map((c) => c.public_key)));
};
const handleSelectNone = () => {
setSelectedKeys(new Set());
};
const selectedContacts = useMemo(
() => contacts.filter((c) => selectedKeys.has(c.public_key)),
[contacts, selectedKeys]
);
const contactCount = selectedContacts.filter((c) => c.type === 1 || c.type === 0).length;
const repeaterCount = selectedContacts.filter((c) => c.type === 2).length;
const roomCount = selectedContacts.filter((c) => c.type === 3).length;
const sensorCount = selectedContacts.filter((c) => c.type === 4).length;
const firstSeenDates = selectedContacts.map((c) => c.first_seen ?? 0).filter((t) => t > 0);
const minDate =
firstSeenDates.length > 0 ? formatDateISO(Math.min(...firstSeenDates)) : 'unknown';
const maxDate =
firstSeenDates.length > 0 ? formatDateISO(Math.max(...firstSeenDates)) : 'unknown';
const handleDelete = async () => {
setDeleting(true);
try {
const keysToDelete = [...selectedKeys];
const result = await api.bulkDeleteContacts(keysToDelete);
toast.success(`Deleted ${result.deleted} contact${result.deleted === 1 ? '' : 's'}`);
onDeleted(keysToDelete);
resetAndClose();
} catch (err) {
console.error('Bulk delete failed:', err);
toast.error('Bulk delete failed', {
description: err instanceof Error ? err.message : undefined,
});
} finally {
setDeleting(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
<DialogHeader>
<DialogTitle>
{step === 'select' ? 'Bulk Delete Contacts' : 'Confirm Deletion'}
</DialogTitle>
<DialogDescription>
{step === 'select'
? 'Select contacts to delete. Message history will be preserved and accessible if a contact is re-added, but will no longer appear in the sidebar.'
: 'Review the contacts that will be permanently deleted.'}
</DialogDescription>
</DialogHeader>
{step === 'select' && (
<>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Show</label>
<select
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
onChange={(e) =>
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
}
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="all">All</option>
<option value="1">Clients</option>
<option value="2">Repeaters</option>
<option value="3">Room Servers</option>
<option value="4">Sensors</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created after</label>
<Input
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created before</label>
<Input
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="flex gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
Select all
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleSelectNone}>
Select none
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
{(startDate || endDate) && ' (filtered)'}
{' · '}
{selectedKeys.size} selected
</div>
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
{filteredContacts.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No contacts match the selected date range.
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5 w-8" />
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
</tr>
</thead>
<tbody>
{filteredContacts.map((c) => (
<tr
key={c.public_key}
className="border-t border-border hover:bg-accent/50 cursor-pointer"
onClick={(e) => handleToggle(c.public_key, e.shiftKey)}
>
<td className="px-3 py-1.5">
<input
type="checkbox"
checked={selectedKeys.has(c.public_key)}
onChange={(e) =>
handleToggle(
c.public_key,
e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey
)
}
onClick={(e) => e.stopPropagation()}
className="rounded border-input"
/>
</td>
<td className="px-3 py-1.5 truncate max-w-[10rem]">
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
{c.public_key.slice(0, 12)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={resetAndClose}>
Cancel
</Button>
<Button
variant="outline"
className="border-warning text-warning hover:bg-warning/10 hover:text-warning"
disabled={selectedKeys.size === 0}
onClick={() => setStep('confirm')}
>
Proceed to confirmation ({selectedKeys.size})
</Button>
</div>
</>
)}
{step === 'confirm' && (
<>
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
</tr>
</thead>
<tbody>
{selectedContacts.map((c) => (
<tr key={c.public_key} className="border-t border-border">
<td className="px-3 py-1.5 truncate max-w-[12rem]">
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
</td>
<td className="px-3 py-1.5 text-xs text-muted-foreground">
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
{c.public_key.slice(0, 12)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 pt-2">
<Button
variant="destructive"
className="w-full h-auto py-3 text-wrap"
disabled={deleting}
onClick={handleDelete}
>
{deleting
? 'Deleting...'
: `I confirm permanent, irrevocable deletion of all listed nodes above, totalling ${[
contactCount > 0 && `${contactCount} contact${contactCount === 1 ? '' : 's'}`,
repeaterCount > 0 &&
`${repeaterCount} repeater${repeaterCount === 1 ? '' : 's'}`,
roomCount > 0 && `${roomCount} room${roomCount === 1 ? '' : 's'}`,
sensorCount > 0 && `${sensorCount} sensor${sensorCount === 1 ? '' : 's'}`,
]
.filter(Boolean)
.join(', ')}, spanning creation dates from ${minDate} to ${maxDate}`}
</Button>
<Button variant="secondary" onClick={() => setStep('select')} disabled={deleting}>
Back
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -6,8 +6,7 @@ import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { api } from '../../api';
import { formatTime } from '../../utils/messageParser';
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
export function SettingsDatabaseSection({
appSettings,
@@ -18,8 +17,6 @@ export function SettingsDatabaseSection({
blockedNames = [],
onToggleBlockedKey,
onToggleBlockedName,
contacts = [],
onBulkDeleteContacts,
className,
}: {
appSettings: AppSettings;
@@ -30,23 +27,18 @@ export function SettingsDatabaseSection({
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
className?: string;
}) {
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
}, [appSettings]);
const handleCleanup = async () => {
@@ -100,15 +92,7 @@ export function SettingsDatabaseSection({
setError(null);
try {
const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert };
const currentBlocked = appSettings.discovery_blocked_types ?? [];
if (
discoveryBlockedTypes.length !== currentBlocked.length ||
discoveryBlockedTypes.some((t) => !currentBlocked.includes(t))
) {
update.discovery_blocked_types = discoveryBlockedTypes;
}
await onSaveAppSettings(update);
await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
toast.success('Database settings saved');
} catch (err) {
console.error('Failed to save database settings:', err);
@@ -121,93 +105,93 @@ export function SettingsDatabaseSection({
return (
<div className={className}>
{/* ── Database Overview ── */}
<div className="space-y-3">
<Label className="text-base">Database Overview</Label>
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm">Database size</span>
<span className="text-sm font-semibold">{health?.database_size_mb ?? '?'} MB</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">Oldest undecrypted packet</span>
{health?.oldest_undecrypted_timestamp ? (
<span className="text-sm font-semibold">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="font-normal text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days)
</span>
</span>
) : (
<span className="text-sm text-muted-foreground">None</span>
)}
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Database size</span>
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
</div>
{health?.oldest_undecrypted_timestamp ? (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="font-medium">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days old)
</span>
</span>
</div>
) : (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="text-muted-foreground">None</span>
</div>
)}
</div>
<Separator />
{/* ── Storage Cleanup ── */}
<div className="space-y-4">
<Label className="text-base">Storage Cleanup</Label>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Delete Undecrypted Packets</Label>
<p className="text-xs text-muted-foreground">
Permanently deletes stored raw packets that have not yet been decrypted. These are
retained in case you later obtain the correct key once deleted, these messages can
never be recovered.
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs text-muted-foreground">
Older than (days)
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-24"
/>
</div>
<Button
variant="outline"
onClick={handleCleanup}
disabled={cleaning}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{cleaning ? 'Deleting...' : 'Delete'}
</Button>
<div className="space-y-3">
<Label>Delete Undecrypted Packets</Label>
<p className="text-xs text-muted-foreground">
Permanently deletes stored raw packets containing DMs and channel messages that have not
yet been decrypted. These packets are retained in case you later obtain the correct key
once deleted, these messages can never be recovered or decrypted.
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs">
Older than (days)
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-24"
/>
</div>
</div>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes the raw packet bytes behind messages that are already decrypted and visible in
chat. This frees space but removes packet-analysis availability for those messages. It
does not affect displayed messages or future decryption.
</p>
<Button
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-warning/50 text-warning hover:bg-warning/10"
onClick={handleCleanup}
disabled={cleaning}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'}
{cleaning ? 'Deleting...' : 'Permanently Delete'}
</Button>
</div>
</div>
<Separator />
{/* ── DM Decryption ── */}
<div className="space-y-3">
<Label className="text-base">DM Decryption</Label>
<Label>Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '}
<em className="text-muted-foreground/80">
This will not affect any displayed messages or your ability to do historical decryption,
but it will remove packet-analysis availability for those historical messages.
</em>{' '}
The raw bytes are only useful for manual packet analysis.
</p>
<Button
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-warning/50 text-warning hover:bg-warning/10"
>
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
</Button>
</div>
<Separator />
<div className="space-y-3">
<Label>DM Decryption</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
@@ -223,87 +207,17 @@ export function SettingsDatabaseSection({
</p>
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
{/* ── Contact Management ── */}
<div className="space-y-2">
<Label className="text-base">Contact Management</Label>
</div>
{/* Block discovery of new node types */}
<div className="space-y-3">
<Label>Block Discovery of New Node Types</Label>
<p className="text-xs text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() =>
setDiscoveryBlockedTypes((prev) =>
checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode]
)
}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
</p>
)}
</div>
<Separator />
{/* Blocked contacts list */}
<div className="space-y-3">
<Label>Blocked Contacts</Label>
<p className="text-xs text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI
MQTT forwarding and bot responses are not affected. Messages are still stored and will
reappear if unblocked.
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
affected. Messages are still stored and will reappear if unblocked.
</p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
<p className="text-sm text-muted-foreground italic">No blocked contacts</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
@@ -354,25 +268,15 @@ export function SettingsDatabaseSection({
)}
</div>
<Separator />
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
{/* Bulk delete */}
<div className="space-y-3">
<Label>Bulk Delete Contacts</Label>
<p className="text-xs text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
</div>
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
</div>
);
}
@@ -643,20 +643,16 @@ function formatPrivateTopicSummary(config: Record<string, unknown>) {
return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`;
}
function censorAppriseUrl(url: string): string {
const protoMatch = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
if (protoMatch) return `${protoMatch[0]}********`;
return '********';
}
function formatAppriseTargets(urls: string | undefined) {
function formatAppriseTargets(urls: string | undefined, maxLength = 80) {
const targets = (urls || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (targets.length === 0) return 'No targets configured';
return targets.map(censorAppriseUrl).join(', ');
const joined = targets.join(', ');
if (joined.length <= maxLength) return joined;
return `${joined.slice(0, maxLength - 3)}...`;
}
function formatSqsQueueSummary(config: Record<string, unknown>) {
@@ -347,20 +347,17 @@ function PreviewSidebarRow({
}) {
return (
<div
data-active={active ? 'true' : undefined}
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
}`}
>
<span className="sidebar-tool-icon" aria-hidden="true">
{leading}
</span>
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
{leading}
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
{label}
</span>
{badge}
{!badge && (
<span className="sidebar-tool-icon" aria-hidden="true">
<span className="text-muted-foreground" aria-hidden="true">
<MessageSquare className="h-3.5 w-3.5" />
</span>
)}
@@ -390,9 +390,9 @@ export function SettingsRadioSection({
return (
<div className={className}>
{/* ── Connection ── */}
{/* Connection display */}
<div className="space-y-3">
<Label className="text-base">Connection</Label>
<Label>Connection</Label>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
@@ -428,58 +428,15 @@ export function SettingsRadioSection({
</p>
</div>
<Separator />
{/* ── Identity ── */}
<div className="space-y-2">
<Label className="text-base">Identity</Label>
</div>
{/* Radio Name */}
<div className="space-y-2">
<Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={identityBusy || identityRebooting || !privateKey.trim()}
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
{identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
{identityError && (
<div className="text-sm text-destructive" role="alert">
{identityError}
</div>
)}
<Separator />
{/* ── Radio Parameters ── */}
<div className="space-y-2">
<Label className="text-base">Radio Parameters</Label>
</div>
{/* Radio Config */}
<div className="space-y-2">
<Label htmlFor="preset">Preset</Label>
<select
@@ -561,36 +518,11 @@ export function SettingsRadioSection({
</div>
</div>
{config.path_hash_mode_supported && (
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
<p>
ALL nodes along a message&apos;s route &mdash; your radio, every repeater, and the
recipient &mdash; must be running firmware that supports the selected mode. Messages
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
</p>
</div>
</div>
)}
<Separator />
{/* ── Location ── */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base">Location</Label>
<Label>Location</Label>
<Button
type="button"
variant="outline"
@@ -653,8 +585,53 @@ export function SettingsRadioSection({
library.
</p>
</div>
<div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="multi-acks-enabled"
checked={multiAcksEnabled}
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal
ACK for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
</p>
</div>
</div>
</div>
</div>
{config.path_hash_mode_supported && (
<>
<Separator />
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
<p>
ALL nodes along a message&apos;s route &mdash; your radio, every repeater, and the
recipient &mdash; must be running firmware that supports the selected mode. Messages
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
</p>
</div>
</div>
</>
)}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
@@ -680,28 +657,64 @@ export function SettingsRadioSection({
<Separator />
{/* ── Messaging ── */}
{/* Keys */}
<div className="space-y-2">
<Label className="text-base">Messaging</Label>
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="multi-acks-enabled"
checked={multiAcksEnabled}
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal ACK
for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
</p>
</div>
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={identityBusy || identityRebooting || !privateKey.trim()}
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
{identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
{identityError && (
<div className="text-sm text-destructive" role="alert">
{identityError}
</div>
)}
<Separator />
{/* Flood & Advert Control */}
<div className="space-y-2">
<Label className="text-base">Flood & Advert Control</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div>
<div className="space-y-2">
@@ -733,13 +746,6 @@ export function SettingsRadioSection({
Configured radio contact capacity. Favorites reload first, then background maintenance
refills to about 80% of this value and offloads once occupancy reaches about 95%.
</p>
{health?.radio_device_info?.max_contacts != null &&
Number(maxRadioContacts) > health.radio_device_info.max_contacts && (
<p className="text-xs text-warning">
Your radio reports a hardware limit of {health.radio_device_info.max_contacts}{' '}
contacts. The effective cap will be limited to what the radio supports.
</p>
)}
</div>
{floodError && (
@@ -754,28 +760,8 @@ export function SettingsRadioSection({
<Separator />
{/* ── Advertising & Discovery ── */}
<div className="space-y-2">
<Label className="text-base">Advertising &amp; Discovery</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
<Label className="text-base">Hear &amp; Be Heard</Label>
</div>
<div className="space-y-2">
+1 -20
View File
@@ -4,7 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import { getContactDisplayName } from '../utils/pubkey';
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
import type { BulkCreateHashtagChannelsResult, Channel, Contact, Conversation } from '../types';
import type { Channel, Contact, Conversation } from '../types';
interface UseContactsAndChannelsArgs {
setActiveConversation: (conv: Conversation | null) => void;
@@ -112,24 +112,6 @@ export function useContactsAndChannels({
[fetchUndecryptedCountInternal, setActiveConversation]
);
const handleBulkCreateHashtagChannels = useCallback(
async (
channelNames: string[],
tryHistorical: boolean
): Promise<BulkCreateHashtagChannelsResult> => {
const result = await api.bulkCreateHashtagChannels(channelNames, tryHistorical);
const data = await api.getChannels();
setChannels(data);
if (tryHistorical && result.decrypt_started) {
fetchUndecryptedCountInternal();
}
return result;
},
[fetchUndecryptedCountInternal]
);
const handleDeleteChannel = useCallback(
async (key: string) => {
if (!confirm('Delete this channel? Message history will be preserved.')) return;
@@ -208,7 +190,6 @@ export function useContactsAndChannels({
handleCreateContact,
handleCreateChannel,
handleCreateHashtagChannel,
handleBulkCreateHashtagChannels,
handleDeleteChannel,
handleDeleteContact,
};
-52
View File
@@ -56,14 +56,6 @@
--badge-mention: var(--destructive);
--badge-mention-foreground: var(--destructive-foreground);
/* Sidebar navigation accents */
--sidebar-icon-color: hsl(var(--foreground));
--sidebar-icon-hover-color: hsl(var(--foreground));
--sidebar-icon-active-color: hsl(var(--foreground));
--sidebar-label-color: hsl(var(--muted-foreground));
--sidebar-label-hover-color: hsl(var(--foreground));
--sidebar-label-active-color: hsl(var(--foreground));
/* Error toast */
--toast-error: 0 30% 14%;
--toast-error-foreground: 0 56% 77%;
@@ -134,50 +126,6 @@
animation: message-highlight 2s ease-out forwards;
}
.sidebar-tool-icon {
display: inline-flex;
height: 1.5rem;
width: 1.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 0.45rem;
color: var(--sidebar-icon-color);
opacity: 1;
transition:
color 150ms ease,
opacity 150ms ease;
}
.sidebar-tool-icon svg {
stroke-width: 2.35;
}
.sidebar-tool-label {
color: var(--sidebar-label-color);
transition: color 150ms ease;
}
.sidebar-action-row:hover .sidebar-tool-icon,
.sidebar-action-row:focus-visible .sidebar-tool-icon {
color: var(--sidebar-icon-hover-color);
opacity: 1;
}
.sidebar-action-row:hover .sidebar-tool-label,
.sidebar-action-row:focus-visible .sidebar-tool-label {
color: var(--sidebar-label-hover-color);
}
.sidebar-action-row[data-active='true'] .sidebar-tool-icon {
color: var(--sidebar-icon-active-color);
opacity: 1;
}
.sidebar-action-row[data-active='true'] .sidebar-tool-label {
color: var(--sidebar-label-active-color);
}
/* Constrain CodeMirror editor width */
.cm-editor {
max-width: 100% !important;
+1
View File
@@ -190,6 +190,7 @@ const baseSettings = {
max_radio_contacts: 200,
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent' as const,
last_message_times: {},
preferences_migrated: false,
advert_interval: 0,
+1
View File
@@ -218,6 +218,7 @@ describe('App search jump target handling', () => {
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {},
preferences_migrated: true,
advert_interval: 0,
@@ -169,6 +169,7 @@ describe('App startup hash resolution', () => {
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {},
preferences_migrated: true,
advert_interval: 0,
@@ -1,46 +0,0 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { BulkAddChannelResultModal } from '../components/BulkAddChannelResultModal';
describe('BulkAddChannelResultModal', () => {
it('renders links only for newly created rooms', () => {
render(
<BulkAddChannelResultModal
open
onClose={() => {}}
result={{
created_channels: [
{
key: 'AA'.repeat(16),
name: '#ops',
is_hashtag: true,
on_radio: false,
last_read_at: null,
},
{
key: 'BB'.repeat(16),
name: '#mesh-room',
is_hashtag: true,
on_radio: false,
last_read_at: null,
},
],
existing_count: 3,
invalid_names: ['bad_room'],
decrypt_started: true,
decrypt_total_packets: 8,
message: 'Created 2 rooms',
}}
/>
);
const opsLink = screen.getByRole('link', { name: '#ops' });
const meshLink = screen.getByRole('link', { name: '#mesh-room' });
expect(opsLink.getAttribute('href')).toContain('#channel/');
expect(meshLink.getAttribute('href')).toContain('#channel/');
expect(screen.queryByRole('link', { name: /bad_room/i })).toBeNull();
expect(screen.getByText(/Ignored invalid room names: bad_room/)).toBeTruthy();
});
});
+1 -1
View File
@@ -1227,7 +1227,7 @@ describe('SettingsFanoutSection', () => {
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
expect(
within(group).getByText(/discord:\/\/\*{8}, mailto:\/\/\*{8}, mailto:\/\/\*{8}/)
within(group).getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)
).toBeInTheDocument();
});
-26
View File
@@ -169,32 +169,6 @@ describe('MessageList channel sender rendering', () => {
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
});
it('links valid channel references when followed by clause punctuation', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
render(
<MessageList
messages={[
createMessage({
text: 'Alice: Check #mesh-room, then #ops-room; then #alpha-room.',
}),
]}
contacts={[]}
loading={false}
onChannelReferenceClick={onChannelReferenceClick}
/>
);
await user.click(screen.getByRole('button', { name: '#mesh-room' }));
await user.click(screen.getByRole('button', { name: '#ops-room' }));
await user.click(screen.getByRole('button', { name: '#alpha-room' }));
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(1, '#mesh-room');
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(2, '#ops-room');
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(3, '#alpha-room');
});
it('links valid channel references in direct messages too', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
+5 -15
View File
@@ -122,21 +122,11 @@ describe('linked channel references', () => {
]);
});
it('finds linked channel references terminated by clause punctuation', () => {
expect(
findLinkedChannelReferences('Join #mesh-room, then #ops2; finally #alpha-room.')
).toEqual([
{ label: '#mesh-room', start: 5, end: 15 },
{ label: '#ops2', start: 22, end: 27 },
{ label: '#alpha-room', start: 37, end: 48 },
]);
});
it('ignores invalid or embedded channel-like text', () => {
const references = findLinkedChannelReferences(
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
);
expect(references.map((reference) => reference.label)).toEqual(['#good-room']);
expect(
findLinkedChannelReferences(
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
)
).toEqual([]);
});
});
@@ -27,7 +27,6 @@ describe('NewMessageModal form reset', () => {
const onCreateContact = vi.fn().mockResolvedValue(undefined);
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
const onBulkAddHashtagChannels = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.clearAllMocks();
@@ -45,7 +44,6 @@ describe('NewMessageModal form reset', () => {
onCreateContact={onCreateContact}
onCreateChannel={onCreateChannel}
onCreateHashtagChannel={onCreateHashtagChannel}
onBulkAddHashtagChannels={onBulkAddHashtagChannels}
{...overrides}
/>
);
@@ -113,53 +111,6 @@ describe('NewMessageModal form reset', () => {
});
});
describe('bulk hashtag tab', () => {
it('is only visible when enabled', () => {
renderModal();
expect(screen.queryByRole('tab', { name: 'Bulk Add Channel' })).toBeNull();
});
it('opens on the bulk tab when enabled and submits normalized room names', async () => {
const user = userEvent.setup();
renderModal(true, { showBulkAddChannelTab: true });
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Bulk Add Channel' })).toHaveAttribute(
'data-state',
'active'
);
});
await user.type(
screen.getByRole('textbox', { name: 'Bulk channel names' }),
'#Ops{enter}mesh-room another-room #Ops'
);
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
await waitFor(() => {
expect(onBulkAddHashtagChannels).toHaveBeenCalledWith(
['#ops', '#mesh-room', '#another-room'],
false
);
});
expect(onClose).toHaveBeenCalled();
});
it('shows invalid bulk room names before submitting', async () => {
const user = userEvent.setup();
renderModal(true, { showBulkAddChannelTab: true });
await user.type(
screen.getByRole('textbox', { name: 'Bulk channel names' }),
'good-room bad_room'
);
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
expect(onBulkAddHashtagChannels).not.toHaveBeenCalled();
expect(screen.getByText('Invalid room names: bad_room')).toBeTruthy();
});
});
describe('new-contact tab', () => {
it('clears name and key after successful Create', async () => {
const user = userEvent.setup();
+2 -114
View File
@@ -51,11 +51,11 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
useRepeaterDashboard: () => mockHook,
}));
// Mock api module (TelemetryHistoryPane fetches on mount)
// Mock api module (used by routing override tests + telemetry history fetch on mount)
vi.mock('../api', () => ({
api: {
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
},
}));
@@ -126,16 +126,6 @@ const defaultProps = {
onDeleteContact: vi.fn(),
};
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('RepeaterDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -653,106 +643,4 @@ describe('RepeaterDashboard', () => {
overrideSpy.mockRestore();
});
});
describe('telemetry history', () => {
beforeEach(async () => {
const { api } = await import('../api');
vi.mocked(api.repeaterTelemetryHistory).mockResolvedValue([]);
});
it('loads telemetry history on mount when logged in', async () => {
const { api } = await import('../api');
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(api.repeaterTelemetryHistory).toHaveBeenCalledWith(REPEATER_KEY);
});
});
it('shows telemetry history pane in logged-in view even before status fetch', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
expect(screen.getByText('0 samples')).toBeInTheDocument();
});
it('updates history from live status fetch', async () => {
const { api } = await import('../api');
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
const liveEntry = { timestamp: 1700000000, data: { battery_volts: 4.2 } };
historySpy.mockResolvedValue([]);
mockHook.loggedIn = true;
mockHook.paneData.status = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
telemetry_history: [liveEntry],
};
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
});
it('does not let an older preload overwrite newer live status history', async () => {
const { api } = await import('../api');
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
const deferred = createDeferred<{ timestamp: number; data: { battery_volts: number } }[]>();
historySpy.mockReturnValue(deferred.promise);
mockHook.loggedIn = true;
mockHook.paneData.status = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
};
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
deferred.resolve([{ timestamp: 1690000000, data: { battery_volts: 3.9 } }]);
await deferred.promise;
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
});
});
+3 -3
View File
@@ -61,6 +61,7 @@ const baseSettings: AppSettings = {
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {},
preferences_migrated: false,
advert_interval: 0,
@@ -68,7 +69,6 @@ const baseSettings: AppSettings = {
flood_scope: '',
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
};
function renderModal(overrides?: {
@@ -615,10 +615,10 @@ describe('SettingsModal', () => {
openDatabaseSection();
expect(
screen.getByText(/removes packet-analysis availability for those messages/i)
screen.getByText(/remove packet-analysis availability for those historical messages/i)
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' }));
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
await waitFor(() => {
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
+9
View File
@@ -92,6 +92,7 @@ function renderSidebar(overrides?: {
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={favorites}
legacySortOrder="recent"
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
/>
);
@@ -139,6 +140,7 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
@@ -298,6 +300,7 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
@@ -394,6 +397,7 @@ describe('Sidebar section summaries', () => {
onToggleCracker: vi.fn(),
onMarkAllRead: vi.fn(),
favorites: [],
legacySortOrder: 'recent' as const,
};
const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
@@ -465,6 +469,7 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
@@ -499,6 +504,7 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
@@ -547,6 +553,7 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
@@ -579,6 +586,7 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="alpha"
/>
);
@@ -615,6 +623,7 @@ describe('Sidebar section summaries', () => {
{ type: 'contact', id: zed.public_key },
{ type: 'contact', id: amy.public_key },
] satisfies Favorite[],
legacySortOrder: 'recent' as const,
};
const getFavoritesOrder = () =>
@@ -9,7 +9,7 @@ import { act, renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
import type { BulkCreateHashtagChannelsResult, Contact } from '../types';
import type { Contact } from '../types';
// Mock api module
vi.mock('../api', () => ({
@@ -18,7 +18,6 @@ vi.mock('../api', () => ({
getChannels: vi.fn(),
createContact: vi.fn(),
createChannel: vi.fn(),
bulkCreateHashtagChannels: vi.fn(),
deleteContact: vi.fn(),
deleteChannel: vi.fn(),
decryptHistoricalPackets: vi.fn(),
@@ -172,41 +171,4 @@ describe('useContactsAndChannels', () => {
expect(api.getContacts).toHaveBeenCalledTimes(2);
});
});
describe('bulk hashtag creation', () => {
it('refreshes channels and returns the backend result', async () => {
const { api } = await import('../api');
const resultPayload: BulkCreateHashtagChannelsResult = {
created_channels: [
{
key: 'AA'.repeat(16),
name: '#ops',
is_hashtag: true,
on_radio: false,
last_read_at: null,
},
],
existing_count: 1,
invalid_names: [],
decrypt_started: true,
decrypt_total_packets: 12,
message: 'Created 1 room',
};
vi.mocked(api.bulkCreateHashtagChannels).mockResolvedValueOnce(resultPayload);
vi.mocked(api.getChannels).mockResolvedValueOnce(resultPayload.created_channels);
vi.mocked(api.getUndecryptedPacketCount).mockResolvedValueOnce({ count: 9 });
const { result } = renderUseContactsAndChannels();
let response: BulkCreateHashtagChannelsResult | null = null;
await act(async () => {
response = await result.current.handleBulkCreateHashtagChannels(['#ops'], true);
});
expect(api.bulkCreateHashtagChannels).toHaveBeenCalledWith(['#ops'], true);
expect(api.getChannels).toHaveBeenCalled();
expect(api.getUndecryptedPacketCount).toHaveBeenCalled();
expect(response).toEqual(resultPayload);
});
});
});
+4
View File
@@ -49,6 +49,10 @@
--overlay: 220 20% 10%;
}
:root[data-theme='light'] .sidebar-tool-label {
color: hsl(var(--foreground));
}
/* ── Windows 95 ───────────────────────────────────────────── */
:root[data-theme='windows-95'] {
--background: 180 100% 25%;
+19 -11
View File
@@ -166,6 +166,23 @@ export interface NearestRepeater {
heard_count: number;
}
export interface ContactDetail {
contact: Contact;
name_history: ContactNameHistory[];
dm_message_count: number;
channel_message_count: number;
most_active_rooms: ContactActiveRoom[];
advert_paths: ContactAdvertPath[];
advert_frequency: number | null;
nearest_repeaters: NearestRepeater[];
}
export interface NameOnlyContactDetail {
name: string;
channel_message_count: number;
most_active_rooms: ContactActiveRoom[];
}
export interface ContactAnalyticsHourlyBucket {
bucket_start: number;
last_24h_count: number;
@@ -218,15 +235,6 @@ export interface ChannelTopSender {
message_count: number;
}
export interface BulkCreateHashtagChannelsResult {
created_channels: Channel[];
existing_count: number;
invalid_names: string[];
decrypt_started: boolean;
decrypt_total_packets: number;
message: string;
}
export interface ChannelDetail {
channel: Channel;
message_counts: ChannelMessageCounts;
@@ -316,6 +324,7 @@ export interface AppSettings {
max_radio_contacts: number;
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
sidebar_sort_order: 'recent' | 'alpha';
last_message_times: Record<string, number>;
preferences_migrated: boolean;
advert_interval: number;
@@ -323,17 +332,16 @@ export interface AppSettings {
flood_scope: string;
blocked_keys: string[];
blocked_names: string[];
discovery_blocked_types: number[];
}
export interface AppSettingsUpdate {
max_radio_contacts?: number;
auto_decrypt_dm_on_advert?: boolean;
sidebar_sort_order?: 'recent' | 'alpha';
advert_interval?: number;
flood_scope?: string;
blocked_keys?: string[];
blocked_names?: string[];
discovery_blocked_types?: number[];
}
export interface MigratePreferencesRequest {
+1 -1
View File
@@ -3,7 +3,7 @@
* Channel messages have format "sender: message".
*/
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|[\s.,;:])/g;
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g;
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
const colonIndex = text.indexOf(': ');
+1 -1
View File
@@ -147,7 +147,7 @@ export function getMapFocusHash(publicKeyPrefix: string): string {
}
// Generate URL hash from conversation
export function getConversationHash(conv: Conversation | null): string {
function getConversationHash(conv: Conversation | null): string {
if (!conv) return '';
if (conv.type === 'raw') return '#raw';
if (conv.type === 'map') return '#map';
+3 -3
View File
@@ -1,9 +1,9 @@
[project]
name = "remoteterm-meshcore"
version = "3.7.1"
version = "3.6.7"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.11"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
@@ -57,7 +57,7 @@ ignore = [
known-first-party = ["app"]
[tool.pyright]
pythonVersion = "3.11"
pythonVersion = "3.10"
typeCheckingMode = "basic"
include = ["app"]
exclude = ["references", ".venv", "tests"]
+1 -1
View File
@@ -26,7 +26,7 @@ echo -e "${YELLOW}=== Phase 1: Lint & Format ===${NC}"
echo -ne "${BLUE}[backend lint]${NC} "
cd "$REPO_ROOT"
uv run ruff check app/ tests/ --fix --quiet
uv run ruff format app/ tests/ --quiet
uv run ruff format app/ tests/ --check --quiet
echo -e "${GREEN}Passed!${NC}"
echo -ne "${BLUE}[frontend lint]${NC} "
+1
View File
@@ -222,6 +222,7 @@ export interface AppSettings {
max_radio_contacts: number;
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
sidebar_sort_order: string;
last_message_times: Record<string, number>;
preferences_migrated: boolean;
advert_interval: number;
-1
View File
@@ -63,7 +63,6 @@ export default defineConfig({
timeout: 180_000,
env: {
MESHCORE_DATABASE_PATH: path.join(tmpDir, 'e2e-test.db'),
MESHCORE_SKIP_POST_CONNECT_SYNC: 'true',
// Pass through the serial port from the environment
...(process.env.MESHCORE_SERIAL_PORT
? { MESHCORE_SERIAL_PORT: process.env.MESHCORE_SERIAL_PORT }
+5 -14
View File
@@ -25,16 +25,6 @@ test.describe('Apprise integration settings', () => {
receiver.close();
});
test.beforeEach(async () => {
// Clean up any stale configs from previous failed runs
const configs = await getFanoutConfigs();
for (const c of configs.filter((c) => c.name === 'E2E Apprise')) {
try {
await deleteFanoutConfig(c.id);
} catch { /* ignore */ }
}
});
test.afterEach(async () => {
if (createdAppriseId) {
try {
@@ -76,15 +66,16 @@ test.describe('Apprise integration settings', () => {
await page.getByRole('button', { name: /Save as Enabled/i }).click();
await expect(page.getByText('Integration saved and enabled')).toBeVisible();
// Capture ID for cleanup before assertions that might fail
// Should be back on list view with our apprise config visible
await expect(page.getByText('E2E Apprise')).toBeVisible();
await expect(page.getByText(appriseUrl)).toBeVisible();
// Clean up via API
const configs = await getFanoutConfigs();
const apprise = configs.find((c) => c.name === 'E2E Apprise');
if (apprise) {
createdAppriseId = apprise.id;
}
// Should be back on list view with our apprise config visible
await expect(fanoutHeader(page, 'E2E Apprise')).toBeVisible();
});
test('create apprise via API, verify options persist after edit', async ({ page }) => {
+6 -109
View File
@@ -131,62 +131,6 @@ class TestHealthEndpoint:
class TestDebugEndpoint:
"""Test the debug support snapshot endpoint."""
def test_support_snapshot_sanitizes_radio_probe_location_fields(self):
"""Debug radio probe should redact advertised lat/lon from self_info."""
from app.routers.debug import _sanitize_radio_probe_self_info
sanitized = _sanitize_radio_probe_self_info(
{
"name": "FlightlessTestNode",
"adv_lat": 47.786445,
"adv_lon": -122.344011,
"radio_freq": 910.525,
}
)
assert sanitized == {
"name": "FlightlessTestNode",
"radio_freq": 910.525,
}
def test_support_snapshot_only_keeps_erroring_fanouts_in_health_summary(self):
"""Debug health summary should only include fanouts with non-empty last_error."""
from app.routers.debug import _build_debug_health_summary
from app.routers.health import FanoutStatusResponse
summary = _build_debug_health_summary(
{
"database_size_mb": 1.23,
"oldest_undecrypted_timestamp": 123,
"fanout_statuses": {
"ok-id": {
"name": "OK Fanout",
"type": "bot",
"status": "connected",
"last_error": None,
},
"err-id": {
"name": "Broken Fanout",
"type": "mqtt_private",
"status": "error",
"last_error": "broker down",
},
},
"bots_disabled_source": None,
"basic_auth_enabled": False,
},
radio_state="connected",
)
assert summary.fanouts_with_errors == {
"err-id": FanoutStatusResponse(
name="Broken Fanout",
type="mqtt_private",
status="error",
last_error="broker down",
)
}
@pytest.mark.asyncio
async def test_support_snapshot_returns_runtime_when_disconnected(self, test_db, client):
"""Debug snapshot should still return logs and runtime state when radio is disconnected."""
@@ -213,21 +157,8 @@ class TestDebugEndpoint:
assert response.status_code == 200
payload = response.json()
assert "app_info" not in payload["health"]
assert "bots_disabled" not in payload["health"]
assert "connection_info" not in payload["health"]
assert "fanout_statuses" not in payload["health"]
assert "radio_connected" not in payload["health"]
assert "radio_device_info" not in payload["health"]
assert "radio_initializing" not in payload["health"]
assert "status" not in payload["health"]
assert payload["health"]["fanouts_with_errors"] == {}
assert payload["health"]["radio_state"] == "disconnected"
assert payload["radio_probe"]["performed"] is False
assert payload["radio_probe"]["errors"] == ["Radio not connected"]
assert "multi_acks_enabled" not in payload["radio_probe"]
assert "max_channels" not in payload["runtime"]
assert "path_hash_mode" not in payload["runtime"]
assert payload["runtime"]["channels_with_incoming_messages"] == 0
assert payload["database"]["total_dms"] == 0
assert payload["database"]["total_channel_messages"] == 0
@@ -293,6 +224,7 @@ class TestDebugEndpoint:
json={
"max_radio_contacts": 321,
"auto_decrypt_dm_on_advert": True,
"sidebar_sort_order": "alpha",
"advert_interval": 7200,
"flood_scope": "US-CA",
"blocked_keys": [pub_key],
@@ -1166,14 +1098,7 @@ class TestRawPacketRepository:
await RawPacketRepository.create(b"\x04\x05\x06", recent_timestamp)
# Insert old but decrypted packet (should NOT be deleted)
old_id, _ = await RawPacketRepository.create(b"\x07\x08\x09", old_timestamp)
msg_id = await MessageRepository.create(
msg_type="PRIV",
conversation_key="test_key",
text="test",
sender_timestamp=old_timestamp,
received_at=old_timestamp,
)
await RawPacketRepository.mark_decrypted(old_id, msg_id)
await RawPacketRepository.mark_decrypted(old_id, 1)
# Prune packets older than 10 days
deleted = await RawPacketRepository.prune_old_undecrypted(10)
@@ -1197,24 +1122,10 @@ class TestRawPacketRepository:
async def test_purge_linked_to_messages_deletes_only_linked_packets(self, test_db):
"""Purge linked raw packets removes only rows with a message_id."""
ts = int(time.time())
msg_id_1 = await MessageRepository.create(
msg_type="PRIV",
conversation_key="k1",
text="t1",
sender_timestamp=ts,
received_at=ts,
)
msg_id_2 = await MessageRepository.create(
msg_type="PRIV",
conversation_key="k2",
text="t2",
sender_timestamp=ts,
received_at=ts,
)
linked_1, _ = await RawPacketRepository.create(b"\x01\x02\x03", ts)
linked_2, _ = await RawPacketRepository.create(b"\x04\x05\x06", ts)
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1)
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2)
await RawPacketRepository.mark_decrypted(linked_1, 101)
await RawPacketRepository.mark_decrypted(linked_2, 102)
await RawPacketRepository.create(b"\x07\x08\x09", ts) # undecrypted, should remain
@@ -1252,24 +1163,10 @@ class TestMaintenanceEndpoint:
from app.routers.packets import MaintenanceRequest, run_maintenance
ts = int(time.time())
msg_id_1 = await MessageRepository.create(
msg_type="PRIV",
conversation_key="k1",
text="t1",
sender_timestamp=ts,
received_at=ts,
)
msg_id_2 = await MessageRepository.create(
msg_type="PRIV",
conversation_key="k2",
text="t2",
sender_timestamp=ts,
received_at=ts,
)
linked_1, _ = await RawPacketRepository.create(b"\x0a\x0b\x0c", ts)
linked_2, _ = await RawPacketRepository.create(b"\x0d\x0e\x0f", ts)
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1)
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2)
await RawPacketRepository.mark_decrypted(linked_1, 201)
await RawPacketRepository.mark_decrypted(linked_2, 202)
request = MaintenanceRequest(purge_linked_raw_packets=True)
result = await run_maintenance(request)
+1 -51
View File
@@ -1,8 +1,7 @@
"""Tests for the channels router endpoints."""
import time
from hashlib import sha256
from unittest.mock import AsyncMock, patch
from unittest.mock import patch
import pytest
@@ -78,55 +77,6 @@ class TestCreateChannel:
assert channel is not None
assert channel.flood_scope_override is None
@pytest.mark.asyncio
async def test_bulk_hashtag_create_adds_only_new_rooms(self, test_db, client):
ops_key = sha256(b"#ops").digest()[:16].hex().upper()
await ChannelRepository.upsert(key=ops_key, name="#ops", is_hashtag=True)
response = await client.post(
"/api/channels/bulk-hashtag",
json={
"channel_names": ["#ops", "mesh-room", "bad_room", "mesh-room", "another-room"],
"try_historical": False,
},
)
assert response.status_code == 200
data = response.json()
assert [channel["name"] for channel in data["created_channels"]] == [
"#mesh-room",
"#another-room",
]
assert data["existing_count"] == 2
assert data["invalid_names"] == ["bad_room"]
assert data["decrypt_started"] is False
@pytest.mark.asyncio
async def test_bulk_hashtag_create_can_start_one_decrypt_job(self, test_db, client):
with (
patch(
"app.routers.channels.RawPacketRepository.get_undecrypted_count",
new=AsyncMock(return_value=7),
),
patch(
"app.routers.channels._run_historical_channel_decryption_for_channels",
new=AsyncMock(),
) as mock_decrypt,
):
response = await client.post(
"/api/channels/bulk-hashtag",
json={
"channel_names": ["ops", "mesh-room"],
"try_historical": True,
},
)
assert response.status_code == 202
data = response.json()
assert data["decrypt_started"] is True
assert data["decrypt_total_packets"] == 7
mock_decrypt.assert_awaited_once()
class TestPublicChannelProtection:
@pytest.mark.asyncio
+1 -1
View File
@@ -33,7 +33,7 @@ class TestTransportExclusivity:
def test_tcp_default_port(self):
s = Settings(tcp_host="192.168.1.1")
assert s.tcp_port == 5000
assert s.tcp_port == 4000
def test_ble_only(self):
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
-42
View File
@@ -363,48 +363,6 @@ class TestDeleteContactCascade:
assert len(await ContactNameHistoryRepository.get_history(KEY_A)) == 0
assert len(await ContactAdvertPathRepository.get_recent_for_contact(KEY_A)) == 0
@pytest.mark.asyncio
async def test_delete_preserves_dms_and_readd_resurfaces_them(self, test_db, client):
await _insert_contact(KEY_A, "Alice")
# Create an incoming DM for this contact
await MessageRepository.create(
msg_type="PRIV",
conversation_key=KEY_A,
text="hello",
sender_timestamp=1000,
received_at=1000,
)
# Unread count should include the DM
unreads = await MessageRepository.get_unread_counts()
assert unreads["counts"].get(f"contact-{KEY_A.lower()}", 0) == 1
with patch("app.routers.contacts.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
mock_rm.radio_operation = _noop_radio_operation()
response = await client.delete(f"/api/contacts/{KEY_A}")
assert response.status_code == 200
# DMs are preserved in the database
msgs = await MessageRepository.get_all(msg_type="PRIV", conversation_key=KEY_A)
assert len(msgs) == 1
# Orphaned DMs still appear in unread counts (LEFT JOIN)
unreads = await MessageRepository.get_unread_counts()
assert unreads["counts"].get(f"contact-{KEY_A.lower()}", 0) == 1
# Re-add the contact
await _insert_contact(KEY_A, "Alice Returns")
# Messages re-surface with the re-added contact
msgs = await MessageRepository.get_all(msg_type="PRIV", conversation_key=KEY_A)
assert len(msgs) == 1
unreads = await MessageRepository.get_unread_counts()
assert unreads["counts"].get(f"contact-{KEY_A.lower()}", 0) == 1
class TestMarkRead:
"""Test POST /api/contacts/{public_key}/mark-read."""
+17 -19
View File
@@ -513,9 +513,7 @@ class TestMigration018:
from hashlib import sha256
assert bytes(rows[0]["payload_hash"]) == sha256(b"hash_a").digest()
# message_id=42 was orphaned (no matching messages row), so
# migration 49's orphan cleanup NULLs it out.
assert rows[1]["message_id"] is None
assert rows[1]["message_id"] == 42
# Verify payload_hash unique index still works
cursor = await conn.execute(
@@ -1249,8 +1247,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 13
assert await get_version(conn) == 51
assert applied == 10
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
@@ -1321,8 +1319,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 13
assert await get_version(conn) == 51
assert applied == 10
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
@@ -1388,8 +1386,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 7
assert await get_version(conn) == 51
assert applied == 4
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
@@ -1441,8 +1439,8 @@ class TestMigration040:
applied = await run_migrations(conn)
assert applied == 12
assert await get_version(conn) == 51
assert applied == 9
assert await get_version(conn) == 49
await conn.execute(
"""
@@ -1503,8 +1501,8 @@ class TestMigration041:
applied = await run_migrations(conn)
assert applied == 11
assert await get_version(conn) == 51
assert applied == 8
assert await get_version(conn) == 49
await conn.execute(
"""
@@ -1556,8 +1554,8 @@ class TestMigration042:
applied = await run_migrations(conn)
assert applied == 10
assert await get_version(conn) == 51
assert applied == 7
assert await get_version(conn) == 49
await conn.execute(
"""
@@ -1696,8 +1694,8 @@ class TestMigration046:
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 51
assert applied == 3
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
@@ -1790,8 +1788,8 @@ class TestMigration047:
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 51
assert applied == 2
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
+11 -11
View File
@@ -180,7 +180,7 @@ class TestChannelMessagePipeline:
assert result is not None
# Raw packet should be stored
raw_packets = [p async for p in RawPacketRepository.stream_all_undecrypted()]
raw_packets = await RawPacketRepository.get_all_undecrypted()
assert len(raw_packets) >= 1
# No message broadcast (only raw_packet broadcast)
@@ -900,7 +900,7 @@ class TestCreateMessageFromDecrypted:
)
# Verify packet is marked decrypted (has message_id set)
undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
undecrypted = await RawPacketRepository.get_all_undecrypted()
packet_ids = [p[0] for p in undecrypted]
assert packet_id not in packet_ids # Should be marked as decrypted
@@ -1206,7 +1206,7 @@ class TestCreateDMMessageFromDecrypted:
)
# Verify packet is marked decrypted
undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
undecrypted = await RawPacketRepository.get_all_undecrypted()
packet_ids = [p[0] for p in undecrypted]
assert packet_id not in packet_ids
@@ -1314,7 +1314,7 @@ class TestDMDecryptionFunction:
assert messages[0].outgoing is False
# Verify raw packet is linked
undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
undecrypted = await RawPacketRepository.get_all_undecrypted()
assert packet_id not in [p[0] for p in undecrypted]
@@ -2080,7 +2080,7 @@ class TestProcessRawPacketIntegration:
result = await process_raw_packet(raw, timestamp=7000)
# Verify packet is in undecrypted list
undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
undecrypted = await RawPacketRepository.get_all_undecrypted()
packet_ids = [p[0] for p in undecrypted]
assert result["packet_id"] in packet_ids
@@ -2590,7 +2590,7 @@ class TestHistoricalChannelDecryptIntegration:
assert len(message_broadcasts) == 0
# Raw packet is in the undecrypted pool
undecrypted = [p async for p in RawPacketRepository.stream_all_undecrypted()]
undecrypted = await RawPacketRepository.get_all_undecrypted()
assert len(undecrypted) == 1
packet_id = undecrypted[0][0]
@@ -2615,7 +2615,7 @@ class TestHistoricalChannelDecryptIntegration:
assert msg.conversation_key == channel_key_hex
# --- Verify: raw packet is now marked as decrypted ---
undecrypted_after = [p async for p in RawPacketRepository.stream_all_undecrypted()]
undecrypted_after = await RawPacketRepository.get_all_undecrypted()
remaining_ids = [p[0] for p in undecrypted_after]
assert packet_id not in remaining_ids
@@ -2639,7 +2639,7 @@ class TestHistoricalChannelDecryptIntegration:
await process_raw_packet(raw_packet, timestamp=1700000000)
# Packet stored undecrypted
assert len([p async for p in RawPacketRepository.stream_all_undecrypted()]) == 1
assert len(await RawPacketRepository.get_all_undecrypted()) == 1
# Run historical decrypt with the wrong key
with patch("app.websocket.ws_manager") as mock_ws:
@@ -2653,7 +2653,7 @@ class TestHistoricalChannelDecryptIntegration:
assert len(messages) == 0
# Packet still undecrypted
assert len([p async for p in RawPacketRepository.stream_all_undecrypted()]) == 1
assert len(await RawPacketRepository.get_all_undecrypted()) == 1
@pytest.mark.asyncio
async def test_historical_decrypt_multiple_packets(self, test_db, captured_broadcasts):
@@ -2680,7 +2680,7 @@ class TestHistoricalChannelDecryptIntegration:
for pkt in packets:
await process_raw_packet(pkt, timestamp=1700000000)
assert len([p async for p in RawPacketRepository.stream_all_undecrypted()]) == 3
assert len(await RawPacketRepository.get_all_undecrypted()) == 3
# Add channel, run historical decrypt
await ChannelRepository.upsert(key=channel_key_hex, name=channel_name, is_hashtag=True)
@@ -2697,4 +2697,4 @@ class TestHistoricalChannelDecryptIntegration:
assert texts == ["Alice: First message", "Bob: Second message", "Carol: Third message"]
# All packets now decrypted
assert len([p async for p in RawPacketRepository.stream_all_undecrypted()]) == 0
assert len(await RawPacketRepository.get_all_undecrypted()) == 0
-15
View File
@@ -381,11 +381,6 @@ class TestDiscoverMesh:
new_callable=AsyncMock,
return_value=[],
),
patch(
"app.routers.radio.reconcile_contact_messages",
new_callable=AsyncMock,
return_value=(0, 0),
),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
@@ -459,11 +454,6 @@ class TestDiscoverMesh:
new_callable=AsyncMock,
return_value=[],
) as mock_promote,
patch(
"app.routers.radio.reconcile_contact_messages",
new_callable=AsyncMock,
return_value=(0, 0),
),
patch("app.routers.radio.broadcast_event") as mock_broadcast,
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
@@ -789,11 +779,6 @@ class TestTracePath:
new_callable=AsyncMock,
return_value=[],
),
patch(
"app.routers.radio.reconcile_contact_messages",
new_callable=AsyncMock,
return_value=(0, 0),
),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
+299
View File
@@ -768,6 +768,305 @@ class TestSyncAndOffloadAll:
assert payload["public_key"] == KEY_A
class TestSyncAndOffloadContacts:
"""Test sync_and_offload_contacts: pull contacts from radio, save to DB, remove from radio."""
@pytest.mark.asyncio
async def test_syncs_and_removes_contacts(self, test_db):
"""Contacts are upserted to DB and removed from radio."""
from app.radio_sync import sync_and_offload_contacts
contact_payload = {
KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0},
KEY_B: {"adv_name": "Bob", "type": 1, "flags": 0},
}
mock_get_result = MagicMock()
mock_get_result.type = EventType.NEW_CONTACT # Not ERROR
mock_get_result.payload = contact_payload
mock_remove_result = MagicMock()
mock_remove_result.type = EventType.OK
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
result = await sync_and_offload_contacts(mock_mc)
assert result["synced"] == 2
assert result["removed"] == 2
# Verify contacts are in real DB
alice = await ContactRepository.get_by_key(KEY_A)
bob = await ContactRepository.get_by_key(KEY_B)
assert alice is not None
assert alice.name == "Alice"
assert bob is not None
assert bob.name == "Bob"
@pytest.mark.asyncio
async def test_claims_prefix_messages_for_each_contact(self, test_db):
"""Prefix message claims still complete via scheduled reconciliation tasks."""
from app.radio_sync import sync_and_offload_contacts
# Pre-insert a message with a prefix key that matches KEY_A
await MessageRepository.create(
msg_type="PRIV",
text="Hello from prefix",
received_at=1700000000,
conversation_key=KEY_A[:12],
sender_timestamp=1700000000,
)
contact_payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}}
mock_get_result = MagicMock()
mock_get_result.type = EventType.NEW_CONTACT
mock_get_result.payload = contact_payload
mock_remove_result = MagicMock()
mock_remove_result.type = EventType.OK
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
created_tasks: list[asyncio.Task] = []
real_create_task = asyncio.create_task
def _capture_task(coro):
task = real_create_task(coro)
created_tasks.append(task)
return task
with patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task):
await sync_and_offload_contacts(mock_mc)
await asyncio.gather(*created_tasks)
# Verify the prefix message was claimed (promoted to full key)
messages = await MessageRepository.get_all(conversation_key=KEY_A)
assert len(messages) == 1
assert messages[0].conversation_key == KEY_A.lower()
@pytest.mark.asyncio
async def test_reconciliation_does_not_block_contact_removal(self, test_db):
"""Slow reconciliation work is scheduled in background, not awaited inline."""
from app.radio_sync import sync_and_offload_contacts
contact_payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}}
mock_get_result = MagicMock()
mock_get_result.type = EventType.NEW_CONTACT
mock_get_result.payload = contact_payload
mock_remove_result = MagicMock()
mock_remove_result.type = EventType.OK
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
reconcile_started = asyncio.Event()
reconcile_release = asyncio.Event()
created_tasks: list[asyncio.Task] = []
real_create_task = asyncio.create_task
async def _slow_reconcile(*, public_key: str, contact_name: str | None, log):
del public_key, contact_name, log
reconcile_started.set()
await reconcile_release.wait()
def _capture_task(coro):
task = real_create_task(coro)
created_tasks.append(task)
return task
with (
patch("app.radio_sync.reconcile_contact_messages", side_effect=_slow_reconcile),
patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task),
):
result = await sync_and_offload_contacts(mock_mc)
await asyncio.sleep(0)
assert result["synced"] == 1
assert result["removed"] == 1
assert reconcile_started.is_set() is True
assert created_tasks and created_tasks[0].done() is False
mock_mc.commands.remove_contact.assert_awaited_once()
reconcile_release.set()
await asyncio.gather(*created_tasks)
@pytest.mark.asyncio
async def test_handles_remove_failure_gracefully(self, test_db):
"""Failed remove_contact logs warning but continues to next contact."""
from app.radio_sync import sync_and_offload_contacts
contact_payload = {
KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0},
KEY_B: {"adv_name": "Bob", "type": 1, "flags": 0},
}
mock_get_result = MagicMock()
mock_get_result.type = EventType.NEW_CONTACT
mock_get_result.payload = contact_payload
mock_fail_result = MagicMock()
mock_fail_result.type = EventType.ERROR
mock_fail_result.payload = {"error": "busy"}
mock_ok_result = MagicMock()
mock_ok_result.type = EventType.OK
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
# First remove fails, second succeeds
mock_mc.commands.remove_contact = AsyncMock(side_effect=[mock_fail_result, mock_ok_result])
result = await sync_and_offload_contacts(mock_mc)
# Both contacts synced, but only one removed successfully
assert result["synced"] == 2
assert result["removed"] == 1
@pytest.mark.asyncio
async def test_handles_remove_exception_gracefully(self, test_db):
"""Exception during remove_contact is caught and processing continues."""
from app.radio_sync import sync_and_offload_contacts
contact_payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}}
mock_get_result = MagicMock()
mock_get_result.type = EventType.NEW_CONTACT
mock_get_result.payload = contact_payload
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(side_effect=Exception("Timeout"))
result = await sync_and_offload_contacts(mock_mc)
assert result["synced"] == 1
assert result["removed"] == 0
@pytest.mark.asyncio
async def test_returns_error_when_get_contacts_fails(self):
"""Error result from get_contacts returns error dict."""
from app.radio_sync import sync_and_offload_contacts
mock_error_result = MagicMock()
mock_error_result.type = EventType.ERROR
mock_error_result.payload = {"error": "radio busy"}
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_error_result)
result = await sync_and_offload_contacts(mock_mc)
assert result["synced"] == 0
assert result["removed"] == 0
assert "error" in result
@pytest.mark.asyncio
async def test_upserts_with_on_radio_false(self, test_db):
"""Contacts are upserted with on_radio=False (being removed from radio)."""
from app.radio_sync import sync_and_offload_contacts
contact_payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}}
mock_get_result = MagicMock()
mock_get_result.type = EventType.NEW_CONTACT
mock_get_result.payload = contact_payload
mock_remove_result = MagicMock()
mock_remove_result.type = EventType.OK
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
await sync_and_offload_contacts(mock_mc)
contact = await ContactRepository.get_by_key(KEY_A)
assert contact is not None
assert contact.on_radio is False
@pytest.mark.asyncio
async def test_evicts_removed_contacts_from_library_cache(self, test_db):
"""Successfully removed contacts are evicted from mc._contacts.
The MeshCore library's remove_contact() command does not update the
library's in-memory _contacts cache. If we don't evict manually,
sync_recent_contacts_to_radio() will find stale entries via
get_contact_by_key_prefix() and skip re-adding contacts to the radio.
"""
from app.radio_sync import sync_and_offload_contacts
contact_payload = {
KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0},
KEY_B: {"adv_name": "Bob", "type": 1, "flags": 0},
}
mock_get_result = MagicMock()
mock_get_result.type = EventType.NEW_CONTACT
mock_get_result.payload = contact_payload
mock_remove_result = MagicMock()
mock_remove_result.type = EventType.OK
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
# Seed the library's in-memory cache with the same contacts —
# simulating what happens after get_contacts() populates it.
mock_mc._contacts = {
KEY_A: {"public_key": KEY_A, "adv_name": "Alice"},
KEY_B: {"public_key": KEY_B, "adv_name": "Bob"},
}
await sync_and_offload_contacts(mock_mc)
# Both contacts should have been evicted from the library cache
assert KEY_A not in mock_mc._contacts
assert KEY_B not in mock_mc._contacts
assert mock_mc._contacts == {}
@pytest.mark.asyncio
async def test_failed_remove_does_not_evict_from_library_cache(self, test_db):
"""Contacts that fail to remove from radio stay in mc._contacts.
We only evict from the cache on successful removal if the radio
still has the contact, the cache should reflect that.
"""
from app.radio_sync import sync_and_offload_contacts
contact_payload = {
KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0},
}
mock_get_result = MagicMock()
mock_get_result.type = EventType.NEW_CONTACT
mock_get_result.payload = contact_payload
mock_fail_result = MagicMock()
mock_fail_result.type = EventType.ERROR
mock_fail_result.payload = {"error": "busy"}
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_fail_result)
mock_mc._contacts = {
KEY_A: {"public_key": KEY_A, "adv_name": "Alice"},
}
await sync_and_offload_contacts(mock_mc)
# Contact should still be in the cache since removal failed
assert KEY_A in mock_mc._contacts
class TestBackgroundContactReconcile:
"""Test the yielding background contact reconcile loop."""
+8 -25
View File
@@ -137,31 +137,18 @@ class TestRepeaterTelemetryRepository:
assert history[0]["data"] == SAMPLE_STATUS
class TestTelemetryHistoryEndpoint:
"""Tests for the read-only GET telemetry-history endpoint."""
class TestTelemetryHistoryInStatusResponse:
"""Tests that history is embedded in the status response (no separate endpoint)."""
@pytest.mark.asyncio
async def test_returns_history_for_repeater(self, _db, client):
async def test_history_not_available_as_separate_endpoint(self, _db, client):
"""The old GET telemetry-history endpoint should be gone."""
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["data"]["battery_volts"] == 4.15
assert resp.status_code in (404, 405)
@pytest.mark.asyncio
async def test_returns_empty_list_when_no_history(self, _db, client):
await _insert_repeater(KEY_A)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_rejects_non_repeater(self, _db, client):
async def test_history_endpoint_non_repeater_rejected(self, _db, client):
await ContactRepository.upsert(
{
"public_key": KEY_A,
@@ -181,9 +168,5 @@ class TestTelemetryHistoryEndpoint:
}
)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_returns_404_for_unknown_contact(self, _db, client):
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 404
# Either 404 (method not found) or 400 (not a repeater) — endpoint is gone
assert resp.status_code in (400, 404, 405)
+4 -2
View File
@@ -622,6 +622,7 @@ class TestAppSettingsRepository:
"max_radio_contacts": 250,
"favorites": "{not-json",
"auto_decrypt_dm_on_advert": 1,
"sidebar_sort_order": "invalid",
"last_message_times": "{also-not-json",
"preferences_migrated": 0,
"advert_interval": None,
@@ -644,6 +645,7 @@ class TestAppSettingsRepository:
assert settings.max_radio_contacts == 250
assert settings.favorites == []
assert settings.last_message_times == {}
assert settings.sidebar_sort_order == "recent"
assert settings.advert_interval == 0
assert settings.last_advert_time == 0
@@ -678,7 +680,7 @@ class TestAppSettingsRepository:
from app.models import AppSettings
current = AppSettings(preferences_migrated=False)
migrated = AppSettings(preferences_migrated=True)
migrated = AppSettings(preferences_migrated=True, sidebar_sort_order="recent")
with (
patch(
@@ -702,7 +704,7 @@ class TestAppSettingsRepository:
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["sidebar_sort_order"] == "recent"
assert mock_update.call_args.kwargs["preferences_migrated"] is True
+1
View File
@@ -177,6 +177,7 @@ class TestMigratePreferences:
assert response.migrated is True
assert response.settings.preferences_migrated is True
assert response.settings.sidebar_sort_order == "alpha"
assert len(response.settings.favorites) == 1
assert response.settings.favorites[0].type == "contact"
assert response.settings.favorites[0].id == "aa" * 32
Generated
+233 -2
View File
@@ -1,6 +1,6 @@
version = 1
revision = 1
requires-python = ">=3.11"
requires-python = ">=3.10"
[[package]]
name = "aiomqtt"
@@ -8,6 +8,7 @@ version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "paho-mqtt" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/b5/798e4855d17f0f3a2e2ed21c07473fcb4bb45993116693d0f68553927e2c/aiomqtt-2.5.0.tar.gz", hash = "sha256:70e181c140a54ae736394efe2b9e865f665551a5417f6957456cc46010487b21", size = 86453 }
wheels = [
@@ -46,6 +47,7 @@ name = "anyio"
version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
@@ -72,11 +74,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/6b/cfa80a13437896eb8f4504ddac6dfa4ef7f1d2b2261057aa4a30003b8de6/apprise-1.9.7-py3-none-any.whl", hash = "sha256:c7640a81a1097685de66e0508e3da89f49235d566cb44bbead1dd98419bf5ee3", size = 1459879 },
]
[[package]]
name = "async-timeout"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 },
]
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313 },
]
[[package]]
name = "bleak"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-timeout", marker = "python_full_version < '3.11'" },
{ name = "dbus-fast", marker = "sys_platform == 'linux'" },
{ name = "pyobjc-core", marker = "sys_platform == 'darwin'" },
{ name = "pyobjc-framework-corebluetooth", marker = "sys_platform == 'darwin'" },
@@ -143,6 +164,18 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283 },
{ url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504 },
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811 },
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402 },
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217 },
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079 },
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475 },
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829 },
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211 },
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036 },
{ url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184 },
{ url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790 },
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 },
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 },
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 },
@@ -210,6 +243,22 @@ version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709 },
{ url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814 },
{ url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467 },
{ url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280 },
{ url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454 },
{ url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609 },
{ url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849 },
{ url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586 },
{ url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290 },
{ url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663 },
{ url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964 },
{ url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064 },
{ url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015 },
{ url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792 },
{ url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198 },
{ url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262 },
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 },
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 },
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 },
@@ -304,6 +353,10 @@ version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/a4/e54607cf8b0a696beba591f1a543cff5b6a9e4b4f842fd55f7ba741d678d/dbus_fast-3.1.2.tar.gz", hash = "sha256:6c9e1b45e4b5e7df0c021bf1bf3f27649374e47c3de1afdba6d00a7d7bba4b3a", size = 73191 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/81/df380f31ff7646c010c166c160391e86d697b66b2024f56418e2e79bffd6/dbus_fast-3.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a0896821dd8b03f960d1bfabd1fa7f4af580f45ec070c1fe90ad9d093f7e56", size = 826852 },
{ url = "https://files.pythonhosted.org/packages/ca/e3/862bb5a67a6ee83dcd20cc2d916a6aad45df441f57ed2860a894fc69bb21/dbus_fast-3.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abe5e38cd78844a66154bfb2c11e70840849cd4ef8acf63504d3ee7ef14d0d15", size = 871036 },
{ url = "https://files.pythonhosted.org/packages/c3/a3/b8f82873aa0466dbe86e89e8e8fb6f89db5bbd90a31dddfa1f4e109f81ef/dbus_fast-3.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:793e58c123ad513c11a97f1dd423518342b806c4d0d8d7a0763b60a8daeb32d2", size = 832326 },
{ url = "https://files.pythonhosted.org/packages/58/1d/be600b1eb685b7f73606ae78349a93f154164ba7d61345a6be7997c2cdbe/dbus_fast-3.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a5726eba4ad6a9ed951e6a402e2c69418d4cc82668709183c78a7ca24ad17cd8", size = 877282 },
{ url = "https://files.pythonhosted.org/packages/0e/7c/c401f6f90fe049425f381a5219bb499e2b71ea6862a06f4787c3afe8107a/dbus_fast-3.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33be2457766da461d3c79627aa6b007a65dd9af0e9b305ca43d7a7dd2794824a", size = 825097 },
{ url = "https://files.pythonhosted.org/packages/f1/15/f33579339eaf50b64be460b6f34fb567819f7d229d946fa5cc599ce34aae/dbus_fast-3.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15279fd88952442c8b6b0b910b6c5eff74e9380dde74db0841523f3e6206377f", size = 870328 },
{ url = "https://files.pythonhosted.org/packages/16/50/06d0061394395784daf578e8ae688b4c5bf7595bab22db88955a6c35e8a0/dbus_fast-3.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fb4db6cc605193576b6825d1827ff6bde9c09c23e385e33b05db74ed8916021f", size = 830406 },
@@ -327,6 +380,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/89/94/b7ff6279e642b014cd4aef4d914b9fca3917c2c9c35df49db062023cbdfc/dbus_fast-3.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1d7cc1315586e4c50875c9a2d56b9ad2e056ec75e2f27c43cd80392f72d0f6e3", size = 1623709 },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 },
]
[[package]]
name = "execnet"
version = "2.1.2"
@@ -379,6 +444,13 @@ version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531 },
{ url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408 },
{ url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889 },
{ url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460 },
{ url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267 },
{ url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429 },
{ url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173 },
{ url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521 },
{ url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375 },
{ url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621 },
@@ -517,6 +589,7 @@ version = "5.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prettytable" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/4c/b4be9024dae3b5b3c0a6c58cc1d4a35fffe51c3adb835350cb7dcd43b5cd/pip_licenses-5.5.1.tar.gz", hash = "sha256:7df370e6e5024a3f7449abf8e4321ef868ba9a795698ad24ab6851f3e7fc65a7", size = 49108 }
wheels = [
@@ -590,6 +663,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484 },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636 },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675 },
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886 },
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151 },
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461 },
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440 },
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005 },
]
[[package]]
@@ -616,6 +694,19 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298 },
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475 },
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815 },
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567 },
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442 },
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956 },
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253 },
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050 },
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178 },
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833 },
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156 },
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378 },
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622 },
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 },
@@ -694,6 +785,14 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 },
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351 },
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363 },
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615 },
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369 },
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218 },
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951 },
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428 },
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009 },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 },
@@ -768,6 +867,7 @@ version = "12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/bf/3dbb1783388da54e650f8a6b88bde03c101d9ba93dfe8ab1b1873f1cd999/pyobjc_core-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93418e79c1655f66b4352168f8c85c942707cb1d3ea13a1da3e6f6a143bacda7", size = 676748 },
{ url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263 },
{ url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335 },
{ url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370 },
@@ -785,6 +885,7 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/aa/2b2d7ec3ac4b112a605e9bd5c5e5e4fd31d60a8a4b610ab19cc4838aa92a/pyobjc_framework_cocoa-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9b880d3bdcd102809d704b6d8e14e31611443aa892d9f60e8491e457182fdd48", size = 383825 },
{ url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812 },
{ url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590 },
{ url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689 },
@@ -803,6 +904,7 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/25/d21d6cb3fd249c2c2aa96ee54279f40876a0c93e7161b3304bf21cbd0bfe/pyobjc_framework_corebluetooth-12.1.tar.gz", hash = "sha256:8060c1466d90bbb9100741a1091bb79975d9ba43911c9841599879fc45c2bbe0", size = 33157 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/1b/06914f4eb1bd8ce598fdd210e1a7411556286910fc8d8919ab7dbaebe629/pyobjc_framework_corebluetooth-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:937849f4d40a33afbcc56cbe90c8d1fbf30fb27a962575b9fb7e8e2c61d3c551", size = 13187 },
{ url = "https://files.pythonhosted.org/packages/57/7a/26ae106beb97e9c4745065edb3ce3c2bdd91d81f5b52b8224f82ce9d5fb9/pyobjc_framework_corebluetooth-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:37e6456c8a076bd5a2bdd781d0324edd5e7397ef9ac9234a97433b522efb13cf", size = 13189 },
{ url = "https://files.pythonhosted.org/packages/2a/56/01fef62a479cdd6ff9ee40b6e062a205408ff386ce5ba56d7e14a71fcf73/pyobjc_framework_corebluetooth-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe72c9732ee6c5c793b9543f08c1f5bdd98cd95dfc9d96efd5708ec9d6eeb213", size = 13209 },
{ url = "https://files.pythonhosted.org/packages/e0/6c/831139ebf6a811aed36abfdfad846bc380dcdf4e6fb751a310ce719ddcfd/pyobjc_framework_corebluetooth-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a894f695e6c672f0260327103a31ad8b98f8d4fb9516a0383db79a82a7e58dc", size = 13229 },
@@ -821,6 +923,7 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/26/e8/75b6b9b3c88b37723c237e5a7600384ea2d84874548671139db02e76652b/pyobjc_framework_libdispatch-12.1.tar.gz", hash = "sha256:4035535b4fae1b5e976f3e0e38b6e3442ffea1b8aa178d0ca89faa9b8ecdea41", size = 38277 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/76/9936d97586dbae4d7d10f3958d899ee7a763930af69b5ad03d4516178c7c/pyobjc_framework_libdispatch-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:50a81a29506f0e35b4dc313f97a9d469f7b668dae3ba597bb67bbab94de446bd", size = 20471 },
{ url = "https://files.pythonhosted.org/packages/1f/75/c4aeab6ce7268373d4ceabbc5c406c4bbf557038649784384910932985f8/pyobjc_framework_libdispatch-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:954cc2d817b71383bd267cc5cd27d83536c5f879539122353ca59f1c945ac706", size = 20463 },
{ url = "https://files.pythonhosted.org/packages/83/6f/96e15c7b2f7b51fc53252216cd0bed0c3541bc0f0aeb32756fefd31bed7d/pyobjc_framework_libdispatch-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0e9570d7a9a3136f54b0b834683bf3f206acd5df0e421c30f8fd4f8b9b556789", size = 15650 },
{ url = "https://files.pythonhosted.org/packages/38/3a/d85a74606c89b6b293782adfb18711026ff79159db20fc543740f2ac0bc7/pyobjc_framework_libdispatch-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:58ffce5e6bcd7456b4311009480b195b9f22107b7682fb0835d4908af5a68ad0", size = 15668 },
@@ -869,10 +972,12 @@ version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
wheels = [
@@ -884,6 +989,7 @@ name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
@@ -932,6 +1038,15 @@ version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
@@ -983,7 +1098,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.7.1"
version = "3.6.7"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },
@@ -1137,6 +1252,55 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033 },
]
[[package]]
name = "tomli"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236 },
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084 },
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832 },
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052 },
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555 },
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128 },
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445 },
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165 },
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891 },
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796 },
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121 },
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070 },
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859 },
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296 },
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124 },
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698 },
{ url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819 },
{ url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766 },
{ url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771 },
{ url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586 },
{ url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792 },
{ url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909 },
{ url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946 },
{ url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705 },
{ url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244 },
{ url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637 },
{ url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925 },
{ url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045 },
{ url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835 },
{ url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109 },
{ url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930 },
{ url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964 },
{ url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065 },
{ url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088 },
{ url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193 },
{ url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488 },
{ url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669 },
{ url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709 },
{ url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563 },
{ url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756 },
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408 },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -1183,6 +1347,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761 }
wheels = [
@@ -1206,6 +1371,12 @@ version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335 },
{ url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903 },
{ url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499 },
{ url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133 },
{ url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681 },
{ url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261 },
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420 },
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677 },
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819 },
@@ -1247,6 +1418,18 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318 },
{ url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478 },
{ url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894 },
{ url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065 },
{ url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377 },
{ url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837 },
{ url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456 },
{ url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614 },
{ url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690 },
{ url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459 },
{ url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663 },
{ url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453 },
{ url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529 },
{ url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384 },
{ url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789 },
@@ -1319,6 +1502,10 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 },
{ url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611 },
{ url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889 },
{ url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616 },
{ url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413 },
{ url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250 },
{ url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117 },
{ url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493 },
@@ -1340,6 +1527,17 @@ version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423 },
{ url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080 },
{ url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329 },
{ url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312 },
{ url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319 },
{ url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631 },
{ url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016 },
{ url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426 },
{ url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360 },
{ url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388 },
{ url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830 },
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423 },
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082 },
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330 },
@@ -1373,6 +1571,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 },
{ url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109 },
{ url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343 },
{ url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599 },
{ url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207 },
{ url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155 },
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884 },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 },
]
@@ -1385,6 +1589,9 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/28/26d86ca6d2f155f31ca61e069312034a8922a5a89f5d0fc68abb7c04aad1/winrt_runtime-3.2.1-cp310-cp310-win32.whl", hash = "sha256:25a2d1e2b45423742319f7e10fa8ca2e7063f01284b6e85e99d805c4b50bbfb3", size = 210993 },
{ url = "https://files.pythonhosted.org/packages/46/a4/f096687e0d1877d206bc5d1f5f07ff90e00b0772d69d4559ab2b6b37090b/winrt_runtime-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:dc81d5fb736bf1ddecf743928622253dce4d0aac9a57faad776d7a3834e13257", size = 242210 },
{ url = "https://files.pythonhosted.org/packages/ff/81/46927ce4d79fc8f40f193f35204bce79eff7c496d888825a7a74d8560b6e/winrt_runtime-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:363f584b1e9fcb601e3e178636d8877e6f0537ac3c96ce4a96f06066f8ff0eae", size = 415833 },
{ url = "https://files.pythonhosted.org/packages/90/8d/d7ae0e07cd85c7768de76e8578261854f2af72bd3a8a527bb675e8ae0eda/winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022", size = 210798 },
{ url = "https://files.pythonhosted.org/packages/ac/66/d05f6e6c0517654734e7f87fa1f0fbc965add9f27cc36b524d96331ab3d8/winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c", size = 242032 },
{ url = "https://files.pythonhosted.org/packages/39/a5/760c8396110f6d3e4c417752da1a2bf3b89e0998329c2f10afc717ef6291/winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130", size = 415659 },
@@ -1408,6 +1615,9 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/b7/822da8bc0b6a67cc0c3e460fef793f00c51a6fe59aa54f6bfe416519a9d9/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win32.whl", hash = "sha256:49489351037094a088a08fbdf0f99c94e3299b574edb211f717c4c727770af78", size = 105569 },
{ url = "https://files.pythonhosted.org/packages/68/46/696893d3bae80751e35fb0fb8fae5e7fc94a5354dfb5e19167d415e27c66/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:20f6a21029034c18ea6a6b6df399671813b071102a0d6d8355bb78cf4f547cdb", size = 114743 },
{ url = "https://files.pythonhosted.org/packages/5b/6a/a36b28739b73cc2c67050da866b063af135b5f6c071997c85a27adb6815c/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c523814eab795bc1bf913292309cb1025ef0a67d5fc33863a98788995e551d", size = 105021 },
{ url = "https://files.pythonhosted.org/packages/3b/cf/671bf29337323cc08f9969cb32312f217d2927d29dbf2964f0dbb378cb90/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b", size = 105535 },
{ url = "https://files.pythonhosted.org/packages/b6/d5/5761a8b6dcc56957018970dd443059c8ee8a79de7b07f0b4d143f8e7dc15/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d", size = 114612 },
{ url = "https://files.pythonhosted.org/packages/24/0b/7819bb102286752d3572a75d03e6a8000ffe3c6cb7aee3eb136dca383fe2/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80", size = 105017 },
@@ -1431,6 +1641,9 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b9/c2b0d201b8b38895809591d089a5edc37e702a23f3a6bc6e542c5e7d6dbf/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win32.whl", hash = "sha256:a758c5f81a98cc38347fdfb024ce62720969480e8c5b98e402b89d2b09b32866", size = 89730 },
{ url = "https://files.pythonhosted.org/packages/56/f9/f086c3ac17745a71d8384e1831cab0d5a7c737e1fe5cb84d7584f6c14bbf/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:f982ef72e729ddd60cdb975293866e84bb838798828933012a57ee4bf12b0ea1", size = 95825 },
{ url = "https://files.pythonhosted.org/packages/aa/b5/f7f830b2da1fb7ffcaf25ce2734db0019615111f8f39e7b4d83fea4a0bd0/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:e88a72e1e09c7ccc899a9e6d2ab3fc0f43b5dd4509bcc49ec4abf65b55ab015f", size = 89402 },
{ url = "https://files.pythonhosted.org/packages/ad/5e/c628719e877a89f00cac7ce53f9666acbc5ed6f074130729d5d6768b63ff/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9", size = 89614 },
{ url = "https://files.pythonhosted.org/packages/ac/1a/d172d6f1c2fae53535e7f23835025cf39e3002749a0304f18a38e8ed490d/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09", size = 95783 },
{ url = "https://files.pythonhosted.org/packages/67/c1/568dfdaea62ca3b13bb70162cb292e5cd0be5bbb98b738961ddcc2edd374/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401", size = 89253 },
@@ -1454,6 +1667,9 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/a3/449ffc2f8e4c3cfbe7f14c1b43bcaa0475fbd2e8e8bf08465399c5ea078c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win32.whl", hash = "sha256:af4914d7b30b49232092cd3b934e3ed6f5d3b1715ba47238541408ee595b7f46", size = 182059 },
{ url = "https://files.pythonhosted.org/packages/50/d9/6ea88731df569f5c1b086daf4c3496c8d43281588e3a578ea623fef6bc43/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0e557dd52fc80392b8bd7c237e1153a50a164b3983838b4ac674551072efc9ed", size = 187866 },
{ url = "https://files.pythonhosted.org/packages/e9/2c/ace56fd32ad07608462de0ac7df218e0bf810e4cc31f2c0fbd7f5f90ee93/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:64cff62baa6b7aadd6c206e61d149113fdcda17360feb6e9d05bc8bbda4b9fde", size = 184627 },
{ url = "https://files.pythonhosted.org/packages/fa/5e/349a5d958be8c0570f0a49bbb746088bcfaa81555accb57503ba01185359/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c", size = 182312 },
{ url = "https://files.pythonhosted.org/packages/90/db/929ab0085ec89e46bd3a58c74b451dd770c3285dfa0cbd4f4aa4730da004/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415", size = 187768 },
{ url = "https://files.pythonhosted.org/packages/a3/53/f316e2224c384178204430439f04f9b72017fe8237e341a9aebb20da8191/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7", size = 184189 },
@@ -1477,6 +1693,9 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/61/2744d0e0b3fa7807149a1a36dd89abba901d6b24184d9fd5ef3f28467232/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win32.whl", hash = "sha256:40dac777d8f45b41449f3ff1ae70f0d457f1ede53f53962a6e2521b651533db5", size = 130040 },
{ url = "https://files.pythonhosted.org/packages/7a/f9/881b7ee8acdf3c9fe6c79d8ccd90f9246b397fc78420d55014c4ac05b822/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a101ec3e0ad0a0783032fdcd5dc48e7cd68ee034cbde4f903a8c7b391532c71a", size = 142463 },
{ url = "https://files.pythonhosted.org/packages/12/db/b09dffcf1158b35d81d8d57bf19ad04293870cea5afa77943c87f1110d88/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:3296a3863ac086928ff3f3dc872b2a2fb971dab728817424264f3ca547504e9e", size = 135871 },
{ url = "https://files.pythonhosted.org/packages/a6/92/ca1fd311d96fce15fba25543a2ae3cb829744a8af548a11d74233d0e4f64/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011", size = 129898 },
{ url = "https://files.pythonhosted.org/packages/03/fd/5bd5da5d7997725ba3f1995c16aa1c3362937f8ff68ad4cadfd3415eebcb/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd", size = 142361 },
{ url = "https://files.pythonhosted.org/packages/df/be/d423b63e740600e0617ddb85fba3ef99e7bbff02299fe46323bfe624a382/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817", size = 135808 },
@@ -1500,6 +1719,9 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/1b/0de659ed4bb80aee28753b4431011334205637a2578481a511866a11e0cf/winrt_windows_devices_radios-3.2.1-cp310-cp310-win32.whl", hash = "sha256:f97766fd551d06c102155d51b2922f96663dee045e1f8d57177def0a2149cb78", size = 38643 },
{ url = "https://files.pythonhosted.org/packages/29/fd/67c6db8a3244ecc95f85970a7b0e749cda28e26563db1274c3db36a8fbe4/winrt_windows_devices_radios-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:104b737fa1279a3b6a88ba3c6236157afc1de03c472657c45e5176ad7a209e23", size = 40295 },
{ url = "https://files.pythonhosted.org/packages/15/6d/d145c7f90b01c24f4f9885d1f7d430ecaf2a2b42b6bc236701791b0b0a06/winrt_windows_devices_radios-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:55b02877d2de06ca6f0f6140611a9af9d0c65710e28f1afdeaac1040433b1837", size = 37060 },
{ url = "https://files.pythonhosted.org/packages/6b/a0/4a8b51da15de218cec04bcc1cd85b4b93bcfd8ebe50a5f0a7eee28836dc6/winrt_windows_devices_radios-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7c02790472414b6cda00d24a8cd23bca18e4b7474ddad4f9264f4484b891807e", size = 38505 },
{ url = "https://files.pythonhosted.org/packages/de/49/ba69e3180585dbc6f3336a09fef7cba4558a6a1e7d500500f62c1478418e/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f87745486d313ba1e7562ca97f25ad436ec01ad4b3b9ea349fb6b6f25cb41104", size = 40157 },
{ url = "https://files.pythonhosted.org/packages/9c/92/64817f71a20ecf842da36dc3848f42614217688137a69c93fda8a6103155/winrt_windows_devices_radios-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6cee6f946ff3a3571850d1ca745edaee7c331d06ca321873e650779654effc4a", size = 36976 },
@@ -1523,6 +1745,9 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/69/d387332c4378b41f87211b7dc40a4cfc6b7047dc227448aaa207624fc911/winrt_windows_foundation-3.2.1-cp310-cp310-win32.whl", hash = "sha256:677e98165dcbbf7a2367f905bc61090ef2c568b6e465f87cf7276df4734f3b0b", size = 111969 },
{ url = "https://files.pythonhosted.org/packages/52/71/046c1e2424627c3db66d764871186de4d26936e8a138d6bf04dc143e4606/winrt_windows_foundation-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8f27b4f0fdb73ccc4a3e24bc8010a6607b2bdd722fa799eafce7daa87d19d39", size = 118695 },
{ url = "https://files.pythonhosted.org/packages/e0/2e/2463bc4ad984836fb3ecf1abac62df67bc5cabab004cad09b828b86ed51b/winrt_windows_foundation-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d900c6165fab4ea589811efa2feed27b532e1b6f505f63bf63e2052b8cb6bdc4", size = 109690 },
{ url = "https://files.pythonhosted.org/packages/c0/36/09b9757f7cbf269e67008ea2ad188a44f974c94c9b49ebf0b52d1a8c4069/winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437", size = 111944 },
{ url = "https://files.pythonhosted.org/packages/05/a5/216d66df6bdcee58eb3877fabc1544337e23f850bf9f93838db7f5698371/winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e", size = 118465 },
{ url = "https://files.pythonhosted.org/packages/be/ca/48ca8b5bc5be5c7a5516c9e1d9a21861b4217e1b4ee57923aab6f13fa411/winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671", size = 109609 },
@@ -1546,6 +1771,9 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/26/ed3d35ea262999d28be957c35a32e93360eac0ef9f14e75d32cd6b5c6a37/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win32.whl", hash = "sha256:46948484addfc4db981dab35688d4457533ceb54d4954922af41503fddaa8389", size = 59880 },
{ url = "https://files.pythonhosted.org/packages/cb/39/b4a1aeba2d13c1f2ad3d851d5092b8397c05f34fb318d6a7d499f5b5720b/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:899eaa3a93c35bfb1857d649e8dd60c38b978dda7cedd9725fcdbcebba156fd6", size = 70650 },
{ url = "https://files.pythonhosted.org/packages/9f/74/f8a4a29202da24f2af2c4a8f515b0a44fe46bc4d25b3d54ea2249e980bd3/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:c36eb49ad1eba1b32134df768bb47af13cabb9b59f974a3cea37843e2d80e0e6", size = 59216 },
{ url = "https://files.pythonhosted.org/packages/87/b3/7e4a75c62e86bedf9458b7ec8dfed74cff3236e0b4b2288f95967d5cc4d2/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7", size = 59693 },
{ url = "https://files.pythonhosted.org/packages/32/58/049db1d95fdfc0c8451dc6db17442ed4e6b2aba361c425c0bb8dc8c98c4a/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89", size = 70828 },
{ url = "https://files.pythonhosted.org/packages/5b/6b/a04974f5555c86452e54c19d063d9fd45f0fe9f2a6858e7fe12c639043fb/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523", size = 59051 },
@@ -1569,6 +1797,9 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/4d/a0d806f4664b9bcf525bd31dcdf1f9520cc14f033e897dc7f7dd4ad4eb77/winrt_windows_storage_streams-3.2.1-cp310-cp310-win32.whl", hash = "sha256:89bb2d667ebed6861af36ed2710757456e12921ee56347946540320dacf6c003", size = 127791 },
{ url = "https://files.pythonhosted.org/packages/99/2c/00baa87041a3d92a3cc5230d4033e995a52740e9c08fcd9f7bde93cb979f/winrt_windows_storage_streams-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:48a78e5dc7d3488eb77e449c278bc6d6ac28abcdda7df298462c4112d7635d00", size = 132608 },
{ url = "https://files.pythonhosted.org/packages/f0/d0/ed03e864aa8eaaec964d5bbc95baccf738275ae6cc88600db66ecb5adaf4/winrt_windows_storage_streams-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:da71231d4a554f9f15f1249b4990c6431176f6dfb0e3385c7caa7896f4ca24d6", size = 128495 },
{ url = "https://files.pythonhosted.org/packages/19/60/a9e0dc03434aa29e6b5c83067e988cd5934adf830cd9f87cbbc06569ca32/winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69", size = 127509 },
{ url = "https://files.pythonhosted.org/packages/23/98/6c9c21b5e75ff5927a130da9eaf5ab628dfa1f93b64c181f0193706cbd6c/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5", size = 132491 },
{ url = "https://files.pythonhosted.org/packages/38/ca/d0a02045d445cbf1029d65f01b487fdded5b333c0367a8bae0565b3def00/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780", size = 128577 },