Add radio model and stats display. Closes #64

This commit is contained in:
Jack Kingsman
2026-03-16 15:29:21 -07:00
parent 58b34a6a2f
commit ea5ba3b2a3
43 changed files with 431 additions and 116 deletions
+10
View File
@@ -131,6 +131,11 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False self._setup_in_progress: bool = False
self._setup_complete: bool = False self._setup_complete: bool = False
self.device_info_loaded: bool = False
self.max_contacts: int | None = None
self.device_model: str | None = None
self.firmware_build: str | None = None
self.firmware_version: str | None = None
self.max_channels: int = 40 self.max_channels: int = 40
self.path_hash_mode: int = 0 self.path_hash_mode: int = 0
self.path_hash_mode_supported: bool = False self.path_hash_mode_supported: bool = False
@@ -488,6 +493,11 @@ class RadioManager:
await self._disable_meshcore_auto_reconnect(mc) await self._disable_meshcore_auto_reconnect(mc)
self._meshcore = None self._meshcore = None
self._setup_complete = False self._setup_complete = False
self.device_info_loaded = False
self.max_contacts = None
self.device_model = None
self.firmware_build = None
self.firmware_version = None
self.max_channels = 40 self.max_channels = 40
self.path_hash_mode = 0 self.path_hash_mode = 0
self.path_hash_mode_supported = False self.path_hash_mode_supported = False
+30 -10
View File
@@ -11,18 +11,34 @@ from app.services.radio_runtime import radio_runtime as radio_manager
router = APIRouter(tags=["health"]) router = APIRouter(tags=["health"])
class RadioDeviceInfoResponse(BaseModel):
model: str | None = None
firmware_build: str | None = None
firmware_version: str | None = None
max_contacts: int | None = None
max_channels: int | None = None
class HealthResponse(BaseModel): class HealthResponse(BaseModel):
status: str status: str
radio_connected: bool radio_connected: bool
radio_initializing: bool = False radio_initializing: bool = False
radio_state: str = "disconnected" radio_state: str = "disconnected"
connection_info: str | None connection_info: str | None
radio_device_info: RadioDeviceInfoResponse | None = None
database_size_mb: float database_size_mb: float
oldest_undecrypted_timestamp: int | None oldest_undecrypted_timestamp: int | None
fanout_statuses: dict[str, dict[str, str]] = {} fanout_statuses: dict[str, dict[str, str]] = {}
bots_disabled: bool = False bots_disabled: bool = False
def _clean_optional_str(value: object) -> str | None:
if not isinstance(value, str):
return None
cleaned = value.strip()
return cleaned or None
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict: async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
"""Build the health status payload used by REST endpoint and WebSocket broadcasts.""" """Build the health status payload used by REST endpoint and WebSocket broadcasts."""
db_size_mb = 0.0 db_size_mb = 0.0
@@ -48,22 +64,12 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
pass pass
setup_in_progress = getattr(radio_manager, "is_setup_in_progress", False) setup_in_progress = getattr(radio_manager, "is_setup_in_progress", False)
if not isinstance(setup_in_progress, bool):
setup_in_progress = False
setup_complete = getattr(radio_manager, "is_setup_complete", radio_connected) setup_complete = getattr(radio_manager, "is_setup_complete", radio_connected)
if not isinstance(setup_complete, bool):
setup_complete = radio_connected
if not radio_connected: if not radio_connected:
setup_complete = False setup_complete = False
connection_desired = getattr(radio_manager, "connection_desired", True) connection_desired = getattr(radio_manager, "connection_desired", True)
if not isinstance(connection_desired, bool):
connection_desired = True
is_reconnecting = getattr(radio_manager, "is_reconnecting", False) is_reconnecting = getattr(radio_manager, "is_reconnecting", False)
if not isinstance(is_reconnecting, bool):
is_reconnecting = False
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete)) radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
if not connection_desired: if not connection_desired:
@@ -77,12 +83,26 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
else: else:
radio_state = "disconnected" radio_state = "disconnected"
radio_device_info = None
device_info_loaded = getattr(radio_manager, "device_info_loaded", False)
if radio_connected and device_info_loaded:
radio_device_info = {
"model": _clean_optional_str(getattr(radio_manager, "device_model", None)),
"firmware_build": _clean_optional_str(getattr(radio_manager, "firmware_build", None)),
"firmware_version": _clean_optional_str(
getattr(radio_manager, "firmware_version", None)
),
"max_contacts": getattr(radio_manager, "max_contacts", None),
"max_channels": getattr(radio_manager, "max_channels", None),
}
return { return {
"status": "ok" if radio_connected and not radio_initializing else "degraded", "status": "ok" if radio_connected and not radio_initializing else "degraded",
"radio_connected": radio_connected, "radio_connected": radio_connected,
"radio_initializing": radio_initializing, "radio_initializing": radio_initializing,
"radio_state": radio_state, "radio_state": radio_state,
"connection_info": connection_info, "connection_info": connection_info,
"radio_device_info": radio_device_info,
"database_size_mb": db_size_mb, "database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts, "oldest_undecrypted_timestamp": oldest_ts,
"fanout_statuses": fanout_statuses, "fanout_statuses": fanout_statuses,
+78 -12
View File
@@ -7,6 +7,21 @@ POST_CONNECT_SETUP_TIMEOUT_SECONDS = 300
POST_CONNECT_SETUP_MAX_ATTEMPTS = 2 POST_CONNECT_SETUP_MAX_ATTEMPTS = 2
def _clean_device_string(value: object) -> str | None:
if not isinstance(value, str):
return None
cleaned = value.strip()
return cleaned or None
def _decode_fixed_string(raw: bytes, start: int, length: int) -> str | None:
if len(raw) < start:
return None
return _clean_device_string(
raw[start : start + length].decode("utf-8", "ignore").replace("\0", "")
)
async def run_post_connect_setup(radio_manager) -> None: async def run_post_connect_setup(radio_manager) -> None:
"""Run shared radio initialization after a transport connection succeeds.""" """Run shared radio initialization after a transport connection succeeds."""
from app.event_handlers import register_event_handlers from app.event_handlers import register_event_handlers
@@ -78,26 +93,66 @@ async def run_post_connect_setup(radio_manager) -> None:
return await _original_handle_rx(data) return await _original_handle_rx(data)
reader.handle_rx = _capture_handle_rx reader.handle_rx = _capture_handle_rx
radio_manager.device_info_loaded = False
radio_manager.max_contacts = None
radio_manager.device_model = None
radio_manager.firmware_build = None
radio_manager.firmware_version = None
radio_manager.max_channels = 40 radio_manager.max_channels = 40
radio_manager.path_hash_mode = 0 radio_manager.path_hash_mode = 0
radio_manager.path_hash_mode_supported = False radio_manager.path_hash_mode_supported = False
try: try:
device_query = await mc.commands.send_device_query() device_query = await mc.commands.send_device_query()
if device_query and "max_channels" in device_query.payload: payload = (
radio_manager.max_channels = max( device_query.payload
1, int(device_query.payload["max_channels"]) if device_query is not None and isinstance(device_query.payload, dict)
) else {}
if device_query and "path_hash_mode" in device_query.payload: )
radio_manager.path_hash_mode = device_query.payload["path_hash_mode"]
payload_max_contacts = payload.get("max_contacts")
if isinstance(payload_max_contacts, int):
radio_manager.max_contacts = max(1, payload_max_contacts)
payload_max_channels = payload.get("max_channels")
if isinstance(payload_max_channels, int):
radio_manager.max_channels = max(1, payload_max_channels)
radio_manager.device_model = _clean_device_string(payload.get("model"))
radio_manager.firmware_build = _clean_device_string(payload.get("fw_build"))
radio_manager.firmware_version = _clean_device_string(payload.get("ver"))
fw_ver = payload.get("fw ver")
payload_reports_device_info = isinstance(fw_ver, int) and fw_ver >= 3
if payload_reports_device_info:
radio_manager.device_info_loaded = True
if "path_hash_mode" in payload and isinstance(payload["path_hash_mode"], int):
radio_manager.path_hash_mode = payload["path_hash_mode"]
radio_manager.path_hash_mode_supported = True radio_manager.path_hash_mode_supported = True
elif _captured_frame:
# Raw-frame fallback: if _captured_frame:
# byte 1 = fw_ver, byte 3 = max_channels, byte 81 = path_hash_mode # Raw-frame fallback / completion:
# byte 1 = fw_ver, byte 2 = max_contacts/2, byte 3 = max_channels,
# bytes 8:20 = fw_build, 20:60 = model, 60:80 = ver, byte 81 = path_hash_mode
raw = _captured_frame[-1] raw = _captured_frame[-1]
fw_ver = raw[1] if len(raw) > 1 else 0 fw_ver = raw[1] if len(raw) > 1 else 0
if fw_ver >= 3 and len(raw) >= 4: if fw_ver >= 3:
radio_manager.max_channels = max(1, raw[3]) radio_manager.device_info_loaded = True
if fw_ver >= 10 and len(raw) >= 82: if radio_manager.max_contacts is None and len(raw) >= 3:
radio_manager.max_contacts = max(1, raw[2] * 2)
if len(raw) >= 4 and not isinstance(payload_max_channels, int):
radio_manager.max_channels = max(1, raw[3])
if radio_manager.firmware_build is None:
radio_manager.firmware_build = _decode_fixed_string(raw, 8, 12)
if radio_manager.device_model is None:
radio_manager.device_model = _decode_fixed_string(raw, 20, 40)
if radio_manager.firmware_version is None:
radio_manager.firmware_version = _decode_fixed_string(raw, 60, 20)
if (
not radio_manager.path_hash_mode_supported
and fw_ver >= 10
and len(raw) >= 82
):
radio_manager.path_hash_mode = raw[81] radio_manager.path_hash_mode = raw[81]
radio_manager.path_hash_mode_supported = True radio_manager.path_hash_mode_supported = True
logger.warning( logger.warning(
@@ -114,6 +169,17 @@ async def run_post_connect_setup(radio_manager) -> None:
logger.info("Path hash mode: %d (supported)", radio_manager.path_hash_mode) logger.info("Path hash mode: %d (supported)", radio_manager.path_hash_mode)
else: else:
logger.debug("Firmware does not report path_hash_mode") logger.debug("Firmware does not report path_hash_mode")
if radio_manager.device_info_loaded:
logger.info(
"Radio device info: model=%s build=%s version=%s max_contacts=%s max_channels=%d",
radio_manager.device_model or "unknown",
radio_manager.firmware_build or "unknown",
radio_manager.firmware_version or "unknown",
radio_manager.max_contacts
if radio_manager.max_contacts is not None
else "unknown",
radio_manager.max_channels,
)
logger.info("Max channel slots: %d", radio_manager.max_channels) logger.info("Max channel slots: %d", radio_manager.max_channels)
except Exception as exc: except Exception as exc:
logger.debug("Failed to query device info capabilities: %s", exc) logger.debug("Failed to query device info capabilities: %s", exc)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{r as u,D as j,j as e,U as y,m as N}from"./index-C_33NHxn.js";import{M as w,T as _}from"./leaflet-DNHXPpW7.js";import{C as k,P as C}from"./Popup-BcLg2SsA.js";import{u as M}from"./hooks-BmAJqTPK.js";const i={recent:"#06b6d4",today:"#2563eb",stale:"#f59e0b",old:"#64748b"},R="#0f172a",p="#f8fafc";function E(a){if(a==null)return i.old;const l=Date.now()/1e3-a,n=3600,t=86400;return l<n?i.recent:l<t?i.today:l<3*t?i.stale:i.old}function v({contacts:a,focusedContact:r}){const l=M(),[n,t]=u.useState(!1);return u.useEffect(()=>{if(r&&r.lat!=null&&r.lon!=null){l.setView([r.lat,r.lon],12),t(!0);return}if(n)return;const c=()=>{if(a.length===0){l.setView([20,0],2),t(!0);return}if(a.length===1){l.setView([a[0].lat,a[0].lon],10),t(!0);return}const d=a.map(m=>[m.lat,m.lon]);l.fitBounds(d,{padding:[50,50],maxZoom:12}),t(!0)};"geolocation"in navigator?navigator.geolocation.getCurrentPosition(d=>{l.setView([d.coords.latitude,d.coords.longitude],8),t(!0)},()=>{c()},{timeout:5e3,maximumAge:3e5}):c()},[l,a,n,r]),null}function V({contacts:a,focusedKey:r}){const[l]=u.useState(()=>Date.now()/1e3-604800),n=u.useMemo(()=>a.filter(s=>j(s.lat,s.lon)&&(s.public_key===r||s.last_seen!=null&&s.last_seen>l)),[a,r,l]),t=u.useMemo(()=>r&&n.find(s=>s.public_key===r)||null,[r,n]),c=t!=null&&(t.last_seen==null||t.last_seen<=l),d=u.useRef({}),m=u.useCallback((s,o)=>{d.current[s]=o},[]);return u.useEffect(()=>{if(t&&d.current[t.public_key]){const s=setTimeout(()=>{var o;(o=d.current[t.public_key])==null||o.openPopup()},100);return()=>clearTimeout(s)}},[t]),e.jsxs("div",{className:"flex flex-col h-full",children:[e.jsxs("div",{className:"px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between",children:[e.jsxs("span",{children:["Showing ",n.length," contact",n.length!==1?"s":""," heard in the last 7 days",c?" plus the focused contact":""]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full",style:{backgroundColor:i.recent},"aria-hidden":"true"})," ","<1h"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full",style:{backgroundColor:i.today},"aria-hidden":"true"})," ","<1d"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full",style:{backgroundColor:i.stale},"aria-hidden":"true"})," ","<3d"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full",style:{backgroundColor:i.old},"aria-hidden":"true"})," ","older"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full border-2",style:{borderColor:p,backgroundColor:i.today},"aria-hidden":"true"})," ","repeater"]})]})]}),e.jsx("div",{className:"flex-1 relative",style:{zIndex:0},role:"img","aria-label":"Map showing mesh node locations",children:e.jsxs(w,{center:[20,0],zoom:2,className:"h-full w-full",style:{background:"#1a1a2e"},children:[e.jsx(_,{attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',url:"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"}),e.jsx(v,{contacts:n,focusedContact:t}),n.map(s=>{const o=s.type===y,x=E(s.last_seen),f=s.name||s.public_key.slice(0,12),h=s.last_seen!=null?N(s.last_seen):"Never heard by this server",g=o?10:7;return e.jsx(u.Fragment,{children:e.jsx(k,{ref:b=>m(s.public_key,b),center:[s.lat,s.lon],radius:g,pathOptions:{color:o?p:R,fillColor:x,fillOpacity:.9,weight:o?3:2},children:e.jsx(C,{children:e.jsxs("div",{className:"text-sm",children:[e.jsxs("div",{className:"font-medium flex items-center gap-1",children:[o&&e.jsx("span",{title:"Repeater","aria-hidden":"true",children:"🛜"}),f]}),e.jsxs("div",{className:"text-xs text-gray-500 mt-1",children:["Last heard: ",h]}),e.jsxs("div",{className:"text-xs text-gray-400 mt-1 font-mono",children:[s.lat.toFixed(5),", ",s.lon.toFixed(5)]})]})})},s.public_key)},s.public_key)})]})})]})}export{V as MapView}; import{r as u,D as j,j as e,U as y,m as N}from"./index-D_aPHvl1.js";import{M as w,T as _}from"./leaflet-Kmmg5omM.js";import{C as k,P as C}from"./Popup-Sx9rYjUO.js";import{u as M}from"./hooks-DO7QgCnP.js";const i={recent:"#06b6d4",today:"#2563eb",stale:"#f59e0b",old:"#64748b"},R="#0f172a",p="#f8fafc";function E(a){if(a==null)return i.old;const l=Date.now()/1e3-a,n=3600,t=86400;return l<n?i.recent:l<t?i.today:l<3*t?i.stale:i.old}function v({contacts:a,focusedContact:r}){const l=M(),[n,t]=u.useState(!1);return u.useEffect(()=>{if(r&&r.lat!=null&&r.lon!=null){l.setView([r.lat,r.lon],12),t(!0);return}if(n)return;const c=()=>{if(a.length===0){l.setView([20,0],2),t(!0);return}if(a.length===1){l.setView([a[0].lat,a[0].lon],10),t(!0);return}const d=a.map(m=>[m.lat,m.lon]);l.fitBounds(d,{padding:[50,50],maxZoom:12}),t(!0)};"geolocation"in navigator?navigator.geolocation.getCurrentPosition(d=>{l.setView([d.coords.latitude,d.coords.longitude],8),t(!0)},()=>{c()},{timeout:5e3,maximumAge:3e5}):c()},[l,a,n,r]),null}function V({contacts:a,focusedKey:r}){const[l]=u.useState(()=>Date.now()/1e3-604800),n=u.useMemo(()=>a.filter(s=>j(s.lat,s.lon)&&(s.public_key===r||s.last_seen!=null&&s.last_seen>l)),[a,r,l]),t=u.useMemo(()=>r&&n.find(s=>s.public_key===r)||null,[r,n]),c=t!=null&&(t.last_seen==null||t.last_seen<=l),d=u.useRef({}),m=u.useCallback((s,o)=>{d.current[s]=o},[]);return u.useEffect(()=>{if(t&&d.current[t.public_key]){const s=setTimeout(()=>{var o;(o=d.current[t.public_key])==null||o.openPopup()},100);return()=>clearTimeout(s)}},[t]),e.jsxs("div",{className:"flex flex-col h-full",children:[e.jsxs("div",{className:"px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between",children:[e.jsxs("span",{children:["Showing ",n.length," contact",n.length!==1?"s":""," heard in the last 7 days",c?" plus the focused contact":""]}),e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full",style:{backgroundColor:i.recent},"aria-hidden":"true"})," ","<1h"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full",style:{backgroundColor:i.today},"aria-hidden":"true"})," ","<1d"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full",style:{backgroundColor:i.stale},"aria-hidden":"true"})," ","<3d"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full",style:{backgroundColor:i.old},"aria-hidden":"true"})," ","older"]}),e.jsxs("span",{className:"flex items-center gap-1",children:[e.jsx("span",{className:"w-3 h-3 rounded-full border-2",style:{borderColor:p,backgroundColor:i.today},"aria-hidden":"true"})," ","repeater"]})]})]}),e.jsx("div",{className:"flex-1 relative",style:{zIndex:0},role:"img","aria-label":"Map showing mesh node locations",children:e.jsxs(w,{center:[20,0],zoom:2,className:"h-full w-full",style:{background:"#1a1a2e"},children:[e.jsx(_,{attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',url:"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"}),e.jsx(v,{contacts:n,focusedContact:t}),n.map(s=>{const o=s.type===y,x=E(s.last_seen),f=s.name||s.public_key.slice(0,12),h=s.last_seen!=null?N(s.last_seen):"Never heard by this server",g=o?10:7;return e.jsx(u.Fragment,{children:e.jsx(k,{ref:b=>m(s.public_key,b),center:[s.lat,s.lon],radius:g,pathOptions:{color:o?p:R,fillColor:x,fillOpacity:.9,weight:o?3:2},children:e.jsx(C,{children:e.jsxs("div",{className:"text-sm",children:[e.jsxs("div",{className:"font-medium flex items-center gap-1",children:[o&&e.jsx("span",{title:"Repeater","aria-hidden":"true",children:"🛜"}),f]}),e.jsxs("div",{className:"text-xs text-gray-500 mt-1",children:["Last heard: ",h]}),e.jsxs("div",{className:"text-xs text-gray-400 mt-1 font-mono",children:[s.lat.toFixed(5),", ",s.lon.toFixed(5)]})]})})},s.public_key)},s.public_key)})]})})]})}export{V as MapView};
//# sourceMappingURL=MapView-B_EeV5Ur.js.map //# sourceMappingURL=MapView-C3Y0Y3CX.js.map
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{j as l}from"./index-C_33NHxn.js";import{d as m,l as u,a as f,e as d,M as x,T as y}from"./leaflet-DNHXPpW7.js";import{C as p,P as c}from"./Popup-BcLg2SsA.js";const g=m(function({positions:n,...t},o){const s=new u.Polyline(n,t);return f(s,d(o,{overlayContainer:s}))},function(n,t,o){t.positions!==o.positions&&n.setLatLngs(t.positions)});function C({neighbors:i,radioLat:n,radioLon:t,radioName:o}){const s=i.filter(e=>e.lat!=null&&e.lon!=null),r=n!=null&&t!=null&&!(n===0&&t===0);if(s.length===0&&!r)return null;const h=r?[n,t]:[s[0].lat,s[0].lon];return l.jsx("div",{className:"min-h-48 flex-1 rounded border border-border overflow-hidden",role:"img","aria-label":"Map showing repeater neighbor locations",children:l.jsxs(x,{center:h,zoom:10,className:"h-full w-full",style:{background:"#1a1a2e"},children:[l.jsx(y,{attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',url:"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"}),r&&s.map((e,a)=>l.jsx(g,{positions:[[n,t],[e.lat,e.lon]],pathOptions:{color:"#3b82f6",weight:1.5,opacity:.5,dashArray:"6 4"}},`line-${a}`)),r&&l.jsx(p,{center:[n,t],radius:8,pathOptions:{color:"#1d4ed8",fillColor:"#3b82f6",fillOpacity:1,weight:2},children:l.jsx(c,{children:l.jsx("span",{className:"text-sm font-medium",children:o||"Our Radio"})})}),s.map((e,a)=>l.jsx(p,{center:[e.lat,e.lon],radius:6,pathOptions:{color:"#000",fillColor:e.snr>=6?"#22c55e":e.snr>=0?"#eab308":"#ef4444",fillOpacity:.8,weight:1},children:l.jsx(c,{children:l.jsx("span",{className:"text-sm",children:e.name||e.pubkey_prefix})})},a))]})})}export{C as NeighborsMiniMap}; import{j as l}from"./index-D_aPHvl1.js";import{d as m,l as u,a as f,e as d,M as x,T as y}from"./leaflet-Kmmg5omM.js";import{C as p,P as c}from"./Popup-Sx9rYjUO.js";const g=m(function({positions:n,...t},o){const s=new u.Polyline(n,t);return f(s,d(o,{overlayContainer:s}))},function(n,t,o){t.positions!==o.positions&&n.setLatLngs(t.positions)});function C({neighbors:i,radioLat:n,radioLon:t,radioName:o}){const s=i.filter(e=>e.lat!=null&&e.lon!=null),r=n!=null&&t!=null&&!(n===0&&t===0);if(s.length===0&&!r)return null;const h=r?[n,t]:[s[0].lat,s[0].lon];return l.jsx("div",{className:"min-h-48 flex-1 rounded border border-border overflow-hidden",role:"img","aria-label":"Map showing repeater neighbor locations",children:l.jsxs(x,{center:h,zoom:10,className:"h-full w-full",style:{background:"#1a1a2e"},children:[l.jsx(y,{attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',url:"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"}),r&&s.map((e,a)=>l.jsx(g,{positions:[[n,t],[e.lat,e.lon]],pathOptions:{color:"#3b82f6",weight:1.5,opacity:.5,dashArray:"6 4"}},`line-${a}`)),r&&l.jsx(p,{center:[n,t],radius:8,pathOptions:{color:"#1d4ed8",fillColor:"#3b82f6",fillOpacity:1,weight:2},children:l.jsx(c,{children:l.jsx("span",{className:"text-sm font-medium",children:o||"Our Radio"})})}),s.map((e,a)=>l.jsx(p,{center:[e.lat,e.lon],radius:6,pathOptions:{color:"#000",fillColor:e.snr>=6?"#22c55e":e.snr>=0?"#eab308":"#ef4444",fillOpacity:.8,weight:1},children:l.jsx(c,{children:l.jsx("span",{className:"text-sm",children:e.name||e.pubkey_prefix})})},a))]})})}export{C as NeighborsMiniMap};
//# sourceMappingURL=NeighborsMiniMap-CsH85D0E.js.map //# sourceMappingURL=NeighborsMiniMap-DJtDAXli.js.map
@@ -1,4 +1,4 @@
import{r as x,D as l,j as i}from"./index-C_33NHxn.js";import{c as O,l as b,a as y,e as w,b as C,M as L,T as M,L as R}from"./leaflet-DNHXPpW7.js";import{u as T}from"./hooks-BmAJqTPK.js";const h=O(function({position:n,...t},o){const r=new b.Marker(n,t);return y(r,w(o,{overlayContainer:r}))},function(n,t,o){t.position!==o.position&&n.setLatLng(t.position),t.icon!=null&&t.icon!==o.icon&&n.setIcon(t.icon),t.zIndexOffset!=null&&t.zIndexOffset!==o.zIndexOffset&&n.setZIndexOffset(t.zIndexOffset),t.opacity!=null&&t.opacity!==o.opacity&&n.setOpacity(t.opacity),n.dragging!=null&&t.draggable!==o.draggable&&(t.draggable===!0?n.dragging.enable():n.dragging.disable())}),p=C(function(n,t){const o=new b.Tooltip(n,t.overlayContainer);return y(o,t)},function(n,t,{position:o},r){x.useEffect(function(){const s=t.overlayContainer;if(s==null)return;const{instance:u}=n,f=a=>{a.tooltip===u&&(o!=null&&u.setLatLng(o),u.update(),r(!0))},c=a=>{a.tooltip===u&&r(!1)};return s.on({tooltipopen:f,tooltipclose:c}),s.bindTooltip(u),function(){s.off({tooltipopen:f,tooltipclose:c}),s._map!=null&&s.unbindTooltip()}},[n,t,r,o])}),m=["#f97316","#eab308","#22c55e","#06b6d4","#ec4899","#f43f5e","#a855f7","#64748b"],E="#3b82f6",S="#8b5cf6";function g(e,n){return R.divIcon({className:"",iconSize:[24,24],iconAnchor:[12,12],html:`<div style=" import{r as x,D as l,j as i}from"./index-D_aPHvl1.js";import{c as O,l as b,a as y,e as w,b as C,M as L,T as M,L as R}from"./leaflet-Kmmg5omM.js";import{u as T}from"./hooks-DO7QgCnP.js";const h=O(function({position:n,...t},o){const r=new b.Marker(n,t);return y(r,w(o,{overlayContainer:r}))},function(n,t,o){t.position!==o.position&&n.setLatLng(t.position),t.icon!=null&&t.icon!==o.icon&&n.setIcon(t.icon),t.zIndexOffset!=null&&t.zIndexOffset!==o.zIndexOffset&&n.setZIndexOffset(t.zIndexOffset),t.opacity!=null&&t.opacity!==o.opacity&&n.setOpacity(t.opacity),n.dragging!=null&&t.draggable!==o.draggable&&(t.draggable===!0?n.dragging.enable():n.dragging.disable())}),p=C(function(n,t){const o=new b.Tooltip(n,t.overlayContainer);return y(o,t)},function(n,t,{position:o},r){x.useEffect(function(){const s=t.overlayContainer;if(s==null)return;const{instance:u}=n,f=a=>{a.tooltip===u&&(o!=null&&u.setLatLng(o),u.update(),r(!0))},c=a=>{a.tooltip===u&&r(!1)};return s.on({tooltipopen:f,tooltipclose:c}),s.bindTooltip(u),function(){s.off({tooltipopen:f,tooltipclose:c}),s._map!=null&&s.unbindTooltip()}},[n,t,r,o])}),m=["#f97316","#eab308","#22c55e","#06b6d4","#ec4899","#f43f5e","#a855f7","#64748b"],E="#3b82f6",S="#8b5cf6";function g(e,n){return R.divIcon({className:"",iconSize:[24,24],iconAnchor:[12,12],html:`<div style="
width:24px;height:24px;border-radius:50%; width:24px;height:24px;border-radius:50%;
background:${n};color:#fff; background:${n};color:#fff;
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
@@ -6,4 +6,4 @@ import{r as x,D as l,j as i}from"./index-C_33NHxn.js";import{c as O,l as b,a as
border:2px solid rgba(255,255,255,0.8); border:2px solid rgba(255,255,255,0.8);
box-shadow:0 1px 4px rgba(0,0,0,0.4); box-shadow:0 1px 4px rgba(0,0,0,0.4);
">${e}</div>`})}function z(e){return m[e%m.length]}function N(e){const n=[];l(e.sender.lat,e.sender.lon)&&n.push([e.sender.lat,e.sender.lon]);for(const t of e.hops)for(const o of t.matches)l(o.lat,o.lon)&&n.push([o.lat,o.lon]);return l(e.receiver.lat,e.receiver.lon)&&n.push([e.receiver.lat,e.receiver.lon]),n}function I({points:e}){const n=T(),t=x.useRef(!1);return x.useEffect(()=>{t.current||e.length===0||(t.current=!0,e.length===1?n.setView(e[0],12):n.fitBounds(e,{padding:[30,30],maxZoom:14}))},[n,e]),null}function k({resolved:e,senderInfo:n}){const t=N(e),o=t.length>0;let r=2,d=0;l(e.sender.lat,e.sender.lon)&&d++,l(e.receiver.lat,e.receiver.lon)&&d++;for(const f of e.hops)f.matches.length===0?r++:(r+=f.matches.length,d+=f.matches.filter(c=>l(c.lat,c.lon)).length);const s=o&&d<r;if(!o)return i.jsx("div",{className:"h-14 rounded border border-border bg-muted/30 flex items-center justify-center text-sm text-muted-foreground",children:"No nodes in this route have GPS coordinates"});const u=t[0];return i.jsxs("div",{children:[i.jsx("div",{className:"rounded border border-border overflow-hidden",role:"img","aria-label":"Map showing message route between nodes",style:{height:220},children:i.jsxs(L,{center:u,zoom:10,className:"h-full w-full",style:{background:"#1a1a2e"},children:[i.jsx(M,{attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',url:"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"}),i.jsx(I,{points:t}),l(e.sender.lat,e.sender.lon)&&i.jsx(h,{position:[e.sender.lat,e.sender.lon],icon:g("S",E),children:i.jsx(p,{direction:"top",offset:[0,-14],children:n.name||"Sender"})}),e.hops.map((f,c)=>f.matches.filter(a=>l(a.lat,a.lon)).map((a,j)=>i.jsx(h,{position:[a.lat,a.lon],icon:g(String(c+1),z(c)),children:i.jsx(p,{direction:"top",offset:[0,-14],children:a.name||a.public_key.slice(0,12)})},`hop-${c}-${j}`))),l(e.receiver.lat,e.receiver.lon)&&i.jsx(h,{position:[e.receiver.lat,e.receiver.lon],icon:g("R",S),children:i.jsx(p,{direction:"top",offset:[0,-14],children:e.receiver.name||"Receiver"})})]})}),s&&i.jsx("p",{className:"text-xs text-muted-foreground mt-1",children:"Some nodes in this route have no GPS and are not shown"})]})}export{k as PathRouteMap}; ">${e}</div>`})}function z(e){return m[e%m.length]}function N(e){const n=[];l(e.sender.lat,e.sender.lon)&&n.push([e.sender.lat,e.sender.lon]);for(const t of e.hops)for(const o of t.matches)l(o.lat,o.lon)&&n.push([o.lat,o.lon]);return l(e.receiver.lat,e.receiver.lon)&&n.push([e.receiver.lat,e.receiver.lon]),n}function I({points:e}){const n=T(),t=x.useRef(!1);return x.useEffect(()=>{t.current||e.length===0||(t.current=!0,e.length===1?n.setView(e[0],12):n.fitBounds(e,{padding:[30,30],maxZoom:14}))},[n,e]),null}function k({resolved:e,senderInfo:n}){const t=N(e),o=t.length>0;let r=2,d=0;l(e.sender.lat,e.sender.lon)&&d++,l(e.receiver.lat,e.receiver.lon)&&d++;for(const f of e.hops)f.matches.length===0?r++:(r+=f.matches.length,d+=f.matches.filter(c=>l(c.lat,c.lon)).length);const s=o&&d<r;if(!o)return i.jsx("div",{className:"h-14 rounded border border-border bg-muted/30 flex items-center justify-center text-sm text-muted-foreground",children:"No nodes in this route have GPS coordinates"});const u=t[0];return i.jsxs("div",{children:[i.jsx("div",{className:"rounded border border-border overflow-hidden",role:"img","aria-label":"Map showing message route between nodes",style:{height:220},children:i.jsxs(L,{center:u,zoom:10,className:"h-full w-full",style:{background:"#1a1a2e"},children:[i.jsx(M,{attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',url:"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"}),i.jsx(I,{points:t}),l(e.sender.lat,e.sender.lon)&&i.jsx(h,{position:[e.sender.lat,e.sender.lon],icon:g("S",E),children:i.jsx(p,{direction:"top",offset:[0,-14],children:n.name||"Sender"})}),e.hops.map((f,c)=>f.matches.filter(a=>l(a.lat,a.lon)).map((a,j)=>i.jsx(h,{position:[a.lat,a.lon],icon:g(String(c+1),z(c)),children:i.jsx(p,{direction:"top",offset:[0,-14],children:a.name||a.public_key.slice(0,12)})},`hop-${c}-${j}`))),l(e.receiver.lat,e.receiver.lon)&&i.jsx(h,{position:[e.receiver.lat,e.receiver.lon],icon:g("R",S),children:i.jsx(p,{direction:"top",offset:[0,-14],children:e.receiver.name||"Receiver"})})]})}),s&&i.jsx("p",{className:"text-xs text-muted-foreground mt-1",children:"Some nodes in this route have no GPS and are not shown"})]})}export{k as PathRouteMap};
//# sourceMappingURL=PathRouteMap-DbzDNGyG.js.map //# sourceMappingURL=PathRouteMap-CJMPJrMX.js.map
@@ -1,2 +1,2 @@
import{d,l as f,a as s,e as C,b as m}from"./leaflet-DNHXPpW7.js";import{r as P}from"./index-C_33NHxn.js";function v(o,n,e){n.center!==e.center&&o.setLatLng(n.center),n.radius!=null&&n.radius!==e.radius&&o.setRadius(n.radius)}const b=d(function({center:n,children:e,...r},p){const t=new f.CircleMarker(n,r);return s(t,C(p,{overlayContainer:t}))},v),k=m(function(n,e){const r=new f.Popup(n,e.overlayContainer);return s(r,e)},function(n,e,{position:r},p){P.useEffect(function(){const{instance:a}=n;function i(u){u.popup===a&&(a.update(),p(!0))}function c(u){u.popup===a&&p(!1)}return e.map.on({popupopen:i,popupclose:c}),e.overlayContainer==null?(r!=null&&a.setLatLng(r),a.openOn(e.map)):e.overlayContainer.bindPopup(a),function(){var l;e.map.off({popupopen:i,popupclose:c}),(l=e.overlayContainer)==null||l.unbindPopup(),e.map.removeLayer(a)}},[n,e,p,r])});export{b as C,k as P}; import{d,l as f,a as s,e as C,b as m}from"./leaflet-Kmmg5omM.js";import{r as P}from"./index-D_aPHvl1.js";function v(o,n,e){n.center!==e.center&&o.setLatLng(n.center),n.radius!=null&&n.radius!==e.radius&&o.setRadius(n.radius)}const b=d(function({center:n,children:e,...r},p){const t=new f.CircleMarker(n,r);return s(t,C(p,{overlayContainer:t}))},v),k=m(function(n,e){const r=new f.Popup(n,e.overlayContainer);return s(r,e)},function(n,e,{position:r},p){P.useEffect(function(){const{instance:a}=n;function i(u){u.popup===a&&(a.update(),p(!0))}function c(u){u.popup===a&&p(!1)}return e.map.on({popupopen:i,popupclose:c}),e.overlayContainer==null?(r!=null&&a.setLatLng(r),a.openOn(e.map)):e.overlayContainer.bindPopup(a),function(){var l;e.map.off({popupopen:i,popupclose:c}),(l=e.overlayContainer)==null||l.unbindPopup(),e.map.removeLayer(a)}},[n,e,p,r])});export{b as C,k as P};
//# sourceMappingURL=Popup-BcLg2SsA.js.map //# sourceMappingURL=Popup-Sx9rYjUO.js.map
@@ -1 +1 @@
{"version":3,"file":"Popup-BcLg2SsA.js","sources":["../../node_modules/@react-leaflet/core/lib/circle.js","../../node_modules/react-leaflet/lib/CircleMarker.js","../../node_modules/react-leaflet/lib/Popup.js"],"sourcesContent":["export function updateCircle(layer, props, prevProps) {\n if (props.center !== prevProps.center) {\n layer.setLatLng(props.center);\n }\n if (props.radius != null && props.radius !== prevProps.radius) {\n layer.setRadius(props.radius);\n }\n}\n","import { createElementObject, createPathComponent, extendContext, updateCircle } from '@react-leaflet/core';\nimport { CircleMarker as LeafletCircleMarker } from 'leaflet';\nexport const CircleMarker = createPathComponent(function createCircleMarker({ center , children: _c , ...options }, ctx) {\n const marker = new LeafletCircleMarker(center, options);\n return createElementObject(marker, extendContext(ctx, {\n overlayContainer: marker\n }));\n}, updateCircle);\n","import { createElementObject, createOverlayComponent } from '@react-leaflet/core';\nimport { Popup as LeafletPopup } from 'leaflet';\nimport { useEffect } from 'react';\nexport const Popup = createOverlayComponent(function createPopup(props, context) {\n const popup = new LeafletPopup(props, context.overlayContainer);\n return createElementObject(popup, context);\n}, function usePopupLifecycle(element, context, { position }, setOpen) {\n useEffect(function addPopup() {\n const { instance } = element;\n function onPopupOpen(event) {\n if (event.popup === instance) {\n instance.update();\n setOpen(true);\n }\n }\n function onPopupClose(event) {\n if (event.popup === instance) {\n setOpen(false);\n }\n }\n context.map.on({\n popupopen: onPopupOpen,\n popupclose: onPopupClose\n });\n if (context.overlayContainer == null) {\n // Attach to a Map\n if (position != null) {\n instance.setLatLng(position);\n }\n instance.openOn(context.map);\n } else {\n // Attach to container component\n context.overlayContainer.bindPopup(instance);\n }\n return function removePopup() {\n context.map.off({\n popupopen: onPopupOpen,\n popupclose: onPopupClose\n });\n context.overlayContainer?.unbindPopup();\n context.map.removeLayer(instance);\n };\n }, [\n element,\n context,\n setOpen,\n position\n ]);\n});\n"],"names":["updateCircle","layer","props","prevProps","CircleMarker","createPathComponent","center","_c","options","ctx","marker","LeafletCircleMarker","createElementObject","extendContext","Popup","createOverlayComponent","context","popup","LeafletPopup","element","position","setOpen","useEffect","instance","onPopupOpen","event","onPopupClose","_a"],"mappings":"yGAAO,SAASA,EAAaC,EAAOC,EAAOC,EAAW,CAC9CD,EAAM,SAAWC,EAAU,QAC3BF,EAAM,UAAUC,EAAM,MAAM,EAE5BA,EAAM,QAAU,MAAQA,EAAM,SAAWC,EAAU,QACnDF,EAAM,UAAUC,EAAM,MAAM,CAEpC,CCLY,MAACE,EAAeC,EAAoB,SAA4B,CAAE,OAAAC,EAAS,SAAUC,EAAK,GAAGC,CAAO,EAAIC,EAAK,CACrH,MAAMC,EAAS,IAAIC,eAAoBL,EAAQE,CAAO,EACtD,OAAOI,EAAoBF,EAAQG,EAAcJ,EAAK,CAClD,iBAAkBC,CAC1B,CAAK,CAAC,CACN,EAAGV,CAAY,ECJFc,EAAQC,EAAuB,SAAqBb,EAAOc,EAAS,CAC7E,MAAMC,EAAQ,IAAIC,EAAAA,MAAahB,EAAOc,EAAQ,gBAAgB,EAC9D,OAAOJ,EAAoBK,EAAOD,CAAO,CAC7C,EAAG,SAA2BG,EAASH,EAAS,CAAE,SAAAI,CAAQ,EAAKC,EAAS,CACpEC,EAAAA,UAAU,UAAoB,CAC1B,KAAM,CAAE,SAAAC,CAAQ,EAAMJ,EACtB,SAASK,EAAYC,EAAO,CACpBA,EAAM,QAAUF,IAChBA,EAAS,OAAM,EACfF,EAAQ,EAAI,EAEpB,CACA,SAASK,EAAaD,EAAO,CACrBA,EAAM,QAAUF,GAChBF,EAAQ,EAAK,CAErB,CACA,OAAAL,EAAQ,IAAI,GAAG,CACX,UAAWQ,EACX,WAAYE,CACxB,CAAS,EACGV,EAAQ,kBAAoB,MAExBI,GAAY,MACZG,EAAS,UAAUH,CAAQ,EAE/BG,EAAS,OAAOP,EAAQ,GAAG,GAG3BA,EAAQ,iBAAiB,UAAUO,CAAQ,EAExC,UAAuB,OAC1BP,EAAQ,IAAI,IAAI,CACZ,UAAWQ,EACX,WAAYE,CAC5B,CAAa,GACDC,EAAAX,EAAQ,mBAAR,MAAAW,EAA0B,cAC1BX,EAAQ,IAAI,YAAYO,CAAQ,CACpC,CACJ,EAAG,CACCJ,EACAH,EACAK,EACAD,CACR,CAAK,CACL,CAAC","x_google_ignoreList":[0,1,2]} {"version":3,"file":"Popup-Sx9rYjUO.js","sources":["../../node_modules/@react-leaflet/core/lib/circle.js","../../node_modules/react-leaflet/lib/CircleMarker.js","../../node_modules/react-leaflet/lib/Popup.js"],"sourcesContent":["export function updateCircle(layer, props, prevProps) {\n if (props.center !== prevProps.center) {\n layer.setLatLng(props.center);\n }\n if (props.radius != null && props.radius !== prevProps.radius) {\n layer.setRadius(props.radius);\n }\n}\n","import { createElementObject, createPathComponent, extendContext, updateCircle } from '@react-leaflet/core';\nimport { CircleMarker as LeafletCircleMarker } from 'leaflet';\nexport const CircleMarker = createPathComponent(function createCircleMarker({ center , children: _c , ...options }, ctx) {\n const marker = new LeafletCircleMarker(center, options);\n return createElementObject(marker, extendContext(ctx, {\n overlayContainer: marker\n }));\n}, updateCircle);\n","import { createElementObject, createOverlayComponent } from '@react-leaflet/core';\nimport { Popup as LeafletPopup } from 'leaflet';\nimport { useEffect } from 'react';\nexport const Popup = createOverlayComponent(function createPopup(props, context) {\n const popup = new LeafletPopup(props, context.overlayContainer);\n return createElementObject(popup, context);\n}, function usePopupLifecycle(element, context, { position }, setOpen) {\n useEffect(function addPopup() {\n const { instance } = element;\n function onPopupOpen(event) {\n if (event.popup === instance) {\n instance.update();\n setOpen(true);\n }\n }\n function onPopupClose(event) {\n if (event.popup === instance) {\n setOpen(false);\n }\n }\n context.map.on({\n popupopen: onPopupOpen,\n popupclose: onPopupClose\n });\n if (context.overlayContainer == null) {\n // Attach to a Map\n if (position != null) {\n instance.setLatLng(position);\n }\n instance.openOn(context.map);\n } else {\n // Attach to container component\n context.overlayContainer.bindPopup(instance);\n }\n return function removePopup() {\n context.map.off({\n popupopen: onPopupOpen,\n popupclose: onPopupClose\n });\n context.overlayContainer?.unbindPopup();\n context.map.removeLayer(instance);\n };\n }, [\n element,\n context,\n setOpen,\n position\n ]);\n});\n"],"names":["updateCircle","layer","props","prevProps","CircleMarker","createPathComponent","center","_c","options","ctx","marker","LeafletCircleMarker","createElementObject","extendContext","Popup","createOverlayComponent","context","popup","LeafletPopup","element","position","setOpen","useEffect","instance","onPopupOpen","event","onPopupClose","_a"],"mappings":"yGAAO,SAASA,EAAaC,EAAOC,EAAOC,EAAW,CAC9CD,EAAM,SAAWC,EAAU,QAC3BF,EAAM,UAAUC,EAAM,MAAM,EAE5BA,EAAM,QAAU,MAAQA,EAAM,SAAWC,EAAU,QACnDF,EAAM,UAAUC,EAAM,MAAM,CAEpC,CCLY,MAACE,EAAeC,EAAoB,SAA4B,CAAE,OAAAC,EAAS,SAAUC,EAAK,GAAGC,CAAO,EAAIC,EAAK,CACrH,MAAMC,EAAS,IAAIC,eAAoBL,EAAQE,CAAO,EACtD,OAAOI,EAAoBF,EAAQG,EAAcJ,EAAK,CAClD,iBAAkBC,CAC1B,CAAK,CAAC,CACN,EAAGV,CAAY,ECJFc,EAAQC,EAAuB,SAAqBb,EAAOc,EAAS,CAC7E,MAAMC,EAAQ,IAAIC,EAAAA,MAAahB,EAAOc,EAAQ,gBAAgB,EAC9D,OAAOJ,EAAoBK,EAAOD,CAAO,CAC7C,EAAG,SAA2BG,EAASH,EAAS,CAAE,SAAAI,CAAQ,EAAKC,EAAS,CACpEC,EAAAA,UAAU,UAAoB,CAC1B,KAAM,CAAE,SAAAC,CAAQ,EAAMJ,EACtB,SAASK,EAAYC,EAAO,CACpBA,EAAM,QAAUF,IAChBA,EAAS,OAAM,EACfF,EAAQ,EAAI,EAEpB,CACA,SAASK,EAAaD,EAAO,CACrBA,EAAM,QAAUF,GAChBF,EAAQ,EAAK,CAErB,CACA,OAAAL,EAAQ,IAAI,GAAG,CACX,UAAWQ,EACX,WAAYE,CACxB,CAAS,EACGV,EAAQ,kBAAoB,MAExBI,GAAY,MACZG,EAAS,UAAUH,CAAQ,EAE/BG,EAAS,OAAOP,EAAQ,GAAG,GAG3BA,EAAQ,iBAAiB,UAAUO,CAAQ,EAExC,UAAuB,OAC1BP,EAAQ,IAAI,IAAI,CACZ,UAAWQ,EACX,WAAYE,CAC5B,CAAa,GACDC,EAAAX,EAAQ,mBAAR,MAAAW,EAA0B,cAC1BX,EAAQ,IAAI,YAAYO,CAAQ,CACpC,CACJ,EAAG,CACCJ,EACAH,EACAK,EACAD,CACR,CAAK,CACL,CAAC","x_google_ignoreList":[0,1,2]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
import"./index-C_33NHxn.js";import{u as t}from"./leaflet-DNHXPpW7.js";function r(){return t().map}export{r as u};
//# sourceMappingURL=hooks-BmAJqTPK.js.map
@@ -0,0 +1,2 @@
import"./index-D_aPHvl1.js";import{u as t}from"./leaflet-Kmmg5omM.js";function r(){return t().map}export{r as u};
//# sourceMappingURL=hooks-DO7QgCnP.js.map
@@ -1 +1 @@
{"version":3,"file":"hooks-BmAJqTPK.js","sources":["../../node_modules/react-leaflet/lib/hooks.js"],"sourcesContent":["import { useLeafletContext } from '@react-leaflet/core';\nimport { useEffect } from 'react';\nexport function useMap() {\n return useLeafletContext().map;\n}\nexport function useMapEvent(type, handler) {\n const map = useMap();\n useEffect(function addMapEventHandler() {\n // @ts-ignore event type\n map.on(type, handler);\n return function removeMapEventHandler() {\n // @ts-ignore event type\n map.off(type, handler);\n };\n }, [\n map,\n type,\n handler\n ]);\n return map;\n}\nexport function useMapEvents(handlers) {\n const map = useMap();\n useEffect(function addMapEventHandlers() {\n map.on(handlers);\n return function removeMapEventHandlers() {\n map.off(handlers);\n };\n }, [\n map,\n handlers\n ]);\n return map;\n}\n"],"names":["useMap","useLeafletContext"],"mappings":"sEAEO,SAASA,GAAS,CACrB,OAAOC,EAAiB,EAAG,GAC/B","x_google_ignoreList":[0]} {"version":3,"file":"hooks-DO7QgCnP.js","sources":["../../node_modules/react-leaflet/lib/hooks.js"],"sourcesContent":["import { useLeafletContext } from '@react-leaflet/core';\nimport { useEffect } from 'react';\nexport function useMap() {\n return useLeafletContext().map;\n}\nexport function useMapEvent(type, handler) {\n const map = useMap();\n useEffect(function addMapEventHandler() {\n // @ts-ignore event type\n map.on(type, handler);\n return function removeMapEventHandler() {\n // @ts-ignore event type\n map.off(type, handler);\n };\n }, [\n map,\n type,\n handler\n ]);\n return map;\n}\nexport function useMapEvents(handlers) {\n const map = useMap();\n useEffect(function addMapEventHandlers() {\n map.on(handlers);\n return function removeMapEventHandlers() {\n map.off(handlers);\n };\n }, [\n map,\n handlers\n ]);\n return map;\n}\n"],"names":["useMap","useLeafletContext"],"mappings":"sEAEO,SAASA,GAAS,CACrB,OAAOC,EAAiB,EAAG,GAC/B","x_google_ignoreList":[0]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{r as s,j as l,Q as f,l as u}from"./index-C_33NHxn.js";var N=["a","button","div","form","h2","h3","img","input","label","li","nav","ol","p","select","span","svg","ul"],h=N.reduce((a,r)=>{const t=f(`Primitive.${r}`),o=s.forwardRef((i,e)=>{const{asChild:p,...n}=i,m=p?t:r;return typeof window<"u"&&(window[Symbol.for("radix-ui")]=!0),l.jsx(m,{...n,ref:e})});return o.displayName=`Primitive.${r}`,{...a,[r]:o}},{}),x="Separator",c="horizontal",w=["horizontal","vertical"],d=s.forwardRef((a,r)=>{const{decorative:t,orientation:o=c,...i}=a,e=S(o)?o:c,n=t?{role:"none"}:{"aria-orientation":e==="vertical"?e:void 0,role:"separator"};return l.jsx(h.div,{"data-orientation":e,...n,...i,ref:r})});d.displayName=x;function S(a){return w.includes(a)}var v=d;const O=s.forwardRef(({className:a,orientation:r="horizontal",decorative:t=!0,...o},i)=>l.jsx(v,{ref:i,decorative:t,orientation:r,className:u("shrink-0 bg-border",r==="horizontal"?"h-[1px] w-full":"h-full w-[1px]",a),...o}));O.displayName=v.displayName;export{O as S}; import{r as s,j as l,Q as f,l as u}from"./index-D_aPHvl1.js";var N=["a","button","div","form","h2","h3","img","input","label","li","nav","ol","p","select","span","svg","ul"],h=N.reduce((a,r)=>{const t=f(`Primitive.${r}`),o=s.forwardRef((i,e)=>{const{asChild:p,...n}=i,m=p?t:r;return typeof window<"u"&&(window[Symbol.for("radix-ui")]=!0),l.jsx(m,{...n,ref:e})});return o.displayName=`Primitive.${r}`,{...a,[r]:o}},{}),x="Separator",c="horizontal",w=["horizontal","vertical"],d=s.forwardRef((a,r)=>{const{decorative:t,orientation:o=c,...i}=a,e=S(o)?o:c,n=t?{role:"none"}:{"aria-orientation":e==="vertical"?e:void 0,role:"separator"};return l.jsx(h.div,{"data-orientation":e,...n,...i,ref:r})});d.displayName=x;function S(a){return w.includes(a)}var v=d;const O=s.forwardRef(({className:a,orientation:r="horizontal",decorative:t=!0,...o},i)=>l.jsx(v,{ref:i,decorative:t,orientation:r,className:u("shrink-0 bg-border",r==="horizontal"?"h-[1px] w-full":"h-full w-[1px]",a),...o}));O.displayName=v.displayName;export{O as S};
//# sourceMappingURL=separator-BtL4plOj.js.map //# sourceMappingURL=separator-DL1oR9lA.js.map
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -50,7 +50,7 @@
undecryptedCount: fetchJsonOrThrow('/api/packets/undecrypted/count'), undecryptedCount: fetchJsonOrThrow('/api/packets/undecrypted/count'),
}; };
</script> </script>
<script type="module" crossorigin src="/assets/index-C_33NHxn.js"></script> <script type="module" crossorigin src="/assets/index-D_aPHvl1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cx5ENk0V.css"> <link rel="stylesheet" crossorigin href="/assets/index-Cx5ENk0V.css">
</head> </head>
<body> <body>
@@ -333,6 +333,35 @@ export function SettingsRadioSection({
? `Connection paused${health?.connection_info ? ` (${health.connection_info})` : ''}` ? `Connection paused${health?.connection_info ? ` (${health.connection_info})` : ''}`
: 'Not connected'; : 'Not connected';
const deviceInfoLabel = useMemo(() => {
const info = health?.radio_device_info;
if (!info) {
return null;
}
const model = info.model?.trim() || null;
const firmwareParts = [info.firmware_build?.trim(), info.firmware_version?.trim()].filter(
(value): value is string => Boolean(value)
);
const capacityParts = [
typeof info.max_contacts === 'number' ? `${info.max_contacts} contacts` : null,
typeof info.max_channels === 'number' ? `${info.max_channels} channels` : null,
].filter((value): value is string => value !== null);
if (!model && firmwareParts.length === 0 && capacityParts.length === 0) {
return null;
}
let label = model ?? 'Radio';
if (firmwareParts.length > 0) {
label += ` running ${firmwareParts.join('/')}`;
}
if (capacityParts.length > 0) {
label += ` (max: ${capacityParts.join(', ')})`;
}
return label;
}, [health?.radio_device_info]);
const handleConnectionAction = async () => { const handleConnectionAction = async () => {
setConnectionBusy(true); setConnectionBusy(true);
try { try {
@@ -377,6 +406,7 @@ export function SettingsRadioSection({
{connectionStatusLabel} {connectionStatusLabel}
</span> </span>
</div> </div>
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
+20
View File
@@ -205,6 +205,26 @@ describe('SettingsModal', () => {
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument(); expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
}); });
it('shows cached radio firmware and capacity info under the connection status', () => {
renderModal({
health: {
...baseHealth,
radio_device_info: {
model: 'T-Echo',
firmware_build: '2025-02-01',
firmware_version: '1.2.3',
max_contacts: 350,
max_channels: 64,
},
},
});
openRadioSection();
expect(
screen.getByText('T-Echo running 2025-02-01/1.2.3 (max: 350 contacts, 64 channels)')
).toBeInTheDocument();
});
it('shows reconnect action when radio connection is paused', () => { it('shows reconnect action when radio connection is paused', () => {
renderModal({ renderModal({
health: { ...baseHealth, radio_state: 'paused' }, health: { ...baseHealth, radio_state: 'paused' },
+7
View File
@@ -57,6 +57,13 @@ export interface HealthStatus {
radio_initializing: boolean; radio_initializing: boolean;
radio_state?: 'connected' | 'initializing' | 'connecting' | 'disconnected' | 'paused'; radio_state?: 'connected' | 'initializing' | 'connecting' | 'disconnected' | 'paused';
connection_info: string | null; connection_info: string | null;
radio_device_info?: {
model: string | null;
firmware_build: string | null;
firmware_version: string | null;
max_contacts: number | null;
max_channels: number | null;
} | null;
database_size_mb: number; database_size_mb: number;
oldest_undecrypted_timestamp: number | null; oldest_undecrypted_timestamp: number | null;
fanout_statuses: Record<string, FanoutStatusEntry>; fanout_statuses: Record<string, FanoutStatusEntry>;
+30
View File
@@ -78,6 +78,11 @@ class TestHealthEndpoint:
with patch("app.routers.health.radio_manager") as mock_rm: with patch("app.routers.health.radio_manager") as mock_rm:
mock_rm.is_connected = True mock_rm.is_connected = True
mock_rm.connection_info = "Serial: /dev/ttyUSB0" mock_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = True
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = False
from app.main import app from app.main import app
@@ -97,6 +102,11 @@ class TestHealthEndpoint:
with patch("app.routers.health.radio_manager") as mock_rm: with patch("app.routers.health.radio_manager") as mock_rm:
mock_rm.is_connected = False mock_rm.is_connected = False
mock_rm.connection_info = None mock_rm.connection_info = None
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = False
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = False
from app.main import app from app.main import app
@@ -1118,6 +1128,11 @@ class TestHealthEndpointDatabaseSize:
): ):
mock_rm.is_connected = True mock_rm.is_connected = True
mock_rm.connection_info = "Serial: /dev/ttyUSB0" mock_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = True
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = False
mock_getsize.return_value = 10 * 1024 * 1024 # 10 MB mock_getsize.return_value = 10 * 1024 * 1024 # 10 MB
from app.main import app from app.main import app
@@ -1148,6 +1163,11 @@ class TestHealthEndpointOldestUndecrypted:
): ):
mock_rm.is_connected = True mock_rm.is_connected = True
mock_rm.connection_info = "Serial: /dev/ttyUSB0" mock_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = True
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = False
mock_getsize.return_value = 5 * 1024 * 1024 # 5 MB mock_getsize.return_value = 5 * 1024 * 1024 # 5 MB
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=1700000000) mock_repo.get_oldest_undecrypted = AsyncMock(return_value=1700000000)
@@ -1175,6 +1195,11 @@ class TestHealthEndpointOldestUndecrypted:
): ):
mock_rm.is_connected = True mock_rm.is_connected = True
mock_rm.connection_info = "Serial: /dev/ttyUSB0" mock_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = True
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = False
mock_getsize.return_value = 1 * 1024 * 1024 # 1 MB mock_getsize.return_value = 1 * 1024 * 1024 # 1 MB
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None) mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
@@ -1202,6 +1227,11 @@ class TestHealthEndpointOldestUndecrypted:
): ):
mock_rm.is_connected = False mock_rm.is_connected = False
mock_rm.connection_info = None mock_rm.connection_info = None
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = False
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = False
mock_getsize.side_effect = OSError("File not found") mock_getsize.side_effect = OSError("File not found")
mock_repo.get_oldest_undecrypted = AsyncMock(side_effect=RuntimeError("No DB")) mock_repo.get_oldest_undecrypted = AsyncMock(side_effect=RuntimeError("No DB"))
+29
View File
@@ -59,6 +59,35 @@ class TestHealthFanoutStatus:
assert data["radio_state"] == "connected" assert data["radio_state"] == "connected"
assert data["connection_info"] == "Serial: /dev/ttyUSB0" assert data["connection_info"] == "Serial: /dev/ttyUSB0"
@pytest.mark.asyncio
async def test_health_includes_cached_radio_device_info(self, test_db):
"""Health includes device metadata captured during post-connect setup."""
with (
patch(
"app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None
),
patch("app.routers.health.radio_manager") as mock_rm,
):
mock_rm.is_setup_in_progress = False
mock_rm.is_setup_complete = True
mock_rm.connection_desired = True
mock_rm.is_reconnecting = False
mock_rm.device_info_loaded = True
mock_rm.device_model = "T-Echo"
mock_rm.firmware_build = "2025-02-01"
mock_rm.firmware_version = "1.2.3"
mock_rm.max_contacts = 350
mock_rm.max_channels = 64
data = await build_health_data(True, "Serial: /dev/ttyUSB0")
assert data["radio_device_info"] == {
"model": "T-Echo",
"firmware_build": "2025-02-01",
"firmware_version": "1.2.3",
"max_contacts": 350,
"max_channels": 64,
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_health_status_degraded_when_disconnected(self, test_db): async def test_health_status_degraded_when_disconnected(self, test_db):
"""Health status is 'degraded' when radio is disconnected.""" """Health status is 'degraded' when radio is disconnected."""
+10
View File
@@ -476,6 +476,11 @@ class TestManualDisconnectCleanup:
mock_mc.connection_manager = connection_manager mock_mc.connection_manager = connection_manager
rm._meshcore = mock_mc rm._meshcore = mock_mc
rm._setup_complete = True rm._setup_complete = True
rm.device_info_loaded = True
rm.max_contacts = 350
rm.device_model = "T-Echo"
rm.firmware_build = "2025-02-01"
rm.firmware_version = "1.2.3"
rm.max_channels = 8 rm.max_channels = 8
rm.path_hash_mode = 2 rm.path_hash_mode = 2
rm.path_hash_mode_supported = True rm.path_hash_mode_supported = True
@@ -491,6 +496,11 @@ class TestManualDisconnectCleanup:
assert reconnect_task is not None and reconnect_task.cancelled() assert reconnect_task is not None and reconnect_task.cancelled()
assert rm.meshcore is None assert rm.meshcore is None
assert rm.is_setup_complete is False assert rm.is_setup_complete is False
assert rm.device_info_loaded is False
assert rm.max_contacts is None
assert rm.device_model is None
assert rm.firmware_build is None
assert rm.firmware_version is None
assert rm.max_channels == 40 assert rm.max_channels == 40
assert rm.path_hash_mode == 0 assert rm.path_hash_mode == 0
assert rm.path_hash_mode_supported is False assert rm.path_hash_mode_supported is False
+68
View File
@@ -112,6 +112,11 @@ class TestRunPostConnectSetup:
radio_manager._setup_lock = None radio_manager._setup_lock = None
radio_manager._setup_in_progress = False radio_manager._setup_in_progress = False
radio_manager._setup_complete = False radio_manager._setup_complete = False
radio_manager.device_info_loaded = False
radio_manager.max_contacts = None
radio_manager.device_model = None
radio_manager.firmware_build = None
radio_manager.firmware_version = None
radio_manager.max_channels = 40 radio_manager.max_channels = 40
radio_manager.path_hash_mode = 0 radio_manager.path_hash_mode = 0
radio_manager.path_hash_mode_supported = False radio_manager.path_hash_mode_supported = False
@@ -145,3 +150,66 @@ class TestRunPostConnectSetup:
replacement_mc.start_auto_message_fetching.assert_awaited_once() replacement_mc.start_auto_message_fetching.assert_awaited_once()
initial_mc.start_auto_message_fetching.assert_not_called() initial_mc.start_auto_message_fetching.assert_not_called()
assert radio_manager.max_channels == 8 assert radio_manager.max_channels == 8
@pytest.mark.asyncio
async def test_caches_device_info_metadata_from_device_query(self):
mc = MagicMock()
mc.commands.send_device_query = AsyncMock(
return_value=MagicMock(
payload={
"fw ver": 10,
"max_contacts": 350,
"max_channels": 64,
"model": "T-Echo",
"fw_build": "2025-02-01",
"ver": "1.2.3",
"path_hash_mode": 2,
}
)
)
mc.commands.set_flood_scope = AsyncMock(return_value=None)
mc._reader = MagicMock()
mc._reader.handle_rx = AsyncMock()
mc.start_auto_message_fetching = AsyncMock()
radio_manager = MagicMock()
radio_manager.meshcore = mc
radio_manager._setup_lock = None
radio_manager._setup_in_progress = False
radio_manager._setup_complete = False
radio_manager.device_info_loaded = False
radio_manager.max_contacts = None
radio_manager.device_model = None
radio_manager.firmware_build = None
radio_manager.firmware_version = None
radio_manager.max_channels = 40
radio_manager.path_hash_mode = 0
radio_manager.path_hash_mode_supported = False
radio_manager._acquire_operation_lock = AsyncMock()
radio_manager._release_operation_lock = MagicMock()
with (
patch("app.event_handlers.register_event_handlers"),
patch("app.keystore.export_and_store_private_key", new=AsyncMock()),
patch("app.radio_sync.sync_radio_time", new=AsyncMock()),
patch(
"app.repository.AppSettingsRepository.get",
new=AsyncMock(return_value=MagicMock(flood_scope=None)),
),
patch("app.radio_sync.sync_and_offload_all", new=AsyncMock(return_value={"synced": 0})),
patch("app.radio_sync.send_advertisement", new=AsyncMock(return_value=False)),
patch("app.radio_sync.drain_pending_messages", new=AsyncMock(return_value=0)),
patch("app.radio_sync.start_periodic_sync"),
patch("app.radio_sync.start_periodic_advert"),
patch("app.radio_sync.start_message_polling"),
):
await run_post_connect_setup(radio_manager)
assert radio_manager.device_info_loaded is True
assert radio_manager.max_contacts == 350
assert radio_manager.max_channels == 64
assert radio_manager.device_model == "T-Echo"
assert radio_manager.firmware_build == "2025-02-01"
assert radio_manager.firmware_version == "1.2.3"
assert radio_manager.path_hash_mode == 2
assert radio_manager.path_hash_mode_supported is True
+25
View File
@@ -41,6 +41,11 @@ class TestWebSocketEndpoint:
mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0" mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_health_rm.is_connected = True mock_health_rm.is_connected = True
mock_health_rm.connection_info = "Serial: /dev/ttyUSB0" mock_health_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_health_rm.is_setup_in_progress = False
mock_health_rm.is_setup_complete = True
mock_health_rm.connection_desired = True
mock_health_rm.is_reconnecting = False
mock_health_rm.device_info_loaded = False
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None) mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
mock_settings.database_path = "/tmp/test.db" mock_settings.database_path = "/tmp/test.db"
mock_settings.disable_bots = False mock_settings.disable_bots = False
@@ -75,6 +80,11 @@ class TestWebSocketEndpoint:
mock_ws_rm.connection_info = None mock_ws_rm.connection_info = None
mock_health_rm.is_connected = False mock_health_rm.is_connected = False
mock_health_rm.connection_info = None mock_health_rm.connection_info = None
mock_health_rm.is_setup_in_progress = False
mock_health_rm.is_setup_complete = False
mock_health_rm.connection_desired = True
mock_health_rm.is_reconnecting = False
mock_health_rm.device_info_loaded = False
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None) mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
mock_settings.database_path = "/tmp/test.db" mock_settings.database_path = "/tmp/test.db"
mock_settings.disable_bots = False mock_settings.disable_bots = False
@@ -105,6 +115,11 @@ class TestWebSocketEndpoint:
mock_ws_rm.connection_info = "TCP: 192.168.1.1:4000" mock_ws_rm.connection_info = "TCP: 192.168.1.1:4000"
mock_health_rm.is_connected = True mock_health_rm.is_connected = True
mock_health_rm.connection_info = "TCP: 192.168.1.1:4000" mock_health_rm.connection_info = "TCP: 192.168.1.1:4000"
mock_health_rm.is_setup_in_progress = False
mock_health_rm.is_setup_complete = True
mock_health_rm.connection_desired = True
mock_health_rm.is_reconnecting = False
mock_health_rm.device_info_loaded = False
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None) mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
mock_settings.database_path = "/tmp/test.db" mock_settings.database_path = "/tmp/test.db"
mock_settings.disable_bots = False mock_settings.disable_bots = False
@@ -136,6 +151,11 @@ class TestWebSocketEndpoint:
mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0" mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_health_rm.is_connected = True mock_health_rm.is_connected = True
mock_health_rm.connection_info = "Serial: /dev/ttyUSB0" mock_health_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_health_rm.is_setup_in_progress = False
mock_health_rm.is_setup_complete = True
mock_health_rm.connection_desired = True
mock_health_rm.is_reconnecting = False
mock_health_rm.device_info_loaded = False
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None) mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
mock_settings.database_path = "/tmp/test.db" mock_settings.database_path = "/tmp/test.db"
mock_settings.disable_bots = False mock_settings.disable_bots = False
@@ -167,6 +187,11 @@ class TestWebSocketEndpoint:
mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0" mock_ws_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_health_rm.is_connected = True mock_health_rm.is_connected = True
mock_health_rm.connection_info = "Serial: /dev/ttyUSB0" mock_health_rm.connection_info = "Serial: /dev/ttyUSB0"
mock_health_rm.is_setup_in_progress = False
mock_health_rm.is_setup_complete = True
mock_health_rm.connection_desired = True
mock_health_rm.is_reconnecting = False
mock_health_rm.device_info_loaded = False
mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None) mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None)
mock_settings.database_path = "/tmp/test.db" mock_settings.database_path = "/tmp/test.db"
mock_settings.disable_bots = False mock_settings.disable_bots = False