import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense, type ReactNode, } from 'react'; import { ChevronDown, Info } from 'lucide-react'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { Button } from '../ui/button'; import { Separator } from '../ui/separator'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '../ui/dialog'; import { toast } from '../ui/sonner'; import { cn } from '@/lib/utils'; import { api } from '../../api'; import type { Channel, Contact, FanoutConfig, HealthStatus } from '../../types'; const BotCodeEditor = lazy(() => import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor })) ); const TYPE_LABELS: Record = { mqtt_private: 'Private MQTT', mqtt_community: 'Community Sharing', mqtt_ha: 'Home Assistant', bot: 'Python Bot', webhook: 'Webhook', apprise: 'Apprise', sqs: 'Amazon SQS', map_upload: 'Map Upload', }; const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets'; const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net'; const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net'; const DEFAULT_COMMUNITY_BROKER_PORT = 443; const DEFAULT_COMMUNITY_TRANSPORT = 'websockets'; const DEFAULT_COMMUNITY_AUTH_MODE = 'token'; const DEFAULT_MESHRANK_BROKER_HOST = 'meshrank.net'; const DEFAULT_MESHRANK_BROKER_PORT = 8883; const DEFAULT_MESHRANK_TRANSPORT = 'tcp'; const DEFAULT_MESHRANK_AUTH_MODE = 'none'; const DEFAULT_MESHRANK_IATA = 'XYZ'; function createCommunityConfigDefaults( overrides: Partial> = {} ): Record { return { broker_host: DEFAULT_COMMUNITY_BROKER_HOST, broker_port: DEFAULT_COMMUNITY_BROKER_PORT, transport: DEFAULT_COMMUNITY_TRANSPORT, use_tls: true, tls_verify: true, auth_mode: DEFAULT_COMMUNITY_AUTH_MODE, username: '', password: '', iata: '', email: '', token_audience: '', topic_template: DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE, ...overrides, }; } const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None: """ Process messages and optionally return a reply. Args: kwargs keys currently provided: sender_name: Display name of sender (may be None) sender_key: 64-char hex public key (None for channel msgs) message_text: The message content is_dm: True for direct messages, False for channel channel_key: 32-char hex key for channels, None for DMs channel_name: Channel name with hash (e.g. "#bot"), None for DMs sender_timestamp: Sender's timestamp (unix seconds, may be None) path: Hex-encoded routing path (may be None) is_outgoing: True if this is our own outgoing message path_bytes_per_hop: Bytes per hop in path (1, 2, or 3) when known Returns: None for no reply, a string for a single reply, or a list of strings to send multiple messages in order """ sender_name = kwargs.get("sender_name") message_text = kwargs.get("message_text", "") channel_name = kwargs.get("channel_name") is_outgoing = kwargs.get("is_outgoing", False) path_bytes_per_hop = kwargs.get("path_bytes_per_hop") # Don't reply to our own outgoing messages if is_outgoing: return None # Example: Only respond in #bot channel to "!pling" command if channel_name == "#bot" and "!pling" in message_text.lower(): return "[BOT] Plong!" return None`; type DraftType = | 'mqtt_private' | 'mqtt_ha' | 'mqtt_community' | 'mqtt_community_meshrank' | 'mqtt_community_letsmesh_us' | 'mqtt_community_letsmesh_eu' | 'webhook' | 'apprise' | 'sqs' | 'bot' | 'map_upload'; type CreateIntegrationDefinition = { value: DraftType; savedType: string; label: string; section: string; description: string; defaultName: string; nameMode: 'counted' | 'fixed'; defaults: { config: Record; scope: Record; }; }; const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [ { value: 'mqtt_private', savedType: 'mqtt_private', label: 'Private MQTT', section: 'Private Forwarding', description: 'Customizable-scope forwarding of all or some messages to an MQTT broker of your choosing, in raw and/or decrypted form.', defaultName: 'Private MQTT', nameMode: 'counted', defaults: { config: { broker_host: '', broker_port: 1883, username: '', password: '', use_tls: false, tls_insecure: false, topic_prefix: 'meshcore', }, scope: { messages: 'all', raw_packets: 'all' }, }, }, { value: 'mqtt_ha', savedType: 'mqtt_ha', label: 'Home Assistant MQTT Discovery', section: 'Private Forwarding', description: "Publishes MQTT Discovery payloads so mesh devices appear natively in Home Assistant. Requires HA's built-in MQTT integration connected to the same broker. Select specific contacts for GPS tracking and repeaters for telemetry sensors.", defaultName: 'Home Assistant', nameMode: 'fixed', defaults: { config: { broker_host: '', broker_port: 1883, username: '', password: '', use_tls: false, tls_insecure: false, topic_prefix: 'meshcore', tracked_contacts: [], tracked_repeaters: [], }, scope: { messages: 'all', raw_packets: 'none' }, }, }, { value: 'mqtt_community', savedType: 'mqtt_community', label: 'Community MQTT/meshcoretomqtt', section: 'Community Sharing', description: 'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.', defaultName: 'Community MQTT', nameMode: 'counted', defaults: { config: createCommunityConfigDefaults(), scope: { messages: 'none', raw_packets: 'all' }, }, }, { value: 'mqtt_community_meshrank', savedType: 'mqtt_community', label: 'MeshRank', section: 'Community Sharing', description: 'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.', defaultName: 'MeshRank', nameMode: 'fixed', defaults: { config: createCommunityConfigDefaults({ broker_host: DEFAULT_MESHRANK_BROKER_HOST, broker_port: DEFAULT_MESHRANK_BROKER_PORT, transport: DEFAULT_MESHRANK_TRANSPORT, auth_mode: DEFAULT_MESHRANK_AUTH_MODE, iata: DEFAULT_MESHRANK_IATA, email: '', token_audience: '', topic_template: '', }), scope: { messages: 'none', raw_packets: 'all' }, }, }, { value: 'mqtt_community_letsmesh_us', savedType: 'mqtt_community', label: 'LetsMesh (US)', section: 'Community Sharing', description: 'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.', defaultName: 'LetsMesh (US)', nameMode: 'fixed', defaults: { config: createCommunityConfigDefaults({ broker_host: DEFAULT_COMMUNITY_BROKER_HOST, token_audience: DEFAULT_COMMUNITY_BROKER_HOST, }), scope: { messages: 'none', raw_packets: 'all' }, }, }, { value: 'mqtt_community_letsmesh_eu', savedType: 'mqtt_community', label: 'LetsMesh (EU)', section: 'Community Sharing', description: 'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.', defaultName: 'LetsMesh (EU)', nameMode: 'fixed', defaults: { config: createCommunityConfigDefaults({ broker_host: DEFAULT_COMMUNITY_BROKER_HOST_EU, token_audience: DEFAULT_COMMUNITY_BROKER_HOST_EU, }), scope: { messages: 'none', raw_packets: 'all' }, }, }, { value: 'webhook', savedType: 'webhook', label: 'Webhook', section: 'Automation', description: 'Generic webhook for decrypted channel/DM messages with customizable verb, method, and optional HMAC signature.', defaultName: 'Webhook', nameMode: 'counted', defaults: { config: { url: '', method: 'POST', headers: {}, hmac_secret: '', hmac_header: '', }, scope: { messages: 'all', raw_packets: 'none' }, }, }, { value: 'apprise', savedType: 'apprise', label: 'Apprise', section: 'Automation', description: 'A wide-ranging generic fanout, capable of forwarding decrypted channel/DM messages to Discord, Telegram, email, SMS, and many others.', defaultName: 'Apprise', nameMode: 'counted', defaults: { config: { urls: '', preserve_identity: true, include_outgoing: false, markdown_format: true, body_format_dm: '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]', body_format_channel: '**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]', }, scope: { messages: 'all', raw_packets: 'none' }, }, }, { value: 'sqs', savedType: 'sqs', label: 'Amazon SQS', section: 'Private Forwarding', description: 'Send full or scope-customized raw or decrypted packets to an SQS', defaultName: 'Amazon SQS', nameMode: 'counted', defaults: { config: { queue_url: '', region_name: '', endpoint_url: '', access_key_id: '', secret_access_key: '', session_token: '', }, scope: { messages: 'all', raw_packets: 'none' }, }, }, { value: 'bot', savedType: 'bot', label: 'Python Bot', section: 'Automation', description: 'A simple, Python-based interface for basic bots that can respond to DM and channel messages.', defaultName: 'Bot', nameMode: 'counted', defaults: { config: { code: DEFAULT_BOT_CODE, }, scope: { messages: 'all', raw_packets: 'none' }, }, }, { value: 'map_upload', savedType: 'map_upload', label: 'Map Upload', section: 'Community Sharing', description: 'Upload repeaters and room servers to map.meshcore.io or a compatible map API endpoint.', defaultName: 'Map Upload', nameMode: 'counted', defaults: { config: { api_url: '', dry_run: true, }, scope: { messages: 'none', raw_packets: 'all' }, }, }, ]; const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries( CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition]) ) as Record; function getNumberInputValue(value: unknown, fallback: number): string | number { if (value === '') return ''; if (typeof value === 'string') return value; if (typeof value === 'number' && Number.isFinite(value)) return value; return fallback; } function getOptionalNumberInputValue(value: unknown): string | number { if (value === '') return ''; if (typeof value === 'string') return value; if (typeof value === 'number' && Number.isFinite(value)) return value; return ''; } function parseIntegerInputValue(value: string): number | string { if (value === '') return ''; const parsed = Number.parseInt(value, 10); return Number.isNaN(parsed) ? value : parsed; } function parseFloatInputValue(value: string): number | string { if (value === '') return ''; const parsed = Number.parseFloat(value); return Number.isNaN(parsed) ? value : parsed; } function normalizeIntegrationConfigForSave( configType: string, config: Record ): Record { const normalized = { ...config }; if (configType === 'mqtt_private') { const port = normalized.broker_port; if (port === '' || port === undefined || port === null) { normalized.broker_port = 1883; } else if (typeof port === 'string') { const parsed = Number.parseInt(port, 10); normalized.broker_port = Number.isNaN(parsed) ? 1883 : parsed; } const topicPrefix = String(normalized.topic_prefix ?? '').trim(); normalized.topic_prefix = topicPrefix || 'meshcore'; } if (configType === 'mqtt_community') { const brokerHost = String(normalized.broker_host ?? '').trim(); normalized.broker_host = brokerHost || DEFAULT_COMMUNITY_BROKER_HOST; const port = normalized.broker_port; if (port === '' || port === undefined || port === null) { normalized.broker_port = DEFAULT_COMMUNITY_BROKER_PORT; } else if (typeof port === 'string') { const parsed = Number.parseInt(port, 10); normalized.broker_port = Number.isNaN(parsed) ? DEFAULT_COMMUNITY_BROKER_PORT : parsed; } const topicTemplate = String(normalized.topic_template ?? '').trim(); normalized.topic_template = topicTemplate || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE; } if (configType === 'map_upload') { const radius = normalized.geofence_radius_km; if (radius === '' || radius === undefined || radius === null) { normalized.geofence_radius_km = 0; } else if (typeof radius === 'string') { const parsed = Number.parseFloat(radius); normalized.geofence_radius_km = Number.isNaN(parsed) ? 0 : parsed; } } return normalized; } function isDraftType(value: string): value is DraftType { return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE; } function getCreateIntegrationDefinition(draftType: DraftType) { return CREATE_INTEGRATION_DEFINITIONS_BY_VALUE[draftType]; } function normalizeDraftName(draftType: DraftType, name: string, configs: FanoutConfig[]) { const definition = getCreateIntegrationDefinition(draftType); if (name) return name; if (definition.nameMode === 'fixed') return definition.defaultName; return getDefaultIntegrationName(definition.savedType, configs); } function normalizeDraftConfig(draftType: DraftType, config: Record) { if (draftType === 'mqtt_community_meshrank') { const topicTemplate = String(config.topic_template || '').trim(); if (!topicTemplate) { throw new Error('MeshRank packet topic is required'); } return normalizeIntegrationConfigForSave('mqtt_community', { ...config, broker_host: DEFAULT_MESHRANK_BROKER_HOST, broker_port: DEFAULT_MESHRANK_BROKER_PORT, transport: DEFAULT_MESHRANK_TRANSPORT, auth_mode: DEFAULT_MESHRANK_AUTH_MODE, use_tls: true, tls_verify: true, iata: DEFAULT_MESHRANK_IATA, email: '', token_audience: '', topic_template: topicTemplate, username: '', password: '', }); } if (draftType === 'mqtt_community_letsmesh_us' || draftType === 'mqtt_community_letsmesh_eu') { const brokerHost = draftType === 'mqtt_community_letsmesh_eu' ? DEFAULT_COMMUNITY_BROKER_HOST_EU : DEFAULT_COMMUNITY_BROKER_HOST; return normalizeIntegrationConfigForSave('mqtt_community', { ...config, broker_host: brokerHost, broker_port: DEFAULT_COMMUNITY_BROKER_PORT, transport: DEFAULT_COMMUNITY_TRANSPORT, auth_mode: DEFAULT_COMMUNITY_AUTH_MODE, use_tls: true, tls_verify: true, token_audience: brokerHost, topic_template: (config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE, username: '', password: '', }); } return normalizeIntegrationConfigForSave( getCreateIntegrationDefinition(draftType).savedType, config ); } function normalizeDraftScope(draftType: DraftType, scope: Record) { if (getCreateIntegrationDefinition(draftType).savedType === 'mqtt_community') { return { messages: 'none', raw_packets: 'all' }; } return scope; } function cloneDraftDefaults(draftType: DraftType) { const recipe = getCreateIntegrationDefinition(draftType); return { config: structuredClone(recipe.defaults.config), scope: structuredClone(recipe.defaults.scope), }; } function CreateIntegrationDialog({ open, options, selectedType, onOpenChange, onSelect, onCreate, }: { open: boolean; options: readonly CreateIntegrationDefinition[]; selectedType: DraftType | null; onOpenChange: (open: boolean) => void; onSelect: (type: DraftType) => void; onCreate: () => void; }) { const selectedOption = options.find((option) => option.value === selectedType) ?? options[0] ?? null; const listRef = useRef(null); const [showScrollHint, setShowScrollHint] = useState(false); const updateScrollHint = useCallback(() => { const container = listRef.current; if (!container) { setShowScrollHint(false); return; } setShowScrollHint(container.scrollTop + container.clientHeight < container.scrollHeight - 8); }, []); useEffect(() => { if (!open) return; const frame = window.requestAnimationFrame(updateScrollHint); window.addEventListener('resize', updateScrollHint); return () => { window.cancelAnimationFrame(frame); window.removeEventListener('resize', updateScrollHint); }; }, [open, options, updateScrollHint]); const sectionedOptions = [...new Set(options.map((o) => o.section))] .map((section) => ({ section, options: options.filter((option) => option.section === section), })) .filter((group) => group.options.length > 0); return ( Create Integration
{sectionedOptions.map((group) => (
{group.section}
{group.options.map((option) => { const selected = option.value === selectedOption?.value; return ( ); })}
))}
{showScrollHint && (
)}
{selectedOption ? ( <>
{selectedOption.section}

{selectedOption.label}

{selectedOption.description}

) : (
No integration types are currently available.
)}
); } function getDetailTypeLabel(detailType: string) { if (isDraftType(detailType)) return getCreateIntegrationDefinition(detailType).label; return TYPE_LABELS[detailType] || detailType; } function fanoutDraftHasUnsavedChanges( original: FanoutConfig | null, current: { name: string; config: Record; scope: Record; } ) { if (!original) return false; return ( current.name !== original.name || JSON.stringify(current.config) !== JSON.stringify(original.config) || JSON.stringify(current.scope) !== JSON.stringify(original.scope) ); } function formatBrokerSummary( config: Record, defaults: { host: string; port: number } ) { const host = (config.broker_host as string) || defaults.host; const port = typeof config.broker_port === 'number' ? config.broker_port : defaults.port; return `${host}:${port}`; } function formatPrivateTopicSummary(config: Record) { const prefix = (config.topic_prefix as string) || 'meshcore'; return `${prefix}/dm:, ${prefix}/gm:, ${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) { const targets = (urls || '') .split('\n') .map((line) => line.trim()) .filter(Boolean); if (targets.length === 0) return 'No targets configured'; return targets.map(censorAppriseUrl).join(', '); } function formatSqsQueueSummary(config: Record) { const queueUrl = ((config.queue_url as string) || '').trim(); if (!queueUrl) return 'No queue configured'; return queueUrl; } function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) { const label = TYPE_LABELS[type] || type; const nextIndex = configs.filter((cfg) => cfg.type === type).length + 1; return `${label} #${nextIndex}`; } function getStatusLabel(status: string | undefined, type?: string) { if (status === 'connected') return type === 'bot' || type === 'webhook' || type === 'apprise' || type === 'map_upload' ? 'Active' : 'Connected'; if (status === 'error') return 'Error'; if (status === 'disconnected') return 'Disconnected'; return 'Inactive'; } function getStatusColor(status: string | undefined, enabled?: boolean) { if (enabled === false) return 'bg-muted-foreground'; if (status === 'connected') return 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]'; if (status === 'error' || status === 'disconnected') return 'bg-destructive shadow-[0_0_6px_hsl(var(--destructive)/0.5)]'; return 'bg-muted-foreground'; } function MqttPrivateConfigEditor({ config, scope, onChange, onScopeChange, }: { config: Record; scope: Record; onChange: (config: Record) => void; onScopeChange: (scope: Record) => void; }) { return (

Forward mesh data to your own MQTT broker for home automation, logging, or alerting.

Outgoing messages (DMs and group messages) will be reported to private MQTT brokers in decrypted/plaintext form.
onChange({ ...config, broker_host: e.target.value })} />
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) }) } />
onChange({ ...config, username: e.target.value })} />
onChange({ ...config, password: e.target.value })} />
{!!config.use_tls && ( )}
onChange({ ...config, topic_prefix: e.target.value })} />
); } function MqttHaConfigEditor({ config, scope, onChange, onScopeChange, }: { config: Record; scope: Record; onChange: (config: Record) => void; onScopeChange: (scope: Record) => void; }) { const [contacts, setContacts] = useState([]); const [trackedRepeaters, setTrackedRepeaters] = useState([]); const [contactSearch, setContactSearch] = useState(''); const [radioConfig, setRadioConfig] = useState<{ public_key: string; name: string } | null>(null); useEffect(() => { (async () => { const all: Contact[] = []; const pageSize = 1000; let offset = 0; while (true) { const page = await api.getContacts(pageSize, offset); all.push(...page); if (page.length < pageSize) break; offset += pageSize; } setContacts(all); })().catch(console.error); api .getRadioConfig() .then((radio) => setRadioConfig({ public_key: radio.public_key, name: radio.name })) .catch(console.error); api .getSettings() .then((s) => setTrackedRepeaters(s.tracked_telemetry_repeaters ?? [])) .catch(console.error); }, []); const selectedContacts = (config.tracked_contacts as string[]) || []; const selectedRepeaters = (config.tracked_repeaters as string[]) || []; const contactOptions = useMemo( () => contacts.filter((c) => c.type === 0 || c.type === 1 || c.type === 3), [contacts] ); const repeaterOptions = useMemo( () => contacts.filter((c) => c.type === 2 && trackedRepeaters.includes(c.public_key)), [contacts, trackedRepeaters] ); const contactSearchLower = contactSearch.toLowerCase().trim(); const filteredContacts = useMemo(() => { const matches = contactOptions.filter((c) => { if (!contactSearchLower) return true; const name = (c.name || '').toLowerCase(); const key = c.public_key.toLowerCase(); return name.includes(contactSearchLower) || key.startsWith(contactSearchLower); }); // Selected contacts sort to top return matches.sort((a, b) => { const aSelected = selectedContacts.includes(a.public_key) ? 0 : 1; const bSelected = selectedContacts.includes(b.public_key) ? 0 : 1; if (aSelected !== bSelected) return aSelected - bSelected; return (a.name || a.public_key).localeCompare(b.name || b.public_key); }); }, [contactOptions, contactSearchLower, selectedContacts]); const selectedContactDetails = contactOptions.filter((c) => selectedContacts.includes(c.public_key) ); const selectedRepeaterDetails = repeaterOptions.filter((c) => selectedRepeaters.includes(c.public_key) ); const prefix = ((config.topic_prefix as string) || 'meshcore').trim() || 'meshcore'; const nodeIdForKey = useCallback((publicKey: string) => publicKey.slice(0, 12).toLowerCase(), []); const topicSummary = useMemo(() => { const items: Array<{ kind: 'radio' | 'event' | 'repeater' | 'contact'; label: string; publicKey: string; nodeId: string; topics: string[]; }> = []; if (radioConfig?.public_key) { const nodeId = nodeIdForKey(radioConfig.public_key); items.push({ kind: 'radio', label: radioConfig.name || radioConfig.public_key.slice(0, 12), publicKey: radioConfig.public_key, nodeId, topics: [`${prefix}/${nodeId}/health`], }); items.push({ kind: 'event', label: radioConfig.name || radioConfig.public_key.slice(0, 12), publicKey: radioConfig.public_key, nodeId, topics: [`${prefix}/${nodeId}/events/message`], }); } for (const repeater of selectedRepeaterDetails) { const nodeId = nodeIdForKey(repeater.public_key); items.push({ kind: 'repeater', label: repeater.name || repeater.public_key.slice(0, 12), publicKey: repeater.public_key, nodeId, topics: [`${prefix}/${nodeId}/telemetry`], }); } for (const contact of selectedContactDetails) { const nodeId = nodeIdForKey(contact.public_key); items.push({ kind: 'contact', label: contact.name || contact.public_key.slice(0, 12), publicKey: contact.public_key, nodeId, topics: [`${prefix}/${nodeId}/gps`], }); } return items; }, [nodeIdForKey, prefix, radioConfig, selectedContactDetails, selectedRepeaterDetails]); const kindLabel: Record<(typeof topicSummary)[number]['kind'], string> = { radio: 'Local radio state', event: 'Message events', repeater: 'Repeater telemetry', contact: 'Contact GPS', }; const localRadioNodeId = radioConfig?.public_key ? nodeIdForKey(radioConfig.public_key) : ''; const exampleRepeaterNodeId = selectedRepeaterDetails.length > 0 ? nodeIdForKey(selectedRepeaterDetails[0].public_key) : ''; const exampleContactNodeId = selectedContactDetails.length > 0 ? nodeIdForKey(selectedContactDetails[0].public_key) : ''; const toggleTrackedContact = (key: string) => { const current = [...selectedContacts]; const idx = current.indexOf(key); if (idx >= 0) current.splice(idx, 1); else current.push(key); onChange({ ...config, tracked_contacts: current }); }; const toggleTrackedRepeater = (key: string) => { const current = [...selectedRepeaters]; const idx = current.indexOf(key); if (idx >= 0) current.splice(idx, 1); else current.push(key); onChange({ ...config, tracked_repeaters: current }); }; return (

Home Assistant MQTT Discovery

Publish discovery configs and MeshCore state to your MQTT broker so Home Assistant creates native devices, sensors, GPS trackers, and message events automatically.

1. Same broker

Home Assistant's built-in MQTT integration must point at the same broker configured below.

2. Pick what to expose

Choose repeaters for telemetry sensors and contacts for GPS tracker entities.

3. Automate in HA

Radio health and message events publish continuously; repeater and contact data update when new data is heard or collected.

Uses{' '} window.open( 'https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery', '_blank' ) } onKeyDown={(e) => { if (e.key === 'Enter') window.open( 'https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery', '_blank' ); }} > MQTT Discovery {' '} and the topic conventions documented in{' '} window.open( 'https://github.com/jkingsman/Remote-Terminal-for-MeshCore/blob/main/README_HA.md', '_blank' ) } onKeyDown={(e) => { if (e.key === 'Enter') window.open( 'https://github.com/jkingsman/Remote-Terminal-for-MeshCore/blob/main/README_HA.md', '_blank' ); }} > README_HA.md .

What gets created in Home Assistant
Local radio device (always) — updates every 60s
  • {`binary_sensor.meshcore_${localRadioNodeId}_connected`} {' '} — radio online/offline
  • {`sensor.meshcore_${localRadioNodeId}_noise_floor`} {' '} — radio noise floor (dBm)
Per tracked repeater — updates on telemetry collect cycle (~8h) or manual dashboard fetch. Entity IDs shown use one repeater for example; these sensors are created for each selected repeater.
  • {`sensor.meshcore_${exampleRepeaterNodeId}_battery_voltage`} {' '} (V)
  • {`sensor.meshcore_${exampleRepeaterNodeId}_noise_floor`} ,{' '} {`sensor.meshcore_${exampleRepeaterNodeId}_last_rssi`} ,{' '} {`sensor.meshcore_${exampleRepeaterNodeId}_last_snr`} {' '} (dBm/dB)
  • {`sensor.meshcore_${exampleRepeaterNodeId}_packets_received`} ,{' '} {`sensor.meshcore_${exampleRepeaterNodeId}_packets_sent`}
  • {`sensor.meshcore_${exampleRepeaterNodeId}_uptime`} {' '} (seconds)
  • {`sensor.meshcore_${exampleRepeaterNodeId}_lpp_temperature_ch1`} ,{' '} {`sensor.meshcore_${exampleRepeaterNodeId}_lpp_humidity_ch1`} , etc. — CayenneLPP sensors (auto-detected from repeater)
Per tracked contact — updates passively when advertisements with GPS are heard. Shown for one contact; a tracker is created for each selected contact.
  • {`device_tracker.meshcore_${exampleContactNodeId}`} {' '} — latitude/longitude
Message events — fires for each message matching the scope below
  • {`event.meshcore_${localRadioNodeId}_messages`} {' '} — trigger automations on sender, channel, or message content

Entity IDs use the first 12 characters of the node's public key. Entities are removed from HA when this integration is disabled or deleted. State topics are published under{' '} {prefix}/<node_id>/health|telemetry|gps.

Published topic summary

Home Assistant device and entity IDs are keyed off the first 12 characters of each node's public key, not the display name. Those same 12 characters are used in the MQTT state topics below.

{topicSummary.length === 0 ? (

No topic previews available yet. Connect to a radio to resolve the local radio key, and select contacts or repeaters above to preview their published topics.

) : (
{topicSummary.map((item) => (
{kindLabel[item.kind]} {item.label} node id {item.nodeId}
key {item.publicKey}
{item.topics.map((topic) => (
{topic}
))}
))}
)}

Discovery config topics are also published under{' '} homeassistant/.../config, but the topics above are the primary runtime state and event topics.

MQTT Broker

onChange({ ...config, broker_host: e.target.value })} />
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value) }) } />
onChange({ ...config, username: e.target.value })} />
onChange({ ...config, password: e.target.value })} />
{!!config.use_tls && ( )}
onChange({ ...config, topic_prefix: e.target.value })} />

State updates publish under {prefix}/. Discovery configs always use the homeassistant/ prefix.

GPS Tracked Contacts

Each selected contact becomes a device_tracker{' '} in HA, updated whenever an advertisement with GPS coordinates is heard. Useful for tracking mobile nodes on an HA map dashboard.

{selectedContactDetails.length > 0 && (
{selectedContactDetails.map((c) => ( {c.name || c.public_key.slice(0, 12)} ))}
)} {contactOptions.length === 0 ? (

No contacts available.

) : ( <> setContactSearch(e.target.value)} className="h-8 text-sm" />
{filteredContacts.length === 0 ? (

No contacts match “{contactSearch}”

) : ( filteredContacts.map((c) => ( )) )}
)}

Telemetry Tracked Repeaters

Each selected repeater becomes an HA device with sensors for battery voltage, RSSI, SNR, noise floor, packet counts, and uptime. Data updates whenever telemetry is collected (auto-collect runs every ~8 hours, or on manual dashboard fetch). Only repeaters already in the auto-telemetry tracking list appear here (add new repeaters by logging into the repeater and opting in at the bottom of the page).

{trackedRepeaters.length === 0 ? (
No repeaters are being auto-tracked for telemetry. Add repeaters to the auto-telemetry tracking list in the Radio section first, then return here to select which ones to expose to HA.
) : repeaterOptions.length === 0 ? (

Auto-tracked repeaters not found in contact list.

) : (
{repeaterOptions.map((c) => ( ))}
)}

Message Events

Matching messages fire an{' '} {`event.meshcore_${localRadioNodeId}_messages`}{' '} entity in HA with sender, text, channel, and direction attributes. Use HA automations to trigger actions on specific messages, channels, or contacts.

); } function MqttCommunityConfigEditor({ config, onChange, }: { config: Record; onChange: (config: Record) => void; }) { const authMode = (config.auth_mode as string) || DEFAULT_COMMUNITY_AUTH_MODE; return (

Advanced community MQTT editor. Use this for manual meshcoretomqtt-compatible setups or for modifying a saved preset after creation. Only raw RF packets are shared — never decrypted messages.

onChange({ ...config, broker_host: e.target.value })} />
onChange({ ...config, broker_port: parseIntegerInputValue(e.target.value), }) } />

LetsMesh uses token auth. MeshRank uses none.

{((config.transport as string) || DEFAULT_COMMUNITY_TRANSPORT) === 'websockets' && (
onChange({ ...config, websocket_path: e.target.value })} />

Defaults to / — use /mqtt for brokers that require a path

)} {authMode === 'token' && (
onChange({ ...config, token_audience: e.target.value })} />

Defaults to the broker host when blank

onChange({ ...config, email: e.target.value })} />

Used to claim your node on the community aggregator

)} {authMode === 'password' && (
onChange({ ...config, username: e.target.value })} />
onChange({ ...config, password: e.target.value })} />
)}
onChange({ ...config, iata: e.target.value.toUpperCase() })} className="w-32" />

Your nearest airport's IATA code (required)

onChange({ ...config, topic_template: e.target.value })} />

Use {'{IATA}'} and {'{PUBLIC_KEY}'}. Default:{' '} {DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}

); } function MeshRankConfigEditor({ config, onChange, }: { config: Record; onChange: (config: Record) => void; }) { return (

Pre-filled MeshRank setup. This saves as a regular Community MQTT integration once created, but only asks for the MeshRank packet topic you were given.

Broker {DEFAULT_MESHRANK_BROKER_HOST} on port{' '} {DEFAULT_MESHRANK_BROKER_PORT} via {DEFAULT_MESHRANK_TRANSPORT}, auth {DEFAULT_MESHRANK_AUTH_MODE}, TLS on, certificate verification on, region code fixed to {DEFAULT_MESHRANK_IATA}.
onChange({ ...config, iata: DEFAULT_MESHRANK_IATA, topic_template: e.target.value, }) } />

Paste the full topic template from your MeshRank config, for example{' '} meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{'{PUBLIC_KEY}'}/packets.

); } function LetsMeshConfigEditor({ config, onChange, brokerHost, }: { config: Record; onChange: (config: Record) => void; brokerHost: string; }) { return (

Pre-filled LetsMesh setup. This saves as a regular Community MQTT integration once created, but only asks for the values LetsMesh expects from you.

Broker {brokerHost} on port {DEFAULT_COMMUNITY_BROKER_PORT} via{' '} {DEFAULT_COMMUNITY_TRANSPORT}, auth {DEFAULT_COMMUNITY_AUTH_MODE}, TLS on, certificate verification on, token audience fixed to {brokerHost}.
onChange({ ...config, email: e.target.value, broker_host: brokerHost }) } />
onChange({ ...config, broker_host: brokerHost, token_audience: brokerHost, iata: e.target.value.toUpperCase(), }) } className="w-32" />
); } function BotConfigEditor({ config, onChange, }: { config: Record; onChange: (config: Record) => void; }) { const code = (config.code as string) || ''; return (

Experimental: This is an alpha feature and introduces automated message sending to your radio; unexpected behavior may occur. Use with caution, and please report any bugs!

Security Warning: This feature executes arbitrary Python code on the server. Only run trusted code, and be cautious of arbitrary usage of message parameters.

Don't wreck the mesh! Bots process ALL messages, including their own. Be careful of creating infinite loops!

Define a bot() function that receives message data and optionally returns a reply.

Loading editor...
} > onChange({ ...config, code: c })} />

Available: Standard Python libraries and any modules installed in the server environment.

Limits: 10 second timeout per bot.

Note: Bots respond to all messages, including your own. For channel messages, sender_key is None. Multiple enabled bots run concurrently. Outgoing messages are serialized with a two-second delay between sends to prevent repeater collision.

); } function MapUploadConfigEditor({ config, onChange, }: { config: Record; onChange: (config: Record) => void; }) { const isDryRun = config.dry_run !== false; const [radioLat, setRadioLat] = useState(null); const [radioLon, setRadioLon] = useState(null); useEffect(() => { api .getRadioConfig() .then((rc) => { setRadioLat(rc.lat ?? 0); setRadioLon(rc.lon ?? 0); }) .catch(() => { setRadioLat(0); setRadioLon(0); }); }, []); const radioLatLonConfigured = radioLat !== null && radioLon !== null && !(radioLat === 0 && radioLon === 0); return (

Automatically upload heard repeater and room server advertisements to{' '} map.meshcore.io . Requires the radio's private key to be available (firmware must have{' '} ENABLE_PRIVATE_KEY_EXPORT=1). Only raw RF packets are shared — never decrypted messages.

Dry Run is {isDryRun ? 'ON' : 'OFF'}.{' '} {isDryRun ? 'No uploads will be sent. Check the backend logs to verify the payload looks correct before enabling live sends.' : 'Live uploads are enabled. Each advert is rate-limited to once per hour per node.'}
onChange({ ...config, api_url: e.target.value })} />

Leave blank to use the default map.meshcore.io endpoint.

{!!config.geofence_enabled && (
{!radioLatLonConfigured && (
Your radio does not currently have a latitude/longitude configured. Geofencing will be silently skipped until coordinates are set in{' '} Settings → Radio → Location.
)} {radioLatLonConfigured && (

Using radio position{' '} {radioLat?.toFixed(5)}, {radioLon?.toFixed(5)} {' '} as the geofence center. Update coordinates in Radio Settings to move the center.

)}
onChange({ ...config, geofence_radius_km: parseFloatInputValue(e.target.value), }) } />

Nodes further than this distance from your radio's position will not be uploaded.

)}
); } type ScopeMode = 'all' | 'none' | 'only' | 'except'; function getScopeMode(value: unknown): ScopeMode { if (value === 'all') return 'all'; if (value === 'none') return 'none'; if (typeof value === 'object' && value !== null) { // Check if either channels or contacts uses the {except: [...]} shape const obj = value as Record; const ch = obj.channels; const co = obj.contacts; if ( (typeof ch === 'object' && ch !== null && !Array.isArray(ch)) || (typeof co === 'object' && co !== null && !Array.isArray(co)) ) { return 'except'; } return 'only'; } return 'all'; } /** Extract the key list from a filter value, whether it's a plain list or {except: [...]} */ function getFilterKeys(filter: unknown): string[] { if (Array.isArray(filter)) return filter as string[]; if (typeof filter === 'object' && filter !== null && 'except' in filter) return ((filter as Record).except as string[]) ?? []; return []; } const MAX_SCOPE_PILL_DISPLAY = 32; interface PillsSearchListItem { key: string; label: string; /** Optional trailing monospace hint (e.g. pubkey prefix) */ trailing?: string; } /** * Search-and-pills picker for the generic fanout scope selector. * Shows selected items as removable pills (up to MAX_SCOPE_PILL_DISPLAY), * a search input, and a scrollable list of filtered items with checkboxes. * When more than MAX_SCOPE_PILL_DISPLAY items are selected, the pill row * collapses to a single informational badge to keep the interface clean. */ function PillsSearchList({ label, labelSuffix, items, selectedKeys, onToggle, onAll, onNone, searchPlaceholder, emptyItemsMessage, }: { label: string; labelSuffix: string; items: PillsSearchListItem[]; selectedKeys: string[]; onToggle: (key: string) => void; onAll: () => void; onNone: () => void; searchPlaceholder: string; emptyItemsMessage: string; }) { const [search, setSearch] = useState(''); const searchLower = search.toLowerCase().trim(); const filtered = useMemo(() => { const matches = items.filter((it) => { if (!searchLower) return true; return ( it.label.toLowerCase().includes(searchLower) || it.key.toLowerCase().startsWith(searchLower) ); }); // Selected items sort to top (mirrors the Home Assistant tracked-contacts picker) return matches.sort((a, b) => { const aSel = selectedKeys.includes(a.key) ? 0 : 1; const bSel = selectedKeys.includes(b.key) ? 0 : 1; if (aSel !== bSel) return aSel - bSel; return a.label.localeCompare(b.label); }); }, [items, searchLower, selectedKeys]); const selectedDetails = useMemo( () => items.filter((it) => selectedKeys.includes(it.key)), [items, selectedKeys] ); const overPillLimit = selectedDetails.length > MAX_SCOPE_PILL_DISPLAY; return (
/
{selectedDetails.length > 0 && (
{overPillLimit ? ( >{MAX_SCOPE_PILL_DISPLAY} selections made; hiding selection preview to keep the interface clean ) : ( selectedDetails.map((it) => ( {it.label} )) )}
)} {items.length === 0 ? (

{emptyItemsMessage}

) : ( <> setSearch(e.target.value)} className="h-8 text-sm" />
{filtered.length === 0 ? (

No {label.toLowerCase()} match “{search}”

) : ( filtered.map((it) => ( )) )}
)}
); } function ScopeSelector({ scope, onChange, showRawPackets = false, }: { scope: Record; onChange: (scope: Record) => void; showRawPackets?: boolean; }) { const [channels, setChannels] = useState([]); const [contacts, setContacts] = useState([]); useEffect(() => { api.getChannels().then(setChannels).catch(console.error); // Paginate to fetch all contacts (API caps at 1000 per request) (async () => { const all: Contact[] = []; const pageSize = 1000; let offset = 0; while (true) { const page = await api.getContacts(pageSize, offset); all.push(...page); if (page.length < pageSize) break; offset += pageSize; } setContacts(all); })().catch(console.error); }, []); const messages = scope.messages ?? 'all'; const rawMode = getScopeMode(messages); // When raw packets aren't offered, "none" is not a valid choice — treat as "all" const mode = !showRawPackets && rawMode === 'none' ? 'all' : rawMode; const isListMode = mode === 'only' || mode === 'except'; const selectedChannels: string[] = isListMode && typeof messages === 'object' && messages !== null ? getFilterKeys((messages as Record).channels) : []; const selectedContacts: string[] = isListMode && typeof messages === 'object' && messages !== null ? getFilterKeys((messages as Record).contacts) : []; /** Wrap channel/contact key lists in the right shape for the current mode */ const buildMessages = (chKeys: string[], coKeys: string[]) => { if (mode === 'except') { return { channels: { except: chKeys }, contacts: { except: coKeys }, }; } return { channels: chKeys, contacts: coKeys }; }; const handleModeChange = (newMode: ScopeMode) => { if (newMode === 'all' || newMode === 'none') { onChange({ ...scope, messages: newMode }); } else if (newMode === 'only') { onChange({ ...scope, messages: { channels: [], contacts: [] } }); } else { onChange({ ...scope, messages: { channels: { except: [] }, contacts: { except: [] } }, }); } }; const toggleChannel = (key: string) => { const current = [...selectedChannels]; const idx = current.indexOf(key); if (idx >= 0) current.splice(idx, 1); else current.push(key); onChange({ ...scope, messages: buildMessages(current, selectedContacts) }); }; const toggleContact = (key: string) => { const current = [...selectedContacts]; const idx = current.indexOf(key); if (idx >= 0) current.splice(idx, 1); else current.push(key); onChange({ ...scope, messages: buildMessages(selectedChannels, current) }); }; // Exclude repeaters (2), rooms (3), and sensors (4) const filteredContacts = contacts.filter((c) => c.type === 0 || c.type === 1); const modeDescriptions: Record = { all: 'All messages', none: 'No messages', only: 'Only listed channels/contacts', except: 'All except listed channels/contacts', }; const rawEnabled = showRawPackets && scope.raw_packets === 'all'; // Warn when the effective scope matches nothing const messagesEffectivelyNone = mode === 'none' || (mode === 'only' && selectedChannels.length === 0 && selectedContacts.length === 0) || (mode === 'except' && channels.length > 0 && filteredContacts.length > 0 && selectedChannels.length >= channels.length && selectedContacts.length >= filteredContacts.length); const showEmptyScopeWarning = messagesEffectivelyNone && !rawEnabled; const listHint = mode === 'only' ? 'Newly added channels or contacts will not be automatically included.' : 'Newly added channels or contacts will be automatically included unless excluded here.'; const checkboxLabel = mode === 'except' ? 'exclude' : 'include'; const messageModes: ScopeMode[] = showRawPackets ? ['all', 'none', 'only', 'except'] : ['all', 'only', 'except']; return (

Message Scope

{showRawPackets && ( )}
{messageModes.map((m) => ( ))}
{showEmptyScopeWarning && (
Nothing is selected — this integration will not forward any data.
)} {isListMode && ( <>

{listHint}

{channels.length > 0 && ( ({ key: ch.key, label: ch.name }))} selectedKeys={selectedChannels} onToggle={toggleChannel} onAll={() => onChange({ ...scope, messages: buildMessages( channels.map((ch) => ch.key), selectedContacts ), }) } onNone={() => onChange({ ...scope, messages: buildMessages([], selectedContacts) })} searchPlaceholder={`Search ${channels.length} channel${channels.length === 1 ? '' : 's'}...`} emptyItemsMessage="No channels available." /> )} {filteredContacts.length > 0 && ( ({ key: c.public_key, label: c.name || c.public_key.slice(0, 12), trailing: c.public_key.slice(0, 12), }))} selectedKeys={selectedContacts} onToggle={toggleContact} onAll={() => onChange({ ...scope, messages: buildMessages( selectedChannels, filteredContacts.map((c) => c.public_key) ), }) } onNone={() => onChange({ ...scope, messages: buildMessages(selectedChannels, []) })} searchPlaceholder={`Search ${filteredContacts.length} contact${filteredContacts.length === 1 ? '' : 's'}...`} emptyItemsMessage="No contacts available." /> )} )}
); } const APPRISE_DEFAULT_DM = '**DM:** {sender_name}: {text} **via:** [{hops_backticked}]'; const APPRISE_DEFAULT_CHANNEL = '**{channel_name}:** {sender_name}: {text} **via:** [{hops_backticked}]'; const APPRISE_DEFAULT_DM_PLAIN = 'DM: {sender_name}: {text} via: [{hops}]'; const APPRISE_DEFAULT_CHANNEL_PLAIN = '{channel_name}: {sender_name}: {text} via: [{hops}]'; const APPRISE_SAMPLE_VARS: Record = { type: 'CHAN', text: 'hello world', sender_name: 'Alice', sender_key: 'a1b2c3d4e5f6', channel_name: '#general', conversation_key: 'abcdef1234567890', hops: '2a, 3b', hops_backticked: '`2a`, `3b`', hop_count: '2', rssi: '-95', snr: '6.5', }; const APPRISE_SAMPLE_VARS_DM: Record = { ...APPRISE_SAMPLE_VARS, type: 'PRIV', channel_name: '', conversation_key: 'a1b2c3d4e5f6', }; function appriseApplyFormat(fmt: string, vars: Record): string { let result = fmt; for (const [key, value] of Object.entries(vars)) { result = result.split(`{${key}}`).join(value); } return result; } /** Render a markdown-ish string into inline React elements (bold, italic, code). */ function appriseRenderMarkdown(s: string): ReactNode[] { const nodes: ReactNode[] = []; let key = 0; // Split on **bold**, __bold__, *italic*, _italic_, and `code` spans. // Longer delimiters first so ** and __ match before * and _. const parts = s.split(/(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*]+\*|_[^_]+_)/g); for (const part of parts) { if ( (part.startsWith('**') && part.endsWith('**')) || (part.startsWith('__') && part.endsWith('__')) ) { nodes.push( {part.slice(2, -2)} ); } else if ( (part.startsWith('*') && part.endsWith('*')) || (part.startsWith('_') && part.endsWith('_')) ) { nodes.push( {part.slice(1, -1)} ); } else if (part.startsWith('`') && part.endsWith('`')) { nodes.push( {part.slice(1, -1)} ); } else if (part) { nodes.push({part}); } } return nodes; } function AppriseFormatPreview({ format, vars, markdown = true, }: { format: string; vars: Record; markdown?: boolean; }) { const raw = appriseApplyFormat(format, vars); return (
{markdown && (
Rendered (Discord, Slack, Telegram)

{appriseRenderMarkdown(raw)}

)}
{markdown ? 'Raw (email, SMS)' : 'Preview'}

{raw}

); } function appriseIsDefault(value: unknown, defaultStr: string): boolean { if (value == null) return true; const s = String(value).trim(); return s === '' || s === defaultStr; } function AppriseConfigEditor({ config, scope, onChange, onScopeChange, }: { config: Record; scope: Record; onChange: (config: Record) => void; onScopeChange: (scope: Record) => void; }) { const markdown = config.markdown_format !== false; const defaultDm = markdown ? APPRISE_DEFAULT_DM : APPRISE_DEFAULT_DM_PLAIN; const defaultChan = markdown ? APPRISE_DEFAULT_CHANNEL : APPRISE_DEFAULT_CHANNEL_PLAIN; const dmFormat = ((config.body_format_dm as string) || '').trim() || defaultDm; const chanFormat = ((config.body_format_channel as string) || '').trim() || defaultChan; return (

Send push notifications via{' '} Apprise {' '} when messages are received. Supports Discord, Slack, Telegram, email, and{' '} 100+ other services .