mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
Compare commits
4 commits
main
...
feat/meshc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dfa7f16b0 | ||
|
|
c9fee2dcdb | ||
|
|
57125964af | ||
|
|
9498779608 |
8 changed files with 811 additions and 85 deletions
|
|
@ -217,7 +217,7 @@ The curated channel chatter your bot observes is used only as short-term *contex
|
||||||
llm:
|
llm:
|
||||||
backend: "google" # google | openai | anthropic
|
backend: "google" # google | openai | anthropic
|
||||||
api_key: "your-api-key"
|
api_key: "your-api-key"
|
||||||
model: "gemini-2.5-flash"
|
model: "gemini-3.1-flash-lite"
|
||||||
```
|
```
|
||||||
|
|
||||||
Any OpenAI-compatible endpoint works for local models — point `base_url` at Ollama (`http://localhost:11434/v1`), LiteLLM (`http://localhost:4000/v1`), or Open WebUI.
|
Any OpenAI-compatible endpoint works for local models — point `base_url` at Ollama (`http://localhost:11434/v1`), LiteLLM (`http://localhost:4000/v1`), or Open WebUI.
|
||||||
|
|
|
||||||
|
|
@ -606,6 +606,38 @@ export async function getMeshcoreChannelsDetail(): Promise<MeshcoreChannelsDetai
|
||||||
return fetchJson<MeshcoreChannelsDetail>('/api/meshcore/channels/detail')
|
return fetchJson<MeshcoreChannelsDetail>('/api/meshcore/channels/detail')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provision a new MeshCore channel on the companion. `key` is a 32-char hex
|
||||||
|
// PSK (16 bytes); omit it for a public channel (name must start with "#").
|
||||||
|
// Throws (with the backend's `detail` message) on failure — e.g. duplicate
|
||||||
|
// name, no free slot, bad key, or MeshCore not connected.
|
||||||
|
export async function addMeshcoreChannel(
|
||||||
|
name: string,
|
||||||
|
key?: string,
|
||||||
|
): Promise<MeshcoreChannels> {
|
||||||
|
const response = await fetch('/api/meshcore/channels', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, key: key || undefined }),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => null)
|
||||||
|
throw new Error(body?.detail || `API error: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a provisioned MeshCore channel from the companion by name.
|
||||||
|
export async function removeMeshcoreChannel(name: string): Promise<MeshcoreChannels> {
|
||||||
|
const response = await fetch(`/api/meshcore/channels/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => null)
|
||||||
|
throw new Error(body?.detail || `API error: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
// MeshCore room servers (type-3 contacts). A routing cell targets a room with
|
// MeshCore room servers (type-3 contacts). A routing cell targets a room with
|
||||||
// the value ``room:<pubkey>`` (vs a bare channel name for channel targets).
|
// the value ``room:<pubkey>`` (vs a bare channel name for channel targets).
|
||||||
// ``active:false`` / [] when MeshCore is not connected.
|
// ``active:false`` / [] when MeshCore is not connected.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Save, RotateCcw, RefreshCw, Check, ChevronRight } from 'lucide-react'
|
import { Save, RotateCcw, RefreshCw, Check, ChevronRight, Trash2, Eye, EyeOff, Copy } from 'lucide-react'
|
||||||
import { TextInput, NumberInput, Toggle, ListInput, SelectInput } from './Config'
|
import { TextInput, NumberInput, Toggle, ListInput, SelectInput } from './Config'
|
||||||
import SerialPortPicker from '@/components/SerialPortPicker'
|
import SerialPortPicker from '@/components/SerialPortPicker'
|
||||||
import { notifyRestartRequired } from '@/components/RestartBanner'
|
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||||
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api'
|
import {
|
||||||
|
fetchConfig as apiFetchConfig,
|
||||||
|
updateConfig as apiUpdateConfig,
|
||||||
|
getMeshcoreChannels,
|
||||||
|
getMeshcoreChannelsDetail,
|
||||||
|
addMeshcoreChannel,
|
||||||
|
removeMeshcoreChannel,
|
||||||
|
sendTestMessage,
|
||||||
|
} from '@/lib/api'
|
||||||
import { useDirty } from '@/context/DirtyContext'
|
import { useDirty } from '@/context/DirtyContext'
|
||||||
|
|
||||||
// Only the fields this page edits are typed explicitly; the rest of the
|
// Only the fields this page edits are typed explicitly; the rest of the
|
||||||
|
|
@ -54,11 +62,23 @@ export default function MeshCoreConnection() {
|
||||||
// Test send state
|
// Test send state
|
||||||
const [channelsActive, setChannelsActive] = useState(false)
|
const [channelsActive, setChannelsActive] = useState(false)
|
||||||
const [channels, setChannels] = useState<string[]>([])
|
const [channels, setChannels] = useState<string[]>([])
|
||||||
|
// Per-channel PSK hex (name -> key), captured from the companion so operators
|
||||||
|
// can share the key with people who want to join. Masked by default.
|
||||||
|
const [channelKeys, setChannelKeys] = useState<Record<string, string | null>>({})
|
||||||
|
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set())
|
||||||
|
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||||
const [selectedChannel, setSelectedChannel] = useState('')
|
const [selectedChannel, setSelectedChannel] = useState('')
|
||||||
const [testText, setTestText] = useState('')
|
const [testText, setTestText] = useState('')
|
||||||
const [testSending, setTestSending] = useState(false)
|
const [testSending, setTestSending] = useState(false)
|
||||||
const [testResult, setTestResult] = useState<{ sent: boolean; detail: string } | null>(null)
|
const [testResult, setTestResult] = useState<{ sent: boolean; detail: string } | null>(null)
|
||||||
|
|
||||||
|
// Add / remove channel state (provisions the companion's channel table)
|
||||||
|
const [newChannelName, setNewChannelName] = useState('')
|
||||||
|
const [newChannelKey, setNewChannelKey] = useState('')
|
||||||
|
const [channelSaving, setChannelSaving] = useState(false)
|
||||||
|
const [channelRemoving, setChannelRemoving] = useState<string | null>(null)
|
||||||
|
const [channelError, setChannelError] = useState<string | null>(null)
|
||||||
|
|
||||||
const fetchConfig = useCallback(async () => {
|
const fetchConfig = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -84,18 +104,91 @@ export default function MeshCoreConnection() {
|
||||||
fetchConfig()
|
fetchConfig()
|
||||||
}, [fetchConfig])
|
}, [fetchConfig])
|
||||||
|
|
||||||
useEffect(() => {
|
const refreshChannels = useCallback(async () => {
|
||||||
getMeshcoreChannels()
|
try {
|
||||||
.then((res) => {
|
// Prefer the detail endpoint (name + PSK key); it carries everything the
|
||||||
|
// names-only list does. Fall back to names-only if detail is unavailable.
|
||||||
|
const detail = await getMeshcoreChannelsDetail()
|
||||||
|
const names = detail.channels.map((c) => c.name)
|
||||||
|
const keyMap: Record<string, string | null> = {}
|
||||||
|
for (const c of detail.channels) keyMap[c.name] = c.key
|
||||||
|
setChannelsActive(detail.active)
|
||||||
|
setChannels(names)
|
||||||
|
setChannelKeys(keyMap)
|
||||||
|
setSelectedChannel((prev) => (prev && names.includes(prev) ? prev : names[0] ?? ''))
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const res = await getMeshcoreChannels()
|
||||||
setChannelsActive(res.active)
|
setChannelsActive(res.active)
|
||||||
setChannels(res.channels)
|
setChannels(res.channels)
|
||||||
if (res.channels.length > 0) setSelectedChannel(res.channels[0])
|
setChannelKeys({})
|
||||||
})
|
setSelectedChannel((prev) => (prev && res.channels.includes(prev) ? prev : res.channels[0] ?? ''))
|
||||||
.catch(() => {
|
} catch {
|
||||||
setChannelsActive(false)
|
setChannelsActive(false)
|
||||||
})
|
}
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshChannels()
|
||||||
|
}, [refreshChannels])
|
||||||
|
|
||||||
|
const toggleRevealKey = (name: string) => {
|
||||||
|
setRevealedKeys((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(name)) next.delete(name)
|
||||||
|
else next.add(name)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyKey = async (name: string, key: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(key)
|
||||||
|
setCopiedKey(name)
|
||||||
|
setTimeout(() => setCopiedKey((prev) => (prev === name ? null : prev)), 1500)
|
||||||
|
} catch {
|
||||||
|
// Clipboard API can be blocked (non-secure context); reveal the key so
|
||||||
|
// the operator can copy it manually instead of failing silently.
|
||||||
|
setRevealedKeys((prev) => new Set(prev).add(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddChannel = async () => {
|
||||||
|
const name = newChannelName.trim()
|
||||||
|
if (!name) return
|
||||||
|
setChannelSaving(true)
|
||||||
|
setChannelError(null)
|
||||||
|
try {
|
||||||
|
await addMeshcoreChannel(name, newChannelKey.trim())
|
||||||
|
setNewChannelName('')
|
||||||
|
setNewChannelKey('')
|
||||||
|
await refreshChannels()
|
||||||
|
} catch (err) {
|
||||||
|
setChannelError(err instanceof Error ? err.message : 'Failed to add channel')
|
||||||
|
} finally {
|
||||||
|
setChannelSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveChannel = async (name: string) => {
|
||||||
|
setChannelRemoving(name)
|
||||||
|
setChannelError(null)
|
||||||
|
try {
|
||||||
|
await removeMeshcoreChannel(name)
|
||||||
|
// Drop it from Observe Channels too, if it was selected there.
|
||||||
|
setMcContext((c) => {
|
||||||
|
if (!c || !(c.observe_channels ?? []).includes(name)) return c
|
||||||
|
return { ...c, observe_channels: (c.observe_channels ?? []).filter((n) => n !== name) }
|
||||||
|
})
|
||||||
|
await refreshChannels()
|
||||||
|
} catch (err) {
|
||||||
|
setChannelError(err instanceof Error ? err.message : 'Failed to remove channel')
|
||||||
|
} finally {
|
||||||
|
setChannelRemoving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleTestSend = async () => {
|
const handleTestSend = async () => {
|
||||||
setTestSending(true)
|
setTestSending(true)
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
|
|
@ -357,19 +450,69 @@ export default function MeshCoreConnection() {
|
||||||
<div className="border border-[#1e2a3a] p-2 space-y-1">
|
<div className="border border-[#1e2a3a] p-2 space-y-1">
|
||||||
{channels.map((ch) => {
|
{channels.map((ch) => {
|
||||||
const selected = (mcContext.observe_channels ?? []).includes(ch)
|
const selected = (mcContext.observe_channels ?? []).includes(ch)
|
||||||
|
const key = channelKeys[ch] ?? null
|
||||||
|
const revealed = revealedKeys.has(ch)
|
||||||
return (
|
return (
|
||||||
<label
|
<div
|
||||||
key={ch}
|
key={ch}
|
||||||
onClick={() => toggleObserveChannel(ch)}
|
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17]"
|
||||||
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
<label
|
||||||
selected ? 'bg-accent border-accent' : 'border-slate-600'
|
onClick={() => toggleObserveChannel(ch)}
|
||||||
}`}>
|
className="flex items-center gap-2 cursor-pointer shrink-0"
|
||||||
{selected && <Check size={12} className="text-white" />}
|
>
|
||||||
|
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||||
|
selected ? 'bg-accent border-accent' : 'border-slate-600'
|
||||||
|
}`}>
|
||||||
|
{selected && <Check size={12} className="text-white" />}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-200">{ch}</span>
|
||||||
|
</label>
|
||||||
|
{/* Channel key (PSK hex) — share this to let others join. Masked by
|
||||||
|
default; reveal per-row with the eye, copy with the copy button. */}
|
||||||
|
<div className="flex items-center gap-1 flex-1 min-w-0 justify-end">
|
||||||
|
{key ? (
|
||||||
|
<>
|
||||||
|
<code
|
||||||
|
title={revealed ? key : 'Key hidden — click the eye to reveal'}
|
||||||
|
className="text-xs font-mono text-slate-400 truncate max-w-[16rem]"
|
||||||
|
>
|
||||||
|
{revealed ? key : '••••••••••••••••'}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={revealed ? 'Hide key' : 'Reveal key'}
|
||||||
|
aria-label={revealed ? `Hide key for ${ch}` : `Reveal key for ${ch}`}
|
||||||
|
onClick={() => toggleRevealKey(ch)}
|
||||||
|
className="p-1 text-slate-600 hover:text-slate-300"
|
||||||
|
>
|
||||||
|
{revealed ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Copy key to clipboard"
|
||||||
|
aria-label={`Copy key for ${ch}`}
|
||||||
|
onClick={() => handleCopyKey(ch, key)}
|
||||||
|
className="p-1 text-slate-600 hover:text-accent"
|
||||||
|
>
|
||||||
|
{copiedKey === ch ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-mono text-slate-600" title="No retrievable key for this channel">—</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-slate-200">{ch}</span>
|
<button
|
||||||
</label>
|
type="button"
|
||||||
|
title={`Remove channel '${ch}' from the companion`}
|
||||||
|
aria-label={`Remove channel ${ch}`}
|
||||||
|
disabled={channelRemoving === ch}
|
||||||
|
onClick={() => handleRemoveChannel(ch)}
|
||||||
|
className="p-1 text-slate-600 hover:text-red-400 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{channels.length === 0 && (
|
{channels.length === 0 && (
|
||||||
|
|
@ -378,7 +521,42 @@ export default function MeshCoreConnection() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-600">Choose which MeshCore channels feed MeshAI's context. Empty = none are watched — pick channels to include their chatter in what the bot knows about the mesh. Leave busy/public channels out to keep them out of context.</p>
|
<p className="text-xs text-slate-600">Choose which MeshCore channels feed MeshAI's context. Empty = none are watched — pick channels to include their chatter in what the bot knows about the mesh. Leave busy/public channels out to keep them out of context. Each channel's key (PSK) is shown on the right — reveal and copy it to share with people who want to join.</p>
|
||||||
|
|
||||||
|
{/* Add a new channel (name + PSK) to the companion's channel table */}
|
||||||
|
<div className="flex items-end gap-2 pt-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<label className="block text-xs text-slate-500 uppercase tracking-wide">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newChannelName}
|
||||||
|
onChange={(e) => setNewChannelName(e.target.value)}
|
||||||
|
placeholder="#channel-name"
|
||||||
|
disabled={!channelsActive}
|
||||||
|
className="w-full px-2 py-1.5 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<label className="block text-xs text-slate-500 uppercase tracking-wide">Key</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newChannelKey}
|
||||||
|
onChange={(e) => setNewChannelKey(e.target.value)}
|
||||||
|
placeholder="PSK hex (32 chars) — leave blank for public #channel"
|
||||||
|
disabled={!channelsActive}
|
||||||
|
className="w-full px-2 py-1.5 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddChannel}
|
||||||
|
disabled={!channelsActive || channelSaving || !newChannelName.trim()}
|
||||||
|
className="px-3 py-1.5 bg-accent hover:bg-accent/80 disabled:opacity-50 disabled:cursor-not-allowed rounded text-sm text-white whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{channelSaving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{channelError && <p className="text-xs text-red-400">{channelError}</p>}
|
||||||
</div>
|
</div>
|
||||||
<ListInput
|
<ListInput
|
||||||
label="Ignore MeshCore Contacts"
|
label="Ignore MeshCore Contacts"
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,22 @@ _config_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BotConfig:
|
class BotConfig:
|
||||||
"""Bot identity and trigger settings."""
|
"""Bot identity and trigger settings.
|
||||||
|
|
||||||
name: str = "ai"
|
mt_mesh_name/mt_node/mc_mesh_name: transport-specific identity used ONLY
|
||||||
owner: str = ""
|
when generating the LLM system prompt for that transport (see
|
||||||
|
router.generate_llm_response). Generic OSS defaults are intentionally
|
||||||
|
empty/mesh-agnostic -- deployments fill these in via config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = "MeshAI"
|
||||||
|
owner: str = "Unknown"
|
||||||
contact_email: str = ""
|
contact_email: str = ""
|
||||||
respond_to_dms: bool = True
|
respond_to_dms: bool = True
|
||||||
filter_bbs_protocols: bool = True
|
filter_bbs_protocols: bool = True
|
||||||
|
mt_mesh_name: str = "" # e.g. "freq51 Meshtastic mesh" -- Meshtastic-only identity framing
|
||||||
|
mt_node: str = "" # e.g. "!27780c47 (AIDA-N2)" -- Meshtastic-only physical node id
|
||||||
|
mc_mesh_name: str = "" # e.g. "the MeshCore mesh" -- MeshCore-only identity framing
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -167,9 +176,7 @@ class LLMConfig:
|
||||||
"observed any yet.\n"
|
"observed any yet.\n"
|
||||||
"- When asked about yourself or commands, answer conversationally based on "
|
"- When asked about yourself or commands, answer conversationally based on "
|
||||||
"the command list provided below. Don't dump lists unless asked.\n"
|
"the command list provided below. Don't dump lists unless asked.\n"
|
||||||
"- You are part of the freq51 mesh.\n"
|
|
||||||
"- When asked about yourself or commands, answer conversationally. Don't dump lists.\n"
|
"- When asked about yourself or commands, answer conversationally. Don't dump lists.\n"
|
||||||
"- You are part of the freq51 mesh in the Twin Falls, Idaho area.\n"
|
|
||||||
"- NEVER use markdown formatting (no bold, no asterisks, no bullet points, no numbered lists). Plain text only.\n"
|
"- NEVER use markdown formatting (no bold, no asterisks, no bullet points, no numbered lists). Plain text only.\n"
|
||||||
"- NEVER say 'Want me to keep going?' -- the system handles continuation prompts automatically."
|
"- NEVER say 'Want me to keep going?' -- the system handles continuation prompts automatically."
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from meshai import secrets_store
|
from meshai import secrets_store
|
||||||
|
|
||||||
|
|
@ -58,6 +58,85 @@ async def meshcore_channels_detail(request: Request):
|
||||||
return {"active": False, "channels": []}
|
return {"active": False, "channels": []}
|
||||||
|
|
||||||
|
|
||||||
|
class AddChannelRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
key: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meshcore/channels")
|
||||||
|
async def meshcore_add_channel(request: Request, body: AddChannelRequest):
|
||||||
|
"""Provision a new MeshCore channel (name + PSK) onto the companion.
|
||||||
|
|
||||||
|
Body: {"name": str, "key"?: str}. ``key`` is a 32-char hex string (16
|
||||||
|
bytes) — omit it (or leave empty) for a public channel, which requires
|
||||||
|
``name`` to start with "#" so the companion derives the PSK from the
|
||||||
|
name. Returns the refreshed channel list on success.
|
||||||
|
"""
|
||||||
|
connector = getattr(request.app.state, "connector", None)
|
||||||
|
mc = _find_child(connector, "meshcore")
|
||||||
|
if mc is None or not getattr(mc, "connected", False):
|
||||||
|
raise HTTPException(status_code=409, detail="MeshCore not connected")
|
||||||
|
|
||||||
|
name = (body.name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="Channel name must not be empty")
|
||||||
|
|
||||||
|
key = (body.key or "").strip()
|
||||||
|
secret: Optional[bytes] = None
|
||||||
|
if key:
|
||||||
|
try:
|
||||||
|
secret = bytes.fromhex(key)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Channel key must be valid hex")
|
||||||
|
if len(secret) != 16:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Channel key must be exactly 32 hex characters (16 bytes)",
|
||||||
|
)
|
||||||
|
elif not name.startswith("#"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="A channel key is required unless the name starts with '#' (public)",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
mc.add_channel(name, secret)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("dashboard: meshcore add_channel error: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
|
||||||
|
logger.info("dashboard: meshcore channel '%s' added", name)
|
||||||
|
return {"active": True, "channels": list(mc.known_channels())}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/meshcore/channels/{name}")
|
||||||
|
async def meshcore_remove_channel(request: Request, name: str):
|
||||||
|
"""Remove a provisioned MeshCore channel from the companion by name.
|
||||||
|
|
||||||
|
Returns the refreshed channel list on success; 404 if the name is not
|
||||||
|
on the companion's channel table.
|
||||||
|
"""
|
||||||
|
connector = getattr(request.app.state, "connector", None)
|
||||||
|
mc = _find_child(connector, "meshcore")
|
||||||
|
if mc is None or not getattr(mc, "connected", False):
|
||||||
|
raise HTTPException(status_code=409, detail="MeshCore not connected")
|
||||||
|
|
||||||
|
try:
|
||||||
|
mc.remove_channel(name)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("dashboard: meshcore remove_channel error: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
|
||||||
|
logger.info("dashboard: meshcore channel '%s' removed", name)
|
||||||
|
return {"active": True, "channels": list(mc.known_channels())}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/meshcore/rooms")
|
@router.get("/meshcore/rooms")
|
||||||
async def meshcore_rooms(request: Request):
|
async def meshcore_rooms(request: Request):
|
||||||
"""List MeshCore room servers if a meshcore transport is connected.
|
"""List MeshCore room servers if a meshcore transport is connected.
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,255 @@ def _build_region_abbreviations(region_names: list[str]) -> dict[str, str]:
|
||||||
return abbrevs
|
return abbrevs
|
||||||
|
|
||||||
|
|
||||||
|
def _build_alert_channels_line(config, transport: str) -> str:
|
||||||
|
"""Build a short identity-block line describing regional alert channels
|
||||||
|
for the given transport, sourced from notifications.region_routes.
|
||||||
|
|
||||||
|
Reads config.notifications.region_routes.cells (family -> region ->
|
||||||
|
cell_dict). Only includes cells that are enabled (matrix-level
|
||||||
|
mt_enabled/mc_enabled AND the cell's own "enabled" flag) and that have
|
||||||
|
a non-empty value in this transport's column ("mt" channel index for
|
||||||
|
meshtastic, "mc" channel name for meshcore).
|
||||||
|
|
||||||
|
Region names are used verbatim from region_routes — they are NOT
|
||||||
|
reconciled with mesh_intelligence region names.
|
||||||
|
|
||||||
|
Fail-safe: returns "" on any error so a broken/missing config never
|
||||||
|
breaks prompt assembly.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A 1-3 line string (no leading/trailing blank lines), or "" if no
|
||||||
|
routed regions are configured/enabled for this transport.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
region_routes = getattr(config.notifications, "region_routes", None)
|
||||||
|
if region_routes is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
transport_enabled = (
|
||||||
|
region_routes.mt_enabled if transport == "meshtastic" else region_routes.mc_enabled
|
||||||
|
)
|
||||||
|
if not transport_enabled:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
cells = getattr(region_routes, "cells", {}) or {}
|
||||||
|
col = "mt" if transport == "meshtastic" else "mc"
|
||||||
|
|
||||||
|
# region -> set(family) for families/regions routed on this transport.
|
||||||
|
families_by_region: dict = {}
|
||||||
|
for family, region_map in cells.items():
|
||||||
|
if not isinstance(region_map, dict):
|
||||||
|
continue
|
||||||
|
for region_name, cell in region_map.items():
|
||||||
|
if not isinstance(cell, dict) or not cell.get("enabled", False):
|
||||||
|
continue
|
||||||
|
dest = cell.get(col)
|
||||||
|
if not dest:
|
||||||
|
continue
|
||||||
|
families_by_region.setdefault(region_name, set()).add(family)
|
||||||
|
|
||||||
|
if not families_by_region:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Distinct hazard families across all routed regions, for the intro line.
|
||||||
|
all_families = sorted({f for fams in families_by_region.values() for f in fams})
|
||||||
|
_FAMILY_LABELS = {
|
||||||
|
"weather": "weather",
|
||||||
|
"fire": "fire",
|
||||||
|
"roads": "roads",
|
||||||
|
"avalanche": "avalanche",
|
||||||
|
"seismic": "seismic",
|
||||||
|
"power_outage": "power outages",
|
||||||
|
"satpass": "satellite passes",
|
||||||
|
}
|
||||||
|
hazard_words = ", ".join(_FAMILY_LABELS.get(f, f) for f in all_families)
|
||||||
|
|
||||||
|
# region -> destination value (mt index or mc name), for the mapping line.
|
||||||
|
# A region may route different families to different destinations in
|
||||||
|
# theory; in practice the matrix is uniform per-region, so just take
|
||||||
|
# the first cell's destination for the mapping display.
|
||||||
|
dest_by_region = {}
|
||||||
|
for family, region_map in cells.items():
|
||||||
|
if not isinstance(region_map, dict):
|
||||||
|
continue
|
||||||
|
for region_name, cell in region_map.items():
|
||||||
|
if region_name not in families_by_region:
|
||||||
|
continue
|
||||||
|
if region_name in dest_by_region:
|
||||||
|
continue
|
||||||
|
dest = cell.get(col)
|
||||||
|
if dest:
|
||||||
|
dest_by_region[region_name] = dest
|
||||||
|
|
||||||
|
if transport == "meshtastic":
|
||||||
|
mapping = ", ".join(
|
||||||
|
f"channel {dest_by_region[r]} = {r}" for r in dest_by_region
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
mapping = ", ".join(
|
||||||
|
f"{dest_by_region[r]} = {r}" for r in dest_by_region
|
||||||
|
)
|
||||||
|
|
||||||
|
if not mapping:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"You broadcast regional hazard alerts ({hazard_words}). "
|
||||||
|
f"People can join a channel to receive that region's alerts: {mapping}."
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("alert channels line build failed")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_meshcore_channel_block(config, channel_details, position=None) -> str:
|
||||||
|
"""Build the MeshCore channel-recommendation context block.
|
||||||
|
|
||||||
|
Single-shot context-stuffing for the LLM (no tools): lists ONLY the
|
||||||
|
channels the operator has opted to observe (``meshcore_context.observe_channels``),
|
||||||
|
each annotated with its PSK key (from ``channel_details``) and — where a
|
||||||
|
region routing cell maps to it — the covered region's human geography
|
||||||
|
(local name, cities, centroid) so the model can match a user's town/GPS
|
||||||
|
to the right channel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: the loaded Config object (reads meshcore_context.observe_channels,
|
||||||
|
notifications.region_routes, mesh_intelligence.regions).
|
||||||
|
channel_details: list of {name, hash, key} dicts from
|
||||||
|
CompositeTransport.channel_details() (key = PSK hex).
|
||||||
|
position: optional (lat, lon) tuple for the requesting user; when
|
||||||
|
present a "USER LOCATION" line is emitted so the LLM can auto-match.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The block as a string, or "" if no observed channels are configured
|
||||||
|
(caller should skip injection in that case).
|
||||||
|
"""
|
||||||
|
# 1. Observed set — the ONLY channels we may disclose/recommend.
|
||||||
|
observe = list(getattr(config.meshcore_context, "observe_channels", []) or [])
|
||||||
|
if not observe:
|
||||||
|
return ""
|
||||||
|
observe_set = set(observe)
|
||||||
|
|
||||||
|
# 2. key lookup: channel name -> PSK hex (from live channel_details()).
|
||||||
|
key_by_name = {}
|
||||||
|
for d in (channel_details or []):
|
||||||
|
name = d.get("name")
|
||||||
|
if name:
|
||||||
|
key_by_name[name] = d.get("key")
|
||||||
|
|
||||||
|
# 3. Reverse map: channel name -> set(route region names), scanning every
|
||||||
|
# family/region in region_routes for a cell whose `mc` names the channel.
|
||||||
|
regions_by_channel = {}
|
||||||
|
try:
|
||||||
|
cells = getattr(config.notifications.region_routes, "cells", {}) or {}
|
||||||
|
for _family, region_map in cells.items():
|
||||||
|
if not isinstance(region_map, dict):
|
||||||
|
continue
|
||||||
|
for region_name, cell in region_map.items():
|
||||||
|
if not isinstance(cell, dict):
|
||||||
|
continue
|
||||||
|
mc = cell.get("mc")
|
||||||
|
if mc:
|
||||||
|
regions_by_channel.setdefault(mc, set()).add(region_name)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("region_routes reverse-map build failed")
|
||||||
|
|
||||||
|
# 4. Human geography from mesh_intelligence regions (best-effort attach).
|
||||||
|
# region_routes uses "SW/SC/East Idaho"; mesh_intelligence uses names
|
||||||
|
# like "South Western ID" — we do a loose direction-word match rather
|
||||||
|
# than a strict reconcile, and let the LLM reason over the raw metadata.
|
||||||
|
geo_regions = []
|
||||||
|
try:
|
||||||
|
geo_regions = list(getattr(config.mesh_intelligence, "regions", []) or [])
|
||||||
|
except Exception:
|
||||||
|
logger.exception("mesh_intelligence regions read failed")
|
||||||
|
|
||||||
|
_DIRECTION_TOKENS = {
|
||||||
|
"sw": "south west", "sc": "south central", "se": "south east",
|
||||||
|
"nw": "north west", "ne": "north east",
|
||||||
|
"n": "north", "s": "south", "e": "east", "w": "west", "c": "central",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _direction_words(route_region: str) -> set:
|
||||||
|
"""Expand the leading direction abbreviation of a route region name
|
||||||
|
(e.g. "SW Idaho" -> {south, west}; "East Idaho" -> {east})."""
|
||||||
|
words = set()
|
||||||
|
for tok in route_region.lower().replace("-", " ").split():
|
||||||
|
if tok in ("idaho", "id"):
|
||||||
|
continue
|
||||||
|
expanded = _DIRECTION_TOKENS.get(tok, tok)
|
||||||
|
words.update(expanded.split())
|
||||||
|
return words
|
||||||
|
|
||||||
|
def _match_geo(route_region: str):
|
||||||
|
"""Fuzzy/substring match a route region name to a mesh_intelligence
|
||||||
|
region; returns the RegionAnchor or None."""
|
||||||
|
rr_words = _direction_words(route_region)
|
||||||
|
best = None
|
||||||
|
best_score = 0
|
||||||
|
for r in geo_regions:
|
||||||
|
name = (getattr(r, "name", "") or "").lower()
|
||||||
|
geo_words = set(name.replace("-", " ").split()) - {"idaho", "id"}
|
||||||
|
score = len(rr_words & geo_words)
|
||||||
|
if score > best_score:
|
||||||
|
best_score, best = score, r
|
||||||
|
return best if best_score > 0 else None
|
||||||
|
|
||||||
|
# 5. Emit one concise line per OBSERVED channel.
|
||||||
|
lines = ["", "MESHCORE CHANNELS YOU CAN RECOMMEND (name + join key):"]
|
||||||
|
for name in observe:
|
||||||
|
if name not in observe_set:
|
||||||
|
continue
|
||||||
|
key = key_by_name.get(name)
|
||||||
|
key_str = key if key else "(key unavailable)"
|
||||||
|
route_regions = sorted(regions_by_channel.get(name, set()))
|
||||||
|
region_str = ""
|
||||||
|
if route_regions:
|
||||||
|
details = []
|
||||||
|
for rr in route_regions:
|
||||||
|
geo = _match_geo(rr)
|
||||||
|
if geo is not None:
|
||||||
|
local = getattr(geo, "local_name", "") or ""
|
||||||
|
cities = getattr(geo, "cities", []) or []
|
||||||
|
lat = getattr(geo, "lat", None)
|
||||||
|
lon = getattr(geo, "lon", None)
|
||||||
|
bits = [rr]
|
||||||
|
inner = []
|
||||||
|
if local:
|
||||||
|
inner.append(local)
|
||||||
|
if cities:
|
||||||
|
inner.append(", ".join(cities[:4]))
|
||||||
|
if inner:
|
||||||
|
bits.append(f"({'; '.join(inner)}")
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
bits[-1] += f"; ~{lat:.2f},{lon:.2f}"
|
||||||
|
bits[-1] += ")"
|
||||||
|
elif lat is not None and lon is not None:
|
||||||
|
bits.append(f"(~{lat:.2f},{lon:.2f})")
|
||||||
|
details.append(" ".join(bits))
|
||||||
|
else:
|
||||||
|
details.append(rr)
|
||||||
|
region_str = " — region: " + " / ".join(details)
|
||||||
|
lines.append(f" {name}{region_str} — key: {key_str}")
|
||||||
|
|
||||||
|
# 6. Optional user location for auto-matching.
|
||||||
|
if position is not None:
|
||||||
|
try:
|
||||||
|
lat, lon = position
|
||||||
|
lines.append(f"USER LOCATION: {lat},{lon}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 7. Instruction for the model.
|
||||||
|
lines.append(
|
||||||
|
"If the user asks which MeshCore channel to join: determine their area "
|
||||||
|
"from USER LOCATION if given, otherwise ask which Idaho town/area they're "
|
||||||
|
"in, then tell them the channel NAME and KEY to join. Only recommend "
|
||||||
|
"channels listed above; never invent a channel or key."
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
class MessageRouter:
|
class MessageRouter:
|
||||||
"""Routes incoming messages to appropriate handlers."""
|
"""Routes incoming messages to appropriate handlers."""
|
||||||
|
|
||||||
|
|
@ -675,20 +924,56 @@ class MessageRouter:
|
||||||
|
|
||||||
# Build system prompt in order: identity -> static -> meshmonitor -> context -> knowledge -> mesh
|
# Build system prompt in order: identity -> static -> meshmonitor -> context -> knowledge -> mesh
|
||||||
|
|
||||||
# 1. Dynamic identity from bot config
|
# Transport of the originating message -- drives identity framing and
|
||||||
|
# gating of transport-specific prompt blocks below.
|
||||||
|
transport = getattr(message, "transport", "meshtastic")
|
||||||
|
|
||||||
|
# 1. Dynamic identity from bot config, branched by transport.
|
||||||
bot_name = self.config.bot.name or "MeshAI"
|
bot_name = self.config.bot.name or "MeshAI"
|
||||||
bot_owner = self.config.bot.owner or "Unknown"
|
bot_owner = self.config.bot.owner or "Unknown"
|
||||||
|
|
||||||
identity = (
|
if transport == "meshcore":
|
||||||
f"You are {bot_name}, an LLM-powered assistant on the freq51 Meshtastic mesh network. "
|
mc_mesh_name = self.config.bot.mc_mesh_name or "the MeshCore mesh"
|
||||||
f"Your managing operator is {bot_owner}. "
|
identity = (
|
||||||
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
|
f"You are {bot_name}, an LLM-powered assistant on {mc_mesh_name}, "
|
||||||
f"IDENTITY: Your name is {bot_name}. You ARE a physical node on the mesh — "
|
f"connected via a MeshCore companion radio. "
|
||||||
f"node !27780c47 (AIDA-N2). You have a real location, real GPS coordinates, "
|
f"Your managing operator is {bot_owner}. "
|
||||||
f"and real radio connections. When someone asks how far something is from you, "
|
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
|
||||||
f"check the mesh data for your node's position and calculate. "
|
f"IDENTITY: Your name is {bot_name}. You have a real MeshCore radio "
|
||||||
f"You are NOT just software — you are a node that other nodes can see, hear, and route through.\n\n"
|
f"presence — you send and receive over an actual MeshCore companion "
|
||||||
)
|
f"radio, not just software. You do NOT have a Meshtastic node identity "
|
||||||
|
f"and you are NOT part of MeshMonitor.\n\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
mt_mesh_name = self.config.bot.mt_mesh_name
|
||||||
|
mt_node = self.config.bot.mt_node
|
||||||
|
if mt_mesh_name or mt_node:
|
||||||
|
mesh_label = mt_mesh_name or "the mesh"
|
||||||
|
identity = (
|
||||||
|
f"You are {bot_name}, an LLM-powered assistant on {mesh_label}. "
|
||||||
|
f"Your managing operator is {bot_owner}. "
|
||||||
|
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
|
||||||
|
)
|
||||||
|
if mt_node:
|
||||||
|
identity += (
|
||||||
|
f"IDENTITY: Your name is {bot_name}. You ARE a physical node on "
|
||||||
|
f"the mesh — node {mt_node}. You have a real location, real GPS "
|
||||||
|
f"coordinates, and real radio connections. When someone asks how "
|
||||||
|
f"far something is from you, check the mesh data for your node's "
|
||||||
|
f"position and calculate. You are NOT just software — you are a "
|
||||||
|
f"node that other nodes can see, hear, and route through.\n\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No transport identity configured -- generic non-MT-specific fallback.
|
||||||
|
identity = (
|
||||||
|
f"You are {bot_name}, an LLM-powered assistant on a mesh network. "
|
||||||
|
f"Your managing operator is {bot_owner}. "
|
||||||
|
f"You are open source at github.com/zvx-echo6/meshai.\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
alert_channels_line = _build_alert_channels_line(self.config, transport)
|
||||||
|
if alert_channels_line:
|
||||||
|
identity += alert_channels_line + "\n\n"
|
||||||
|
|
||||||
# 2. Static system prompt from config
|
# 2. Static system prompt from config
|
||||||
static_prompt = ""
|
static_prompt = ""
|
||||||
|
|
@ -724,9 +1009,11 @@ class MessageRouter:
|
||||||
)
|
)
|
||||||
system_prompt += "\n".join(cmd_lines)
|
system_prompt += "\n".join(cmd_lines)
|
||||||
|
|
||||||
# 3. MeshMonitor info (only when enabled)
|
# 3. MeshMonitor info (only when enabled -- Meshtastic-only, MeshMonitor
|
||||||
|
# has no MeshCore concept)
|
||||||
if (
|
if (
|
||||||
self.meshmonitor_sync
|
transport == "meshtastic"
|
||||||
|
and self.meshmonitor_sync
|
||||||
and self.config.meshmonitor.enabled
|
and self.config.meshmonitor.enabled
|
||||||
and self.config.meshmonitor.inject_into_prompt
|
and self.config.meshmonitor.inject_into_prompt
|
||||||
):
|
):
|
||||||
|
|
@ -837,53 +1124,78 @@ class MessageRouter:
|
||||||
# v0.7-fire-tracker-4: scope already detected above; no
|
# v0.7-fire-tracker-4: scope already detected above; no
|
||||||
# second call needed.
|
# second call needed.
|
||||||
|
|
||||||
# Always include Tier 1 summary for mesh questions
|
# Meshtastic-only: node-health/gateway/packet reporting. This whole
|
||||||
tier1 = self.mesh_reporter.build_tier1_summary()
|
# sub-block is built from meshtasticd/MeshMonitor-derived node data
|
||||||
system_prompt += "\n\n" + tier1
|
# (mesh_reporter tracks Meshtastic node IDs, infra/gateway scoring,
|
||||||
|
# packet cadence, etc.) and has no MeshCore equivalent.
|
||||||
|
if transport == "meshtastic":
|
||||||
|
# Always include Tier 1 summary for mesh questions
|
||||||
|
tier1 = self.mesh_reporter.build_tier1_summary()
|
||||||
|
system_prompt += "\n\n" + tier1
|
||||||
|
|
||||||
# Add Tier 2 detail if scoped
|
# Add Tier 2 detail if scoped
|
||||||
if scope_type == "region" and scope_value:
|
if scope_type == "region" and scope_value:
|
||||||
region_detail = self.mesh_reporter.build_region_detail(scope_value)
|
region_detail = self.mesh_reporter.build_region_detail(scope_value)
|
||||||
system_prompt += "\n\n" + region_detail
|
system_prompt += "\n\n" + region_detail
|
||||||
elif scope_type == "node" and scope_value:
|
elif scope_type == "node" and scope_value:
|
||||||
node_detail = self.mesh_reporter.build_node_detail(scope_value)
|
node_detail = self.mesh_reporter.build_node_detail(scope_value)
|
||||||
system_prompt += "\n\n" + node_detail
|
system_prompt += "\n\n" + node_detail
|
||||||
|
|
||||||
# Always include relevant recommendations
|
# Always include relevant recommendations
|
||||||
recommendations = self.mesh_reporter.build_recommendations(scope_type, scope_value)
|
recommendations = self.mesh_reporter.build_recommendations(scope_type, scope_value)
|
||||||
if recommendations:
|
if recommendations:
|
||||||
system_prompt += "\n\n" + recommendations
|
system_prompt += "\n\n" + recommendations
|
||||||
|
|
||||||
# Add mesh awareness instructions with dynamic region name mappings
|
# Add mesh awareness instructions with dynamic region name mappings
|
||||||
region_name_instructions = ""
|
region_name_instructions = ""
|
||||||
if self.config.mesh_intelligence and self.config.mesh_intelligence.regions:
|
if self.config.mesh_intelligence and self.config.mesh_intelligence.regions:
|
||||||
# Build region name mappings for the prompt
|
# Build region name mappings for the prompt
|
||||||
mappings = []
|
mappings = []
|
||||||
for region in self.config.mesh_intelligence.regions:
|
for region in self.config.mesh_intelligence.regions:
|
||||||
local = getattr(region, "local_name", "") or ""
|
local = getattr(region, "local_name", "") or ""
|
||||||
if local and local != region.name:
|
if local and local != region.name:
|
||||||
mappings.append(f'say "{local}" not "{region.name}"')
|
mappings.append(f'say "{local}" not "{region.name}"')
|
||||||
if mappings:
|
if mappings:
|
||||||
region_name_instructions = f"- ALWAYS use local region names: {', '.join(mappings)}. The code names mean nothing to users."
|
region_name_instructions = f"- ALWAYS use local region names: {', '.join(mappings)}. The code names mean nothing to users."
|
||||||
|
|
||||||
system_prompt += _MESH_AWARENESS_PROMPT.format(
|
system_prompt += _MESH_AWARENESS_PROMPT.format(
|
||||||
region_name_instructions=region_name_instructions
|
region_name_instructions=region_name_instructions
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build region geography from config dynamically
|
# Build region geography from config dynamically
|
||||||
if self.config.mesh_intelligence and self.config.mesh_intelligence.regions:
|
if self.config.mesh_intelligence and self.config.mesh_intelligence.regions:
|
||||||
geo_lines = ["", "REGION GEOGRAPHY (use local names when discussing these regions):"]
|
geo_lines = ["", "REGION GEOGRAPHY (use local names when discussing these regions):"]
|
||||||
for region in self.config.mesh_intelligence.regions:
|
for region in self.config.mesh_intelligence.regions:
|
||||||
local = getattr(region, "local_name", "") or ""
|
local = getattr(region, "local_name", "") or ""
|
||||||
local_str = f' "{local}"' if local else ""
|
local_str = f' "{local}"' if local else ""
|
||||||
desc = getattr(region, "description", "") or ""
|
desc = getattr(region, "description", "") or ""
|
||||||
desc_str = f" — {desc}" if desc else ""
|
desc_str = f" — {desc}" if desc else ""
|
||||||
aliases = getattr(region, "aliases", []) or []
|
aliases = getattr(region, "aliases", []) or []
|
||||||
alias_str = ""
|
alias_str = ""
|
||||||
if aliases:
|
if aliases:
|
||||||
alias_str = f'\n People may call this: {", ".join(aliases)}'
|
alias_str = f'\n People may call this: {", ".join(aliases)}'
|
||||||
geo_lines.append(f" - {region.name}{local_str}{desc_str}{alias_str}")
|
geo_lines.append(f" - {region.name}{local_str}{desc_str}{alias_str}")
|
||||||
system_prompt += "\n".join(geo_lines)
|
system_prompt += "\n".join(geo_lines)
|
||||||
|
|
||||||
|
# MeshCore channel-recommendation block: for "what channel
|
||||||
|
# should I join?" style questions. Transport-neutral (works for
|
||||||
|
# both meshes -- the block itself is scoped to MeshCore channel
|
||||||
|
# data). Fail-safe — never break the LLM path if transport/config
|
||||||
|
# access throws.
|
||||||
|
try:
|
||||||
|
channel_details = self.connector.channel_details()
|
||||||
|
position = None
|
||||||
|
try:
|
||||||
|
position = self.connector.get_node_position(message.sender_id)
|
||||||
|
except Exception:
|
||||||
|
position = None
|
||||||
|
mc_block = _build_meshcore_channel_block(
|
||||||
|
self.config, channel_details, position
|
||||||
|
)
|
||||||
|
if mc_block:
|
||||||
|
system_prompt += "\n\n" + mc_block
|
||||||
|
except Exception:
|
||||||
|
logger.exception("meshcore channel block injection failed")
|
||||||
|
|
||||||
# Update mesh context tracking
|
# Update mesh context tracking
|
||||||
self._update_user_mesh_context(
|
self._update_user_mesh_context(
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,11 @@ class CompositeTransport(MeshTransport):
|
||||||
child = self.meshcore_child()
|
child = self.meshcore_child()
|
||||||
return child.known_channels() if child is not None else []
|
return child.known_channels() if child is not None else []
|
||||||
|
|
||||||
|
def channel_details(self) -> List[dict]:
|
||||||
|
"""Passthrough to the MeshCore child's [{name, hash, key}] channel view; [] if no meshcore child."""
|
||||||
|
child = self.meshcore_child()
|
||||||
|
return child.channel_details() if child is not None else []
|
||||||
|
|
||||||
def get_contacts(self) -> List[dict]:
|
def get_contacts(self) -> List[dict]:
|
||||||
"""Passthrough to the MeshCore child's contact roster; [] if no meshcore child."""
|
"""Passthrough to the MeshCore child's contact roster; [] if no meshcore child."""
|
||||||
child = self.meshcore_child()
|
child = self.meshcore_child()
|
||||||
|
|
|
||||||
|
|
@ -852,6 +852,119 @@ class MeshCoreTransport(MeshTransport):
|
||||||
dashboard; captured alongside _chan_name_to_idx at connect."""
|
dashboard; captured alongside _chan_name_to_idx at connect."""
|
||||||
return list(self._chan_details)
|
return list(self._chan_details)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Channel provisioning (add / remove) — self-service from the dashboard.
|
||||||
|
#
|
||||||
|
# There is no dedicated "delete channel" opcode in the companion
|
||||||
|
# firmware/lib; ``set_channel`` (opcode 0x20) is the only channel write.
|
||||||
|
# A slot is considered EMPTY when ``get_channel(idx)`` reports a blank
|
||||||
|
# ``channel_name`` (see _enumerate_channels). Freeing a slot therefore
|
||||||
|
# means writing it back to that same empty state: name="" and a fixed
|
||||||
|
# all-zero 16-byte secret (passed explicitly so the lib's "derive from
|
||||||
|
# sha256(name)" fallback — which only triggers when secret is None or
|
||||||
|
# name starts with "#" — never fires for a blank name).
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Companion channel table has a hard cap of 40 slots (0-39); see
|
||||||
|
# _enumerate_channels. Firmware truncates names to 32 bytes.
|
||||||
|
_MAX_CHANNEL_SLOTS = 40
|
||||||
|
_MAX_CHANNEL_NAME_BYTES = 32
|
||||||
|
_EMPTY_SECRET = bytes(16)
|
||||||
|
|
||||||
|
def _find_free_slot(self) -> Optional[int]:
|
||||||
|
"""Scan the full companion channel table for the first EMPTY slot.
|
||||||
|
|
||||||
|
Unlike ``_enumerate_channels`` (which stops early after 3 contiguous
|
||||||
|
empty slots as a fast-path heuristic for the common contiguous-
|
||||||
|
provisioning case), this scans every slot 0..39 without an early
|
||||||
|
empty-run cutoff, so a free slot past a gap is never missed. Stops
|
||||||
|
early only on a transport error/None result (treated as end of a
|
||||||
|
usable table). Returns None if no empty slot was found.
|
||||||
|
"""
|
||||||
|
for idx in range(self._MAX_CHANNEL_SLOTS):
|
||||||
|
try:
|
||||||
|
event = self._run_coro(self._mc.commands.get_channel(idx))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"MeshCore: get_channel(%d) failed while scanning for free slot: %s",
|
||||||
|
idx, exc,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if not event:
|
||||||
|
break
|
||||||
|
is_err = getattr(event, "is_error", None)
|
||||||
|
if callable(is_err) and event.is_error():
|
||||||
|
break
|
||||||
|
payload = event.payload or {}
|
||||||
|
name = payload.get("channel_name", "")
|
||||||
|
if not name:
|
||||||
|
return idx
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_channel(self, name: str, secret: Optional[bytes]) -> dict:
|
||||||
|
"""Provision a new channel (name + PSK) onto the companion.
|
||||||
|
|
||||||
|
``secret`` is 16 raw bytes, or None for a public/derived channel
|
||||||
|
(name should start with "#" in that case — the lib derives the PSK
|
||||||
|
as sha256(name)[:16]). Picks the first free slot, writes it, then
|
||||||
|
re-enumerates so ``_chan_name_to_idx``/``_chan_details`` reflect the
|
||||||
|
change immediately. Raises ValueError on bad input, RuntimeError if
|
||||||
|
not connected or no free slot, and re-raises transport errors.
|
||||||
|
"""
|
||||||
|
if self._mc is None or not self._connected:
|
||||||
|
raise RuntimeError("MeshCore not connected")
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Channel name must not be empty")
|
||||||
|
if len(name.encode("utf-8")) > self._MAX_CHANNEL_NAME_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Channel name exceeds {self._MAX_CHANNEL_NAME_BYTES}-byte companion limit"
|
||||||
|
)
|
||||||
|
if secret is not None and len(secret) != 16:
|
||||||
|
raise ValueError("Channel secret must be exactly 16 bytes")
|
||||||
|
if name in self._chan_name_to_idx:
|
||||||
|
raise ValueError(f"Channel '{name}' already exists")
|
||||||
|
|
||||||
|
free_idx = self._find_free_slot()
|
||||||
|
if free_idx is None:
|
||||||
|
raise RuntimeError("No free channel slot on companion")
|
||||||
|
|
||||||
|
event = self._run_coro(self._mc.commands.set_channel(free_idx, name, secret))
|
||||||
|
is_err = getattr(event, "is_error", None)
|
||||||
|
if callable(is_err) and event.is_error():
|
||||||
|
reason = (getattr(event, "payload", None) or {}).get("reason", "unknown")
|
||||||
|
raise RuntimeError(f"set_channel failed: {reason}")
|
||||||
|
|
||||||
|
self._enumerate_channels()
|
||||||
|
logger.info("MeshCore: added channel '%s' at slot %d", name, free_idx)
|
||||||
|
return {"name": name, "idx": free_idx}
|
||||||
|
|
||||||
|
def remove_channel(self, name: str) -> None:
|
||||||
|
"""Clear a provisioned channel's slot on the companion.
|
||||||
|
|
||||||
|
Resolves *name* to its slot via ``_chan_name_to_idx`` and writes the
|
||||||
|
slot back to its empty state (blank name, all-zero secret) — the
|
||||||
|
companion firmware exposes no dedicated delete opcode, so this is
|
||||||
|
the only way to free a slot. Re-enumerates on success. Raises
|
||||||
|
RuntimeError if not connected or the name is unknown.
|
||||||
|
"""
|
||||||
|
if self._mc is None or not self._connected:
|
||||||
|
raise RuntimeError("MeshCore not connected")
|
||||||
|
idx = self._chan_name_to_idx.get(name)
|
||||||
|
if idx is None:
|
||||||
|
raise RuntimeError(f"Channel '{name}' not found on companion")
|
||||||
|
|
||||||
|
event = self._run_coro(
|
||||||
|
self._mc.commands.set_channel(idx, "", self._EMPTY_SECRET)
|
||||||
|
)
|
||||||
|
is_err = getattr(event, "is_error", None)
|
||||||
|
if callable(is_err) and event.is_error():
|
||||||
|
reason = (getattr(event, "payload", None) or {}).get("reason", "unknown")
|
||||||
|
raise RuntimeError(f"set_channel (clear) failed: {reason}")
|
||||||
|
|
||||||
|
self._enumerate_channels()
|
||||||
|
logger.info("MeshCore: removed channel '%s' (was slot %d)", name, idx)
|
||||||
|
|
||||||
def get_contacts(self) -> list[dict]:
|
def get_contacts(self) -> list[dict]:
|
||||||
"""Roster of known MeshCore contacts. [] if not connected."""
|
"""Roster of known MeshCore contacts. [] if not connected."""
|
||||||
if self._mc is None or not self._connected:
|
if self._mc is None or not self._connected:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue