Add channel path hash width override

This commit is contained in:
Jack Kingsman
2026-04-03 13:05:58 -07:00
parent d802dd4212
commit 8e998c03ba
16 changed files with 374 additions and 22 deletions
+2 -1
View File
@@ -218,6 +218,7 @@ app/
- `POST /channels/bulk-hashtag`
- `DELETE /channels/{key}`
- `POST /channels/{key}/flood-scope-override`
- `POST /channels/{key}/path-hash-mode-override`
- `POST /channels/{key}/mark-read`
### Messages
@@ -280,7 +281,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
Main tables:
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
- `channels`
Includes optional `flood_scope_override` for channel-specific regional sends.
Includes optional `flood_scope_override` for channel-specific regional sends and optional `path_hash_mode_override` for per-channel path hop width.
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
- `raw_packets`
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
+1
View File
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS channels (
is_hashtag INTEGER DEFAULT 0,
on_radio INTEGER DEFAULT 0,
flood_scope_override TEXT,
path_hash_mode_override INTEGER,
last_read_at INTEGER
);
+22
View File
@@ -395,6 +395,12 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 51)
applied += 1
if version < 52:
logger.info("Applying migration 52: add path_hash_mode_override to channels")
await _migrate_052_add_channel_path_hash_mode_override(conn)
await set_version(conn, 52)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -3149,3 +3155,19 @@ async def _migrate_051_drop_sidebar_sort_order(conn: aiosqlite.Connection) -> No
await conn.commit()
else:
raise
async def _migrate_052_add_channel_path_hash_mode_override(conn: aiosqlite.Connection) -> None:
"""Add nullable per-channel path hash mode override column."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "channels" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
try:
await conn.execute("ALTER TABLE channels ADD COLUMN path_hash_mode_override INTEGER")
await conn.commit()
except Exception as e:
if "duplicate column" in str(e).lower():
await conn.commit()
else:
raise
+4
View File
@@ -330,6 +330,10 @@ class Channel(BaseModel):
default=None,
description="Per-channel outbound flood scope override (null = use global app setting)",
)
path_hash_mode_override: int | None = Field(
default=None,
description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
)
last_read_at: int | None = None # Server-side read state tracking
+16 -3
View File
@@ -26,7 +26,7 @@ class ChannelRepository:
"""Get a channel by its key (32-char hex string)."""
cursor = await db.conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
FROM channels
WHERE key = ?
""",
@@ -40,6 +40,7 @@ class ChannelRepository:
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"],
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"],
)
return None
@@ -48,7 +49,7 @@ class ChannelRepository:
async def get_all() -> list[Channel]:
cursor = await db.conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
FROM channels
ORDER BY name
"""
@@ -61,6 +62,7 @@ class ChannelRepository:
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"],
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"],
)
for row in rows
@@ -71,7 +73,7 @@ class ChannelRepository:
"""Return channels currently marked as resident on the radio in the database."""
cursor = await db.conn.execute(
"""
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
FROM channels
WHERE on_radio = 1
ORDER BY name
@@ -85,6 +87,7 @@ class ChannelRepository:
is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"],
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"],
)
for row in rows
@@ -123,6 +126,16 @@ class ChannelRepository:
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def update_path_hash_mode_override(key: str, path_hash_mode_override: int | None) -> bool:
"""Set or clear a channel's path hash mode override."""
cursor = await db.conn.execute(
"UPDATE channels SET path_hash_mode_override = ? WHERE key = ?",
(path_hash_mode_override, key.upper()),
)
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def mark_all_read(timestamp: int) -> None:
"""Mark all channels as read at the given timestamp."""
+32
View File
@@ -60,6 +60,15 @@ class ChannelFloodScopeOverrideRequest(BaseModel):
)
class ChannelPathHashModeOverrideRequest(BaseModel):
path_hash_mode_override: int | None = Field(
default=None,
ge=0,
le=2,
description="Path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
)
def _derive_channel_identity(
requested_name: str,
request_key: str | None = None,
@@ -348,6 +357,29 @@ async def set_channel_flood_scope_override(
return refreshed
@router.post("/{key}/path-hash-mode-override", response_model=Channel)
async def set_channel_path_hash_mode_override(
key: str, request: ChannelPathHashModeOverrideRequest
) -> Channel:
"""Set or clear a per-channel path hash mode override."""
channel = await ChannelRepository.get_by_key(key)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
updated = await ChannelRepository.update_path_hash_mode_override(
channel.key, request.path_hash_mode_override
)
if not updated:
raise HTTPException(status_code=500, detail="Failed to update path-hash-mode override")
refreshed = await ChannelRepository.get_by_key(channel.key)
if refreshed is None:
raise HTTPException(status_code=500, detail="Channel disappeared after update")
broadcast_event("channel", refreshed.model_dump())
return refreshed
@router.delete("/{key}")
async def delete_channel(key: str) -> dict:
"""Delete a channel from the database by key.
+68 -1
View File
@@ -122,7 +122,7 @@ async def send_channel_message_with_effective_scope(
error_broadcast_fn: BroadcastFn,
app_settings_repository=AppSettingsRepository,
) -> Any:
"""Send a channel message, temporarily overriding flood scope when configured."""
"""Send a channel message, temporarily overriding flood scope and/or path hash mode."""
override_scope = normalize_region_scope(channel.flood_scope_override)
baseline_scope = ""
@@ -151,6 +151,36 @@ async def send_channel_message_with_effective_scope(
),
)
# Path hash mode per-channel override
override_phm = channel.path_hash_mode_override
baseline_phm = radio_manager.path_hash_mode
apply_phm = (
override_phm is not None
and radio_manager.path_hash_mode_supported
and override_phm != baseline_phm
)
if apply_phm:
logger.info(
"Temporarily applying channel path_hash_mode override for %s: %d",
channel.name,
override_phm,
)
phm_result = await mc.commands.set_path_hash_mode(override_phm)
if phm_result is not None and phm_result.type == EventType.ERROR:
logger.warning(
"Failed to apply channel path_hash_mode override for %s: %s",
channel.name,
phm_result.payload,
)
raise HTTPException(
status_code=500,
detail=(
f"Failed to apply path hash mode override before {action_label}: "
f"{phm_result.payload}"
),
)
try:
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
channel_key,
@@ -254,6 +284,43 @@ async def send_channel_message_with_effective_scope(
),
)
if apply_phm:
try:
restore_phm = await mc.commands.set_path_hash_mode(baseline_phm)
if restore_phm is not None and restore_phm.type == EventType.ERROR:
logger.error(
"Failed to restore baseline path_hash_mode after sending to %s: %s",
channel.name,
restore_phm.payload,
)
error_broadcast_fn(
"Path hash mode restore failed",
(
f"Sent to {channel.name}, but restoring path hash mode failed. "
"The radio may be using a non-default hop width. "
"Consider rebooting the radio."
),
)
else:
radio_manager.path_hash_mode = baseline_phm
logger.debug(
"Restored baseline path_hash_mode after channel send: %d",
baseline_phm,
)
except Exception:
logger.exception(
"Failed to restore baseline path_hash_mode after sending to %s",
channel.name,
)
error_broadcast_fn(
"Path hash mode restore failed",
(
f"Sent to {channel.name}, but restoring path hash mode failed. "
"The radio may be using a non-default hop width. "
"Consider rebooting the radio."
),
)
def _extract_expected_ack_code(result: Any) -> str | None:
if result is None or result.type == EventType.ERROR: