mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-03 20:13:00 +02:00
Add repeater telemetry reading
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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]
|
||||
```
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
532
frontend/dist/assets/index-BHdy0kQg.js
vendored
532
frontend/dist/assets/index-BHdy0kQg.js
vendored
File diff suppressed because one or more lines are too long
537
frontend/dist/assets/index-DJfUQN-2.js
vendored
Normal file
537
frontend/dist/assets/index-DJfUQN-2.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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}...`
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user