diff --git a/AGENTS.md b/AGENTS.md index 2d263c9..f265a75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -353,6 +353,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels | | DELETE | `/api/channels/{key}` | Delete channel | | POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override | +| POST | `/api/channels/{key}/path-hash-mode-override` | Set or clear a per-channel path hash mode override | | POST | `/api/channels/{key}/mark-read` | Mark channel as read | | GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) | | GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) | @@ -401,6 +402,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). - Hashtag channels: `SHA256("#name")[:16]` converted to hex - Custom channels: User-provided or generated - Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting. +- Channels may persist `path_hash_mode_override` (0/1/2); when set, channel sends temporarily switch the radio path hash mode for the duration of the send, then restore the radio default. ### Message Types diff --git a/app/AGENTS.md b/app/AGENTS.md index ba7136d..413a5bb 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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) diff --git a/app/database.py b/app/database.py index b81e819..6c633c1 100644 --- a/app/database.py +++ b/app/database.py @@ -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 ); diff --git a/app/migrations.py b/app/migrations.py index 52b6386..b702eb4 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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 diff --git a/app/models.py b/app/models.py index 17fca18..8a4c7d0 100644 --- a/app/models.py +++ b/app/models.py @@ -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 diff --git a/app/repository/channels.py b/app/repository/channels.py index 8a28ade..e7a765a 100644 --- a/app/repository/channels.py +++ b/app/repository/channels.py @@ -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.""" diff --git a/app/routers/channels.py b/app/routers/channels.py index d7e0fc9..2f02bb2 100644 --- a/app/routers/channels.py +++ b/app/routers/channels.py @@ -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. diff --git a/app/services/message_send.py b/app/services/message_send.py index d93136a..86df64a 100644 --- a/app/services/message_send.py +++ b/app/services/message_send.py @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e172f5f..c2cf6af 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -397,6 +397,7 @@ export function App() { handleSendMessage, handleResendChannelMessage, handleSetChannelFloodScopeOverride, + handleSetChannelPathHashModeOverride, handleSenderClick, handleTrace, handlePathDiscovery, @@ -527,6 +528,7 @@ export function App() { onDeleteContact: handleDeleteContact, onDeleteChannel: handleDeleteChannel, onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride, + onSetChannelPathHashModeOverride: handleSetChannelPathHashModeOverride, onOpenContactInfo: handleOpenContactInfo, onOpenChannelInfo: handleOpenChannelInfo, onSenderClick: handleSenderClick, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 76fb34f..449d501 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -210,6 +210,12 @@ export const api = { body: JSON.stringify({ flood_scope_override: floodScopeOverride }), }), + setChannelPathHashModeOverride: (key: string, pathHashModeOverride: number | null) => + fetchJson(`/channels/${key}/path-hash-mode-override`, { + method: 'POST', + body: JSON.stringify({ path_hash_mode_override: pathHashModeOverride }), + }), + // Messages getMessages: ( params?: { diff --git a/frontend/src/components/ChannelPathHashModeOverrideModal.tsx b/frontend/src/components/ChannelPathHashModeOverrideModal.tsx new file mode 100644 index 0000000..242cc8c --- /dev/null +++ b/frontend/src/components/ChannelPathHashModeOverrideModal.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react'; + +import { Button } from './ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Label } from './ui/label'; + +const PATH_HASH_MODE_LABELS: Record = { + 0: '1-byte', + 1: '2-byte', + 2: '3-byte', +}; + +interface ChannelPathHashModeOverrideModalProps { + open: boolean; + onClose: () => void; + channelName: string; + currentOverride: number | null; + radioDefault: number; + onSetOverride: (value: number | null) => void; +} + +export function ChannelPathHashModeOverrideModal({ + open, + onClose, + channelName, + currentOverride, + radioDefault, + onSetOverride, +}: ChannelPathHashModeOverrideModalProps) { + const [selected, setSelected] = useState(null); + + useEffect(() => { + if (open) { + setSelected(currentOverride); + } + }, [currentOverride, open]); + + const radioDefaultLabel = PATH_HASH_MODE_LABELS[radioDefault] ?? `${radioDefault}`; + + const options: { value: number | null; label: string; description: string }[] = [ + { + value: null, + label: `Radio default (${radioDefaultLabel})`, + description: 'Use the radio-wide path hash mode setting', + }, + { + value: 0, + label: '1-byte hop identifiers', + description: 'Shortest paths, least repeater disambiguation', + }, + { + value: 1, + label: '2-byte hop identifiers', + description: 'Better repeater disambiguation', + }, + { + value: 2, + label: '3-byte hop identifiers', + description: 'Best repeater disambiguation, longest paths', + }, + ]; + + return ( + !isOpen && onClose()}> + + + Path Hop Width Override + + Override the path hash mode for this channel. Wider hop identifiers improve repeater + disambiguation but extend send time and will prevent users on old (<1.14) firmware + from receiving the message. + + + +
+
+
{channelName}
+
+ Current override:{' '} + {currentOverride != null + ? (PATH_HASH_MODE_LABELS[currentOverride] ?? `mode ${currentOverride}`) + : `none (using radio default: ${radioDefaultLabel})`} +
+
+ +
+ +
+ {options.map((opt) => ( + + ))} +
+
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index aa6fba9..008ed3b 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; -import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react'; +import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react'; import { toast } from './ui/sonner'; import { DirectTraceIcon } from './DirectTraceIcon'; import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal'; import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal'; +import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal'; import { isFavorite } from '../utils/favorites'; import { handleKeyboardActivate } from '../utils/a11y'; import { isPublicChannelKey } from '../utils/publicChannel'; @@ -36,6 +37,7 @@ interface ChatHeaderProps { onToggleNotifications: () => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void; + onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void; onDeleteChannel: (key: string) => void; onDeleteContact: (publicKey: string) => void; onOpenContactInfo?: (publicKey: string) => void; @@ -56,6 +58,7 @@ export function ChatHeader({ onToggleNotifications, onToggleFavorite, onSetChannelFloodScopeOverride, + onSetChannelPathHashModeOverride, onDeleteChannel, onDeleteContact, onOpenContactInfo, @@ -64,11 +67,13 @@ export function ChatHeader({ const [showKey, setShowKey] = useState(false); const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false); const [channelOverrideOpen, setChannelOverrideOpen] = useState(false); + const [pathHashModeOverrideOpen, setPathHashModeOverrideOpen] = useState(false); useEffect(() => { setShowKey(false); setPathDiscoveryOpen(false); setChannelOverrideOpen(false); + setPathHashModeOverrideOpen(false); }, [conversation.id]); const activeChannel = @@ -81,6 +86,12 @@ export function ChatHeader({ ? stripRegionScopePrefix(activeFloodScopeOverride) : null; const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null; + const activePathHashModeOverride = + conversation.type === 'channel' ? (activeChannel?.path_hash_mode_override ?? null) : null; + const showPathHashModeOverride = + conversation.type === 'channel' && + onSetChannelPathHashModeOverride && + config?.path_hash_mode_supported; const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag; const activeContact = conversation.type === 'contact' @@ -108,6 +119,11 @@ export function ChatHeader({ setChannelOverrideOpen(true); }; + const handleEditPathHashModeOverride = () => { + if (conversation.type !== 'channel' || !onSetChannelPathHashModeOverride) return; + setPathHashModeOverrideOpen(true); + }; + const handleOpenConversationInfo = () => { if (conversation.type === 'contact' && onOpenContactInfo) { onOpenContactInfo(conversation.id); @@ -323,6 +339,19 @@ export function ChatHeader({ )} )} + {showPathHashModeOverride && ( + + )} {(conversation.type === 'channel' || conversation.type === 'contact') && (