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:
Pablo Revilla
2025-11-05 19:07:23 -08:00
parent 9fa874762e
commit 4a3f205d26
6 changed files with 168 additions and 140 deletions
+3 -3
View File
@@ -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)
+49 -9
View File
@@ -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 %}
+16 -5
View File
@@ -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, "&lt;").replace(/>/g, "&gt;");
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
View File
@@ -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
View File
@@ -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
View File
@@ -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):
"""