mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-08-08 01:42:44 +02:00
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
This commit is contained in:
+3
-3
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
<div id="chat-container">
|
||||
<div class="container" id="chat-log"></div>
|
||||
@@ -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 = `
|
||||
<span class="col-2 timestamp" title="${packet.import_time}">${formattedTimestamp}</span>
|
||||
<span class="col-2 timestamp" title="${packet.import_time_us}">${formattedTimestamp}</span>
|
||||
<span class="col-2 channel">
|
||||
<a href="/packet/${packet.id}" data-translate-lang-title="view_packet_details">✉️</a>
|
||||
${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);
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
<table class="packet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Packet ID</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
@@ -104,7 +105,7 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let lastImportTime = null;
|
||||
let lastImportTimeUs = null;
|
||||
let updatesPaused = false;
|
||||
let nodeMap = {};
|
||||
let updateInterval = 3000;
|
||||
@@ -170,6 +171,13 @@ function portLabel(portnum, payload) {
|
||||
<span class="text-secondary">(${portnum})</span>`;
|
||||
}
|
||||
|
||||
// --- Convert import_time_us to local time string ---
|
||||
function formatLocalTime(importTimeUs) {
|
||||
const ms = importTimeUs / 1000;
|
||||
const date = new Date(ms);
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
// --- Fetch firehose interval from shared site config ---
|
||||
async function configureFirehose() {
|
||||
try {
|
||||
@@ -188,7 +196,7 @@ async function configureFirehose() {
|
||||
async function fetchUpdates() {
|
||||
if (updatesPaused) return;
|
||||
const url = new URL("/api/packets", window.location.origin);
|
||||
if (lastImportTime) url.searchParams.set("since", lastImportTime);
|
||||
if (lastImportTimeUs) url.searchParams.set("since", lastImportTimeUs);
|
||||
url.searchParams.set("limit", 50);
|
||||
|
||||
try {
|
||||
@@ -231,8 +239,11 @@ async function fetchUpdates() {
|
||||
}
|
||||
|
||||
const safePayload = (pkt.payload || "").replace(/</g, "<").replace(/>/g, ">");
|
||||
const localTime = formatLocalTime(pkt.import_time_us);
|
||||
|
||||
const html = `
|
||||
<tr class="packet-row" data-id="${pkt.id}">
|
||||
<td>${localTime}</td>
|
||||
<td><span class="toggle-btn">▶</span> <a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">${pkt.id}</a></td>
|
||||
<td>${from}</td>
|
||||
<td>${to}</td>
|
||||
@@ -240,13 +251,13 @@ async function fetchUpdates() {
|
||||
<td>${links}</td>
|
||||
</tr>
|
||||
<tr class="payload-row">
|
||||
<td colspan="5" class="payload-cell">${safePayload}</td>
|
||||
<td colspan="6" class="payload-cell">${safePayload}</td>
|
||||
</tr>`;
|
||||
list.insertAdjacentHTML("afterbegin", html);
|
||||
}
|
||||
|
||||
while (list.rows.length > 400) list.deleteRow(-1);
|
||||
lastImportTime = packets[packets.length - 1].import_time;
|
||||
lastImportTimeUs = packets[packets.length - 1].import_time_us;
|
||||
|
||||
} catch (err) {
|
||||
console.error("Packet fetch failed:", err);
|
||||
@@ -276,4 +287,4 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
+28
-13
@@ -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 ----------------------
|
||||
|
||||
+8
-4
@@ -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 = "<redacted>"
|
||||
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'<a href="https://www.google.com/maps/search/?api=1&query={payload.latitude_i * 1e-7},{payload.longitude_i * 1e-7}" target="_blank">map</a>'
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+64
-106
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user