Add repeater telemetry reading

This commit is contained in:
Jack Kingsman
2026-01-09 21:17:55 -08:00
parent 2a6aeebabe
commit e401a32049
12 changed files with 1129 additions and 554 deletions

View File

@@ -180,6 +180,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/radio/decryption-status` | Check if decryption enabled |
| GET | `/api/contacts` | List contacts |
| POST | `/api/contacts/sync` | Pull from radio |
| POST | `/api/contacts/{key}/telemetry` | Request telemetry from repeater |
| GET | `/api/channels` | List channels |
| POST | `/api/channels` | Create channel |
| GET | `/api/messages` | List with filters |

View File

@@ -319,6 +319,7 @@ All endpoints are prefixed with `/api`.
- `POST /api/contacts/sync` - Pull from radio to database
- `POST /api/contacts/{key}/add-to-radio` - Push to radio
- `POST /api/contacts/{key}/remove-from-radio` - Remove from radio
- `POST /api/contacts/{key}/telemetry` - Request telemetry from repeater (see below)
### Channels
- `GET /api/channels` - List from database
@@ -389,3 +390,56 @@ await mc.commands.add_contact(contact_dict)
await mc.commands.set_channel(idx, name, key)
await mc.commands.send_advert(flood=True)
```
## Repeater Telemetry
The `POST /api/contacts/{key}/telemetry` endpoint fetches status, neighbors, and ACL from repeaters (contact type=2).
### Request Flow
1. Verify contact exists and is a repeater (type=2)
2. Sync contacts from radio with `ensure_contacts()`
3. Remove and re-add contact with flood mode (clears stale auth state)
4. Send login with password
5. Request status with retries (3 attempts, 10s timeout)
6. Fetch neighbors with `fetch_all_neighbours()` (handles pagination)
7. Fetch ACL with `req_acl_sync()`
8. Resolve pubkey prefixes to contact names from database
### ACL Permission Levels
```python
ACL_PERMISSION_NAMES = {
0: "Guest",
1: "Read-only",
2: "Read-write",
3: "Admin",
}
```
### Response Models
```python
class NeighborInfo(BaseModel):
pubkey_prefix: str # 4-12 char prefix
name: str | None # Resolved contact name
snr: float # Signal-to-noise ratio in dB
last_heard_seconds: int # Seconds since last heard
class AclEntry(BaseModel):
pubkey_prefix: str # 12 char prefix
name: str | None # Resolved contact name
permission: int # 0-3
permission_name: str # Human-readable name
class TelemetryResponse(BaseModel):
# Status fields
pubkey_prefix: str
battery_volts: float # Converted from mV
uptime_seconds: int
# ... signal quality, packet counts, etc.
# Related data
neighbors: list[NeighborInfo]
acl: list[AclEntry]
```

View File

@@ -122,3 +122,47 @@ class SendDirectMessageRequest(SendMessageRequest):
class SendChannelMessageRequest(SendMessageRequest):
channel_key: str = Field(description="Channel key (32-char hex)")
class TelemetryRequest(BaseModel):
password: str = Field(default="", description="Repeater password (empty string for no password)")
class NeighborInfo(BaseModel):
"""Information about a neighbor seen by a repeater."""
pubkey_prefix: str = Field(description="Public key prefix (4-12 chars)")
name: str | None = Field(default=None, description="Resolved contact name if known")
snr: float = Field(description="Signal-to-noise ratio in dB")
last_heard_seconds: int = Field(description="Seconds since last heard")
class AclEntry(BaseModel):
"""Access control list entry for a repeater."""
pubkey_prefix: str = Field(description="Public key prefix (12 chars)")
name: str | None = Field(default=None, description="Resolved contact name if known")
permission: int = Field(description="Permission level: 0=Guest, 1=Read-only, 2=Read-write, 3=Admin")
permission_name: str = Field(description="Human-readable permission name")
class TelemetryResponse(BaseModel):
"""Telemetry data from a repeater, formatted for human readability."""
pubkey_prefix: str = Field(description="12-char public key prefix")
battery_volts: float = Field(description="Battery voltage in volts")
tx_queue_len: int = Field(description="Transmit queue length")
noise_floor_dbm: int = Field(description="Noise floor in dBm")
last_rssi_dbm: int = Field(description="Last RSSI in dBm")
last_snr_db: float = Field(description="Last SNR in dB")
packets_received: int = Field(description="Total packets received")
packets_sent: int = Field(description="Total packets sent")
airtime_seconds: int = Field(description="TX airtime in seconds")
rx_airtime_seconds: int = Field(description="RX airtime in seconds")
uptime_seconds: int = Field(description="Uptime in seconds")
sent_flood: int = Field(description="Flood packets sent")
sent_direct: int = Field(description="Direct packets sent")
recv_flood: int = Field(description="Flood packets received")
recv_direct: int = Field(description="Direct packets received")
flood_dups: int = Field(description="Duplicate flood packets")
direct_dups: int = Field(description="Duplicate direct packets")
full_events: int = Field(description="Full event queue count")
neighbors: list[NeighborInfo] = Field(default_factory=list, description="List of neighbors seen by repeater")
acl: list[AclEntry] = Field(default_factory=list, description="Access control list")

View File

@@ -4,7 +4,15 @@ from fastapi import APIRouter, HTTPException, Query
from meshcore import EventType
from app.dependencies import require_connected
from app.models import Contact
from app.models import Contact, TelemetryRequest, TelemetryResponse, NeighborInfo, AclEntry, CONTACT_TYPE_REPEATER
# ACL permission level names
ACL_PERMISSION_NAMES = {
0: "Guest",
1: "Read-only",
2: "Read-write",
3: "Admin",
}
from app.radio import radio_manager
from app.repository import ContactRepository
@@ -136,3 +144,182 @@ async def delete_contact(public_key: str) -> dict:
logger.info("Deleted contact %s", contact.public_key[:12])
return {"status": "ok"}
@router.post("/{public_key}/telemetry", response_model=TelemetryResponse)
async def request_telemetry(public_key: str, request: TelemetryRequest) -> TelemetryResponse:
"""Request telemetry from a repeater.
The contact must be a repeater (type=2). If not on the radio, it will be added.
Uses login + status request with retry logic.
"""
mc = require_connected()
# Get contact from database
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
# Verify it's a repeater
if contact.type != CONTACT_TYPE_REPEATER:
raise HTTPException(
status_code=400,
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})"
)
# Sync contacts from radio to ensure our cache is up-to-date
logger.info("Syncing contacts from radio before telemetry request")
await mc.ensure_contacts()
# Remove contact if it exists (clears any stale auth state on radio)
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
logger.info("Removing existing contact %s from radio", contact.public_key[:12])
await mc.commands.remove_contact(contact.public_key)
await mc.commands.get_contacts()
# Add contact fresh with flood mode (matching test_telemetry.py pattern)
logger.info("Adding repeater %s to radio with flood mode", contact.public_key[:12])
contact_data = {
"public_key": contact.public_key,
"adv_name": contact.name or "",
"type": contact.type,
"flags": contact.flags,
"out_path": "",
"out_path_len": -1, # Flood mode
"adv_lat": contact.lat or 0.0,
"adv_lon": contact.lon or 0.0,
"last_advert": contact.last_advert or 0,
}
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to add contact to radio: {add_result.payload}"
)
# Refresh and verify
await mc.commands.get_contacts()
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if not radio_contact:
raise HTTPException(
status_code=500,
detail="Failed to add contact to radio - contact not found after add"
)
# Send login with password
password = request.password
logger.info("Sending login to repeater %s", contact.public_key[:12])
login_result = await mc.commands.send_login(contact.public_key, password)
if login_result.type == EventType.ERROR:
raise HTTPException(
status_code=401,
detail=f"Login failed: {login_result.payload}"
)
# Request status with retries
logger.info("Requesting status from repeater %s", contact.public_key[:12])
status = None
for attempt in range(1, 4):
logger.debug("Status request attempt %d/3", attempt)
status = await mc.commands.req_status_sync(
contact.public_key,
timeout=10.0,
min_timeout=5.0
)
if status:
break
logger.debug("Status request timeout, retrying...")
if not status:
raise HTTPException(
status_code=504,
detail="No response from repeater after 3 attempts"
)
logger.info("Received telemetry from %s: %s", contact.public_key[:12], status)
# Fetch neighbors (fetch_all_neighbours handles pagination)
logger.info("Fetching neighbors from repeater %s", contact.public_key[:12])
neighbors_data = None
for attempt in range(1, 4):
logger.debug("Neighbors request attempt %d/3", attempt)
neighbors_data = await mc.commands.fetch_all_neighbours(
contact.public_key,
timeout=10.0,
min_timeout=5.0
)
if neighbors_data:
break
logger.debug("Neighbors request timeout, retrying...")
# Process neighbors - resolve pubkey prefixes to contact names
neighbors: list[NeighborInfo] = []
if neighbors_data and "neighbours" in neighbors_data:
logger.info("Received %d neighbors", len(neighbors_data["neighbours"]))
for n in neighbors_data["neighbours"]:
pubkey_prefix = n.get("pubkey", "")
# Try to resolve to a contact name from our database
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
neighbors.append(NeighborInfo(
pubkey_prefix=pubkey_prefix,
name=resolved_contact.name if resolved_contact else None,
snr=n.get("snr", 0.0),
last_heard_seconds=n.get("secs_ago", 0),
))
# Fetch ACL
logger.info("Fetching ACL from repeater %s", contact.public_key[:12])
acl_data = None
for attempt in range(1, 4):
logger.debug("ACL request attempt %d/3", attempt)
acl_data = await mc.commands.req_acl_sync(
contact.public_key,
timeout=10.0,
min_timeout=5.0
)
if acl_data:
break
logger.debug("ACL request timeout, retrying...")
# Process ACL - resolve pubkey prefixes to contact names
acl_entries: list[AclEntry] = []
if acl_data and isinstance(acl_data, list):
logger.info("Received %d ACL entries", len(acl_data))
for entry in acl_data:
pubkey_prefix = entry.get("key", "")
perm = entry.get("perm", 0)
# Try to resolve to a contact name from our database
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
acl_entries.append(AclEntry(
pubkey_prefix=pubkey_prefix,
name=resolved_contact.name if resolved_contact else None,
permission=perm,
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
))
# Convert raw telemetry to response format
# bat is in mV, convert to V (e.g., 3775 -> 3.775)
return TelemetryResponse(
pubkey_prefix=status.get("pubkey_pre", contact.public_key[:12]),
battery_volts=status.get("bat", 0) / 1000.0,
tx_queue_len=status.get("tx_queue_len", 0),
noise_floor_dbm=status.get("noise_floor", 0),
last_rssi_dbm=status.get("last_rssi", 0),
last_snr_db=status.get("last_snr", 0.0),
packets_received=status.get("nb_recv", 0),
packets_sent=status.get("nb_sent", 0),
airtime_seconds=status.get("airtime", 0),
rx_airtime_seconds=status.get("rx_airtime", 0),
uptime_seconds=status.get("uptime", 0),
sent_flood=status.get("sent_flood", 0),
sent_direct=status.get("sent_direct", 0),
recv_flood=status.get("recv_flood", 0),
recv_direct=status.get("recv_direct", 0),
flood_dups=status.get("flood_dups", 0),
direct_dups=status.get("direct_dups", 0),
full_events=status.get("full_evts", 0),
neighbors=neighbors,
acl=acl_entries,
)

View File

@@ -141,6 +141,9 @@ await api.decryptHistoricalPackets({ key_type: 'channel', channel_name: '#test'
// Radio reconnection
await api.reconnectRadio(); // Returns { status, message, connected }
// Repeater telemetry
await api.requestTelemetry(publicKey, password); // Returns TelemetryResponse
```
### API Proxy (Development)
@@ -206,6 +209,29 @@ interface Conversation {
interface AppSettings {
max_radio_contacts: number;
}
// Repeater telemetry types
interface NeighborInfo {
pubkey_prefix: string;
name: string | null;
snr: number;
last_heard_seconds: number;
}
interface AclEntry {
pubkey_prefix: string;
name: string | null;
permission: number;
permission_name: string;
}
interface TelemetryResponse {
battery_volts: number;
uptime_seconds: number;
// ... status fields
neighbors: NeighborInfo[];
acl: AclEntry[];
}
```
## Component Patterns
@@ -220,7 +246,7 @@ export interface MessageInputHandle {
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
function MessageInput({ onSend, disabled }, ref) {
function MessageInput({ onSend, disabled, isRepeaterMode }, ref) {
useImperativeHandle(ref, () => ({
appendText: (text: string) => {
setText((prev) => prev + text);
@@ -236,6 +262,27 @@ const messageInputRef = useRef<MessageInputHandle>(null);
messageInputRef.current?.appendText(`@[${sender}] `);
```
### Repeater Mode
When selecting a repeater contact (type=2), MessageInput switches to password mode:
- Input type changes to `password`
- Button shows "Fetch" instead of "Send"
- Submitting requests telemetry instead of sending a message
- Enter "." for empty password
```typescript
<MessageInput
onSend={activeContactIsRepeater ? handleTelemetryRequest : handleSendMessage}
isRepeaterMode={activeContactIsRepeater}
/>
```
Telemetry response is displayed as three local messages (not persisted):
1. **[Telemetry]** - Battery voltage, uptime, signal quality, packet stats
2. **[Neighbors]** - Sorted by SNR (highest first), with resolved names
3. **[ACL]** - Access control list with permission levels
### Unread Count Tracking
Uses refs to avoid stale closures in memoized handlers:

File diff suppressed because one or more lines are too long

537
frontend/dist/assets/index-DJfUQN-2.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RemoteTerm for MeshCore</title>
<script type="module" crossorigin src="/assets/index-BHdy0kQg.js"></script>
<script type="module" crossorigin src="/assets/index-DJfUQN-2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CtV9BARe.css">
</head>
<body>

View File

@@ -22,6 +22,7 @@ import {
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
import { cn } from '@/lib/utils';
import type {
AclEntry,
AppSettings,
AppSettingsUpdate,
Contact,
@@ -29,13 +30,90 @@ import type {
Conversation,
HealthStatus,
Message,
NeighborInfo,
RawPacket,
RadioConfig,
RadioConfigUpdate,
TelemetryResponse,
} from './types';
import { CONTACT_TYPE_REPEATER } from './types';
const MAX_RAW_PACKETS = 500; // Limit stored packets to prevent memory issues
// Format seconds into human-readable duration (e.g., 1d17h2m, 1h5m, 3m)
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) {
if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`;
if (hours > 0) return `${days}d${hours}h`;
if (mins > 0) return `${days}d${mins}m`;
return `${days}d`;
}
if (hours > 0) {
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
}
return `${mins}m`;
}
// Format telemetry response as human-readable text
// Note: Avoid "Word: " pattern at line start - it triggers sender extraction in MessageList
function formatTelemetry(telemetry: TelemetryResponse): string {
const lines = [
`[Telemetry]`,
`Battery Voltage: ${telemetry.battery_volts.toFixed(3)}V`,
`Uptime: ${formatDuration(telemetry.uptime_seconds)}`,
`TX Airtime: ${formatDuration(telemetry.airtime_seconds)}`,
`RX Airtime: ${formatDuration(telemetry.rx_airtime_seconds)}`,
'',
`Noise Floor: ${telemetry.noise_floor_dbm} dBm`,
`Last RSSI: ${telemetry.last_rssi_dbm} dBm`,
`Last SNR: ${telemetry.last_snr_db.toFixed(1)} dB`,
'',
`Packets: ${telemetry.packets_received.toLocaleString()} rx / ${telemetry.packets_sent.toLocaleString()} tx`,
`Flood: ${telemetry.recv_flood.toLocaleString()} rx / ${telemetry.sent_flood.toLocaleString()} tx`,
`Direct: ${telemetry.recv_direct.toLocaleString()} rx / ${telemetry.sent_direct.toLocaleString()} tx`,
`Duplicates: ${telemetry.flood_dups.toLocaleString()} flood / ${telemetry.direct_dups.toLocaleString()} direct`,
'',
`TX Queue: ${telemetry.tx_queue_len}`,
`Debug Flags: ${telemetry.full_events}`,
];
return lines.join('\n');
}
// Format neighbors list as human-readable text
function formatNeighbors(neighbors: NeighborInfo[]): string {
if (neighbors.length === 0) {
return '[Neighbors]\nNo neighbors reported';
}
// Sort by SNR descending (highest first)
const sorted = [...neighbors].sort((a, b) => b.snr - a.snr);
const lines = [`[Neighbors] (${sorted.length})`];
for (const n of sorted) {
const name = n.name || n.pubkey_prefix;
const snr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
lines.push(`${name}, ${snr} dB [${formatDuration(n.last_heard_seconds)} ago]`);
}
return lines.join('\n');
}
// Format ACL list as human-readable text
function formatAcl(acl: AclEntry[]): string {
if (acl.length === 0) {
return '[ACL]\nNo ACL entries';
}
const lines = [`[ACL] (${acl.length})`];
for (const entry of acl) {
const name = entry.name || entry.pubkey_prefix;
lines.push(`${name}: ${entry.permission_name}`);
}
return lines.join('\n');
}
// Generate a key for deduplicating messages by content
function getMessageContentKey(msg: Message): string {
return `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
@@ -512,6 +590,13 @@ export function App() {
fetchMessages(true);
}, [fetchMessages]);
// Check if active conversation is a repeater
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
const contact = contacts.find(c => c.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
// Send message handler
const handleSendMessage = useCallback(
async (text: string) => {
@@ -528,6 +613,84 @@ export function App() {
[activeConversation, fetchMessages]
);
// Request telemetry from a repeater
const handleTelemetryRequest = useCallback(
async (password: string) => {
if (!activeConversation || activeConversation.type !== 'contact') return;
if (!activeContactIsRepeater) return;
try {
const telemetry = await api.requestTelemetry(activeConversation.id, password);
const now = Math.floor(Date.now() / 1000);
// Create a local message to display the telemetry (not persisted to database)
const telemetryMessage: Message = {
id: -Date.now(), // Negative ID to avoid collision with real messages
type: 'PRIV',
conversation_key: activeConversation.id,
text: formatTelemetry(telemetry),
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false, // Show as incoming (from the repeater)
acked: true, // Mark as acked since it's a response
};
// Create a second message for neighbors
const neighborsMessage: Message = {
id: -Date.now() - 1, // Different ID
type: 'PRIV',
conversation_key: activeConversation.id,
text: formatNeighbors(telemetry.neighbors),
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: true,
};
// Create a third message for ACL
const aclMessage: Message = {
id: -Date.now() - 2, // Different ID
type: 'PRIV',
conversation_key: activeConversation.id,
text: formatAcl(telemetry.acl),
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: true,
};
// Add all messages to the list
setMessages((prev) => [...prev, telemetryMessage, neighborsMessage, aclMessage]);
} catch (err) {
// Show error as a local message
const errorMessage: Message = {
id: -Date.now(),
type: 'PRIV',
conversation_key: activeConversation.id,
text: `Telemetry request failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
sender_timestamp: Math.floor(Date.now() / 1000),
received_at: Math.floor(Date.now() / 1000),
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: true,
};
setMessages((prev) => [...prev, errorMessage]);
}
},
[activeConversation, activeContactIsRepeater]
);
// Config save handler
const handleSaveConfig = useCallback(async (update: RadioConfigUpdate) => {
await api.updateRadioConfig(update);
@@ -820,12 +983,15 @@ export function App() {
/>
<MessageInput
ref={messageInputRef}
onSend={handleSendMessage}
onSend={activeContactIsRepeater ? handleTelemetryRequest : handleSendMessage}
disabled={!health?.radio_connected}
isRepeaterMode={activeContactIsRepeater}
placeholder={
health?.radio_connected
? `Message ${activeConversation.name}...`
: 'Radio not connected'
!health?.radio_connected
? 'Radio not connected'
: activeContactIsRepeater
? `Enter password for ${activeConversation.name} (or . for none)...`
: `Message ${activeConversation.name}...`
}
/>
</>

View File

@@ -7,6 +7,7 @@ import type {
Message,
RadioConfig,
RadioConfigUpdate,
TelemetryResponse,
} from './types';
const API_BASE = '/api';
@@ -80,6 +81,11 @@ export const api = {
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',
}),
requestTelemetry: (publicKey: string, password: string) =>
fetchJson<TelemetryResponse>(`/contacts/${publicKey}/telemetry`, {
method: 'POST',
body: JSON.stringify({ password }),
}),
// Channels
getChannels: () => fetchJson<Channel[]>('/channels'),

View File

@@ -6,6 +6,8 @@ interface MessageInputProps {
onSend: (text: string) => Promise<void>;
disabled: boolean;
placeholder?: string;
/** When true, input becomes password field for repeater telemetry */
isRepeaterMode?: boolean;
}
export interface MessageInputHandle {
@@ -13,7 +15,7 @@ export interface MessageInputHandle {
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
function MessageInput({ onSend, disabled, placeholder }, ref) {
function MessageInput({ onSend, disabled, placeholder, isRepeaterMode }, ref) {
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -30,19 +32,35 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
async (e: FormEvent) => {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed || sending || disabled) return;
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setSending(false);
// For repeater mode, allow empty password via "."
if (isRepeaterMode) {
if (sending || disabled) return;
// "." means empty password
const password = trimmed === '.' ? '' : trimmed;
setSending(true);
try {
await onSend(password);
setText('');
} catch (err) {
console.error('Failed to request telemetry:', err);
} finally {
setSending(false);
}
} else {
if (!trimmed || sending || disabled) return;
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setSending(false);
}
}
},
[text, sending, disabled, onSend]
[text, sending, disabled, onSend, isRepeaterMode]
);
const handleKeyDown = useCallback(
@@ -55,20 +73,27 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
[handleSubmit]
);
// For repeater mode, enable submit if there's text OR if it's just "." for empty password
const canSubmit = isRepeaterMode
? text.trim().length > 0 || text === '.'
: text.trim().length > 0;
return (
<form className="px-4 py-3 border-t border-border flex gap-2" onSubmit={handleSubmit}>
<Input
ref={inputRef}
type="text"
type={isRepeaterMode ? 'password' : 'text'}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder || 'Type a message...'}
placeholder={placeholder || (isRepeaterMode ? 'Enter password (or . for none)...' : 'Type a message...')}
disabled={disabled || sending}
className="flex-1"
/>
<Button type="submit" disabled={disabled || sending || !text.trim()}>
{sending ? 'Sending...' : 'Send'}
<Button type="submit" disabled={disabled || sending || !canSubmit}>
{sending
? (isRepeaterMode ? 'Fetching...' : 'Sending...')
: (isRepeaterMode ? 'Fetch' : 'Send')}
</Button>
</form>
);

View File

@@ -109,3 +109,43 @@ export interface AppSettings {
export interface AppSettingsUpdate {
max_radio_contacts?: number;
}
/** Contact type constant for repeaters */
export const CONTACT_TYPE_REPEATER = 2;
export interface NeighborInfo {
pubkey_prefix: string;
name: string | null;
snr: number;
last_heard_seconds: number;
}
export interface AclEntry {
pubkey_prefix: string;
name: string | null;
permission: number;
permission_name: string;
}
export interface TelemetryResponse {
pubkey_prefix: string;
battery_volts: number;
tx_queue_len: number;
noise_floor_dbm: number;
last_rssi_dbm: number;
last_snr_db: number;
packets_received: number;
packets_sent: number;
airtime_seconds: number;
rx_airtime_seconds: number;
uptime_seconds: number;
sent_flood: number;
sent_direct: number;
recv_flood: number;
recv_direct: number;
flood_dups: number;
direct_dups: number;
full_events: number;
neighbors: NeighborInfo[];
acl: AclEntry[];
}