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 @@
+ | Time |
Packet ID |
From |
To |
@@ -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):
"""