From 4a3f205d26dd6c5b46cc114794b1ab3c62e26bc3 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Wed, 5 Nov 2025 19:07:23 -0800 Subject: [PATCH] Worked on /api/packet. Needed to modify - Store.py to read the new time data - api.py to present the new time data - firehose.html chat.html and map.html now use the new apis and the time is the browser local time --- meshview/store.py | 6 +- meshview/templates/chat.html | 58 +++++++++-- meshview/templates/firehose.html | 21 +++- meshview/templates/map.html | 41 +++++--- meshview/web.py | 12 ++- meshview/web_api/api.py | 170 ++++++++++++------------------- 6 files changed, 168 insertions(+), 140 deletions(-) diff --git a/meshview/store.py b/meshview/store.py index ef3c7ae..a5d9ffc 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -33,11 +33,11 @@ async def get_packets(node_id=None, portnum=None, after=None, before=None, limit if portnum: q = q.where(Packet.portnum == portnum) if after: - q = q.where(Packet.import_time > after) + q = q.where(Packet.import_time_us > after) if before: - q = q.where(Packet.import_time < before) + q = q.where(Packet.import_time_us < before) - q = q.order_by(Packet.import_time.desc()) + q = q.order_by(Packet.import_time_us.desc()) if limit is not None: q = q.limit(limit) diff --git a/meshview/templates/chat.html b/meshview/templates/chat.html index 24d333d..93b9517 100644 --- a/meshview/templates/chat.html +++ b/meshview/templates/chat.html @@ -1,18 +1,56 @@ {% extends "base.html" %} {% block css %} -.timestamp { min-width: 10em; } +.timestamp { + min-width: 10em; + color: #ccc; +} + .chat-packet:nth-of-type(odd) { background-color: #3a3a3a; } -.chat-packet { border-bottom: 1px solid #555; padding: 8px; border-radius: 8px; } +.chat-packet { + border-bottom: 1px solid #555; + padding: 3px 6px; /* slightly more horizontal breathing room */ + border-radius: 6px; + margin: 0; +} + +/* Wider spacing between Bootstrap-style columns */ +.chat-packet > [class^="col-"] { + padding-left: 10px !important; /* wider horizontal */ + padding-right: 10px !important; /* wider horizontal */ + padding-top: 1px !important; + padding-bottom: 1px !important; +} + .chat-packet:nth-of-type(even) { background-color: #333333; } -@keyframes flash { 0% { background-color: #ffe066; } 100% { background-color: inherit; } } +/* Make the channel italic and slightly lighter */ +.channel { + font-style: italic; + color: #bbb; +} +.channel a { + font-style: normal; + color: #999; +} + +@keyframes flash { + 0% { background-color: #ffe066; } + 100% { background-color: inherit; } +} .chat-packet.flash { animation: flash 3.5s ease-out; } -.replying-to { font-size: 0.85em; color: #aaa; margin-top: 4px; padding-left: 20px; } +.replying-to { + font-size: 0.8em; + color: #aaa; + margin-top: 2px; + padding-left: 10px; +} .replying-to .reply-preview { color: #aaa; } {% endblock %} + + {% block body %}
@@ -50,7 +88,8 @@ document.addEventListener("DOMContentLoaded", async () => { renderedPacketIds.add(packet.id); packetMap.set(packet.id, packet); - const date = new Date(packet.import_time); + // Convert microseconds to JS Date + const date = new Date(packet.import_time_us / 1000); const formattedTime = date.toLocaleTimeString([], { hour:"numeric", minute:"2-digit", second:"2-digit", hour12:true }); const formattedDate = `${(date.getMonth()+1).toString().padStart(2,"0")}/${date.getDate().toString().padStart(2,"0")}/${date.getFullYear()}`; const formattedTimestamp = `${formattedTime} - ${formattedDate}`; @@ -78,7 +117,7 @@ document.addEventListener("DOMContentLoaded", async () => { div.className = "row chat-packet" + (highlight ? " flash" : ""); div.dataset.packetId = packet.id; div.innerHTML = ` - ${formattedTimestamp} + ${formattedTimestamp} ✉️ ${escapeHtml(packet.channel || "")} @@ -98,13 +137,13 @@ document.addEventListener("DOMContentLoaded", async () => { function renderPacketsEnsureDescending(packets, highlight=false) { if (!Array.isArray(packets) || packets.length===0) return; - const sortedDesc = packets.slice().sort((a,b)=>new Date(b.import_time)-new Date(a.import_time)); + const sortedDesc = packets.slice().sort((a,b)=>b.import_time_us - a.import_time_us); for (let i=sortedDesc.length-1; i>=0; i--) renderPacket(sortedDesc[i], highlight); } async function fetchInitial() { try { - const resp = await fetch("/api/chat?limit=100"); + const resp = await fetch("/api/packets?portnum=1&limit=100"); const data = await resp.json(); if (data?.packets?.length) renderPacketsEnsureDescending(data.packets); lastTime = data?.latest_import_time || lastTime; @@ -113,7 +152,7 @@ document.addEventListener("DOMContentLoaded", async () => { async function fetchUpdates() { try { - const url = new URL("/api/chat", window.location.origin); + const url = new URL("/api/packets?portnum=1", window.location.origin); url.searchParams.set("limit","100"); if (lastTime) url.searchParams.set("since", lastTime); const resp = await fetch(url); @@ -138,4 +177,5 @@ document.addEventListener("DOMContentLoaded", async () => { setInterval(fetchUpdates, 5000); }); + {% endblock %} diff --git a/meshview/templates/firehose.html b/meshview/templates/firehose.html index 78bbc22..c84af26 100644 --- a/meshview/templates/firehose.html +++ b/meshview/templates/firehose.html @@ -92,6 +92,7 @@ + @@ -104,7 +105,7 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 7bbc2e1..6d0566f 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -60,21 +60,36 @@ function hashToColor(str){ if(colorMap.has(str)) return colorMap.get(str); const function isInvalidCoord(n){ return !n||!n.lat||!n.long||n.lat===0||n.long===0||Number.isNaN(n.lat)||Number.isNaN(n.long); } // ---------------------- Packet Fetching ---------------------- -function fetchLatestPacket(){ fetch(`/api/packets?limit=1`).then(r=>r.json()).then(data=>{ lastImportTime=data.packets?.[0]?.import_time||new Date().toISOString(); }).catch(console.error); } +function fetchLatestPacket(){ + fetch(`/api/packets?limit=1`) + .then(r=>r.json()) + .then(data=>{ + lastImportTime=data.packets?.[0]?.import_time_us||0; + }) + .catch(console.error); +} + function fetchNewPackets(){ if(mapInterval <= 0) return; - if(!lastImportTime) return; - fetch(`/api/packets?since=${encodeURIComponent(lastImportTime)}`).then(r=>r.json()).then(data=>{ - if(!data.packets||data.packets.length===0) return; - let latest = lastImportTime; - data.packets.forEach(pkt=>{ - if(pkt.import_time>latest) latest=pkt.import_time; - const marker = markerById[pkt.from_node_id]; - const nodeData = nodeMap.get(pkt.from_node_id); - if(marker && nodeData) blinkNode(marker,nodeData.long_name,pkt.portnum); - }); - lastImportTime=latest; - }).catch(console.error); + if(lastImportTime===null) return; + const url = new URL(`/api/packets`, window.location.origin); + url.searchParams.set("since", lastImportTime); + url.searchParams.set("limit", 50); + + fetch(url) + .then(r=>r.json()) + .then(data=>{ + if(!data.packets || data.packets.length===0) return; + let latest = lastImportTime; + data.packets.forEach(pkt=>{ + if(pkt.import_time_us > latest) latest = pkt.import_time_us; + const marker = markerById[pkt.from_node_id]; + const nodeData = nodeMap.get(pkt.from_node_id); + if(marker && nodeData) blinkNode(marker,nodeData.long_name,pkt.portnum); + }); + lastImportTime = latest; + }) + .catch(console.error); } // ---------------------- Polling ---------------------- diff --git a/meshview/web.py b/meshview/web.py index 3167bf9..0d503ac 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -62,6 +62,7 @@ class Packet: payload: str pretty_payload: Markup import_time: datetime.datetime + import_time_us: int @classmethod def from_model(cls, packet): @@ -81,14 +82,16 @@ class Packet: text_payload = text_format.MessageToString(payload) elif packet.portnum == PortNum.TEXT_MESSAGE_APP and packet.to_node_id != 0xFFFFFFFF: text_payload = "" + elif isinstance(payload, bytes): + text_payload = payload.decode("utf-8", errors="replace") # decode bytes safely else: - text_payload = payload + text_payload = str(payload) if payload: if ( packet.portnum == PortNum.POSITION_APP - and payload.latitude_i - and payload.longitude_i + and getattr(payload, "latitude_i", None) + and getattr(payload, "longitude_i", None) ): pretty_payload = Markup( f'map' @@ -102,9 +105,10 @@ class Packet: to_node_id=packet.to_node_id, portnum=packet.portnum, data=text_mesh_packet, - payload=text_payload, + payload=text_payload, # now always a string pretty_payload=pretty_payload, import_time=packet.import_time, + import_time_us=packet.import_time_us, # <-- include microseconds raw_mesh_packet=mesh_packet, raw_payload=payload, ) diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py index 99834c9..fc6131d 100644 --- a/meshview/web_api/api.py +++ b/meshview/web_api/api.py @@ -44,94 +44,6 @@ async def api_channels(request: web.Request): return web.json_response({"channels": [], "error": str(e)}) -@routes.get("/api/chat") -async def api_chat(request): - try: - # Parse query params - limit_str = request.query.get("limit", "20") - since_str = request.query.get("since") - - # Clamp limit between 1 and 200 - try: - limit = min(max(int(limit_str), 1), 100) - except ValueError: - limit = 50 - - # Parse "since" timestamp if provided - since = None - if since_str: - try: - since = datetime.datetime.fromisoformat(since_str) - except Exception as e: - logger.error(f"Failed to parse since '{since_str}': {e}") - - # Fetch packets from store - packets = await store.get_packets( - node_id=0xFFFFFFFF, - portnum=PortNum.TEXT_MESSAGE_APP, - limit=limit, - ) - - ui_packets = [Packet.from_model(p) for p in packets] - - # Filter out "seq N" and missing payloads - filtered_packets = [ - p for p in ui_packets if p.payload and not SEQ_REGEX.fullmatch(p.payload) - ] - - # Apply "since" filter - if since: - filtered_packets = [p for p in filtered_packets if p.import_time > since] - - # Sort by import_time descending (latest first) - filtered_packets.sort(key=lambda p: p.import_time, reverse=True) - - # Trim to requested limit - filtered_packets = filtered_packets[:limit] - - # Build response data - packets_data = [] - for p in filtered_packets: - reply_id = getattr( - getattr(getattr(p, "raw_mesh_packet", None), "decoded", None), "reply_id", None - ) - - packet_dict = { - "id": p.id, - "import_time": p.import_time.isoformat(), - "channel": getattr(p.from_node, "channel", ""), - "from_node_id": p.from_node_id, - "long_name": getattr(p.from_node, "long_name", ""), - "payload": p.payload, - } - - if reply_id: - packet_dict["reply_id"] = reply_id - - packets_data.append(packet_dict) - - # Pick latest import time for clients to use in next request - if filtered_packets: - latest_import_time = filtered_packets[0].import_time.isoformat() - elif since: - latest_import_time = since.isoformat() - else: - latest_import_time = None - - return web.json_response( - { - "packets": packets_data, - "latest_import_time": latest_import_time, - } - ) - - except Exception as e: - logger.error(f"Error in /api/chat: {e}") - return web.json_response( - {"error": "Failed to fetch chat data", "details": str(e)}, status=500 - ) - - @routes.get("/api/nodes") async def api_nodes(request): try: @@ -181,44 +93,90 @@ async def api_nodes(request): @routes.get("/api/packets") async def api_packets(request): try: - # Query parameters - limit = int(request.query.get("limit", 50)) + # --- Parse query parameters --- + limit_str = request.query.get("limit", "50") since_str = request.query.get("since") - since_time = None + portnum = request.query.get("portnum") + # Clamp limit between 1 and 100 + try: + limit = min(max(int(limit_str), 1), 100) + except ValueError: + limit = 50 + + # Parse "since" timestamp in microseconds + since = None if since_str: try: - # Robust ISO 8601 parsing (handles 'Z' for UTC) - since_time = datetime.datetime.fromisoformat(since_str.replace("Z", "+00:00")) - except Exception as e: - logger.error(f"Failed to parse 'since' timestamp '{since_str}': {e}") + since = int(since_str) + except ValueError: + logger.warning(f"Invalid 'since' value (expected microseconds): {since_str}") - # Fetch packets from the store - packets = await store.get_packets(limit=limit, after=since_time) - packets = [Packet.from_model(p) for p in packets] + # --- Fetch packets from store --- + packets = await store.get_packets( + node_id=0xFFFFFFFF if portnum else None, + portnum=portnum, + after=since, + limit=limit, + ) - packets_json = [] - for p in packets: - payload = (p.payload or "").strip() + ui_packets = [Packet.from_model(p) for p in packets] - packets_json.append( + # --- Chat-like filtering (if TEXT_MESSAGE_APP) --- + if str(portnum) == str(PortNum.TEXT_MESSAGE_APP): + # Filter out empty or "seq N" payloads + ui_packets = [ + p for p in ui_packets if p.payload and not SEQ_REGEX.fullmatch(p.payload) + ] + + # Sort newest first + ui_packets.sort(key=lambda p: p.import_time_us, reverse=True) + ui_packets = ui_packets[:limit] + + packets_data = [] + for p in ui_packets: + reply_id = getattr( + getattr(getattr(p, "raw_mesh_packet", None), "decoded", None), + "reply_id", + None, + ) + + packet_dict = { + "id": p.id, + "import_time_us": p.import_time_us, + "channel": getattr(p.from_node, "channel", ""), + "from_node_id": p.from_node_id, + "long_name": getattr(p.from_node, "long_name", ""), + "payload": (p.payload or "").strip(), + } + + if reply_id: + packet_dict["reply_id"] = reply_id + + packets_data.append(packet_dict) + + # --- General packet listing --- + else: + packets_data = [ { "id": p.id, "from_node_id": p.from_node_id, "to_node_id": p.to_node_id, "portnum": int(p.portnum) if p.portnum is not None else None, - "import_time": p.import_time.isoformat(), - "payload": payload, + "payload": (p.payload or "").strip(), + "import_time_us": p.import_time_us, } - ) + for p in ui_packets + ] - return web.json_response({"packets": packets_json}) + return web.json_response({"packets": packets_data}) except Exception as e: logger.error(f"Error in /api/packets: {e}") return web.json_response({"error": "Failed to fetch packets"}, status=500) + @routes.get("/api/stats") async def api_stats(request): """
Time Packet ID From To