mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
Improve live node positions and expose precision metadata (#231)
* Fetch latest node positions and precision metadata * Stop showing position source and precision in UI * Guard node positions against stale merges
This commit is contained in:
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS nodes (
|
||||
uptime_seconds INTEGER,
|
||||
position_time INTEGER,
|
||||
location_source TEXT,
|
||||
precision_bits INTEGER,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
altitude REAL
|
||||
|
||||
770
tests/nodes.json
770
tests/nodes.json
File diff suppressed because it is too large
Load Diff
48
web/app.rb
48
web/app.rb
@@ -348,6 +348,20 @@ end
|
||||
|
||||
init_db unless db_schema_present?
|
||||
|
||||
def ensure_schema_upgrades
|
||||
db = open_database
|
||||
node_columns = db.execute("PRAGMA table_info(nodes)").map { |row| row[1] }
|
||||
unless node_columns.include?("precision_bits")
|
||||
db.execute("ALTER TABLE nodes ADD COLUMN precision_bits INTEGER")
|
||||
end
|
||||
rescue SQLite3::SQLException => e
|
||||
warn "[warn] failed to apply schema upgrade: #{e.message}"
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
ensure_schema_upgrades
|
||||
|
||||
# Retrieve recently heard nodes ordered by their last contact time.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows returned.
|
||||
@@ -362,7 +376,8 @@ def query_nodes(limit)
|
||||
SELECT node_id, short_name, long_name, hw_model, role, snr,
|
||||
battery_level, voltage, last_heard, first_heard,
|
||||
uptime_seconds, channel_utilization, air_util_tx,
|
||||
position_time, latitude, longitude, altitude
|
||||
position_time, location_source, precision_bits,
|
||||
latitude, longitude, altitude
|
||||
FROM nodes
|
||||
WHERE last_heard >= ?
|
||||
SQL
|
||||
@@ -386,6 +401,8 @@ def query_nodes(limit)
|
||||
r["position_time"] = pt
|
||||
r["last_seen_iso"] = Time.at(lh).utc.iso8601 if lh
|
||||
r["pos_time_iso"] = Time.at(pt).utc.iso8601 if pt
|
||||
pb = r["precision_bits"]
|
||||
r["precision_bits"] = pb.to_i if pb
|
||||
end
|
||||
rows
|
||||
ensure
|
||||
@@ -506,6 +523,8 @@ def query_positions(limit)
|
||||
end
|
||||
pt_val = r["position_time"]
|
||||
r["position_time_iso"] = Time.at(pt_val).utc.iso8601 if pt_val
|
||||
pb = r["precision_bits"]
|
||||
r["precision_bits"] = pb.to_i if pb
|
||||
end
|
||||
rows
|
||||
ensure
|
||||
@@ -874,6 +893,11 @@ def upsert_node(db, node_id, n)
|
||||
met["uptimeSeconds"],
|
||||
pt,
|
||||
pos["locationSource"],
|
||||
coerce_integer(
|
||||
pos["precisionBits"] ||
|
||||
pos["precision_bits"] ||
|
||||
pos.dig("raw", "precision_bits"),
|
||||
),
|
||||
pos["latitude"],
|
||||
pos["longitude"],
|
||||
pos["altitude"],
|
||||
@@ -882,8 +906,8 @@ def upsert_node(db, node_id, n)
|
||||
db.execute <<~SQL, row
|
||||
INSERT INTO nodes(node_id,num,short_name,long_name,macaddr,hw_model,role,public_key,is_unmessagable,is_favorite,
|
||||
hops_away,snr,last_heard,first_heard,battery_level,voltage,channel_utilization,air_util_tx,uptime_seconds,
|
||||
position_time,location_source,latitude,longitude,altitude)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
position_time,location_source,precision_bits,latitude,longitude,altitude)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
num=excluded.num, short_name=excluded.short_name, long_name=excluded.long_name, macaddr=excluded.macaddr,
|
||||
hw_model=excluded.hw_model, role=excluded.role, public_key=excluded.public_key, is_unmessagable=excluded.is_unmessagable,
|
||||
@@ -891,7 +915,7 @@ def upsert_node(db, node_id, n)
|
||||
first_heard=COALESCE(nodes.first_heard, excluded.first_heard, excluded.last_heard),
|
||||
battery_level=excluded.battery_level, voltage=excluded.voltage, channel_utilization=excluded.channel_utilization,
|
||||
air_util_tx=excluded.air_util_tx, uptime_seconds=excluded.uptime_seconds, position_time=excluded.position_time,
|
||||
location_source=excluded.location_source, latitude=excluded.latitude, longitude=excluded.longitude,
|
||||
location_source=excluded.location_source, precision_bits=excluded.precision_bits, latitude=excluded.latitude, longitude=excluded.longitude,
|
||||
altitude=excluded.altitude
|
||||
WHERE COALESCE(excluded.last_heard,0) >= COALESCE(nodes.last_heard,0)
|
||||
SQL
|
||||
@@ -963,8 +987,9 @@ end
|
||||
# @param latitude [Float, nil] reported latitude.
|
||||
# @param longitude [Float, nil] reported longitude.
|
||||
# @param altitude [Float, nil] reported altitude.
|
||||
# @param precision_bits [Integer, nil] precision estimate provided by the device.
|
||||
# @param snr [Float, nil] link SNR for the packet.
|
||||
def update_node_from_position(db, node_id, node_num, rx_time, position_time, location_source, latitude, longitude, altitude, snr)
|
||||
def update_node_from_position(db, node_id, node_num, rx_time, position_time, location_source, precision_bits, latitude, longitude, altitude, snr)
|
||||
num = coerce_integer(node_num)
|
||||
id = string_or_nil(node_id)
|
||||
if id&.start_with?("!")
|
||||
@@ -985,6 +1010,7 @@ def update_node_from_position(db, node_id, node_num, rx_time, position_time, loc
|
||||
lat = coerce_float(latitude)
|
||||
lon = coerce_float(longitude)
|
||||
alt = coerce_float(altitude)
|
||||
precision = coerce_integer(precision_bits)
|
||||
snr_val = coerce_float(snr)
|
||||
|
||||
row = [
|
||||
@@ -994,6 +1020,7 @@ def update_node_from_position(db, node_id, node_num, rx_time, position_time, loc
|
||||
last_heard,
|
||||
pos_time,
|
||||
loc,
|
||||
precision,
|
||||
lat,
|
||||
lon,
|
||||
alt,
|
||||
@@ -1001,8 +1028,8 @@ def update_node_from_position(db, node_id, node_num, rx_time, position_time, loc
|
||||
]
|
||||
with_busy_retry do
|
||||
db.execute <<~SQL, row
|
||||
INSERT INTO nodes(node_id,num,last_heard,first_heard,position_time,location_source,latitude,longitude,altitude,snr)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
INSERT INTO nodes(node_id,num,last_heard,first_heard,position_time,location_source,precision_bits,latitude,longitude,altitude,snr)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(node_id) DO UPDATE SET
|
||||
num=COALESCE(excluded.num,nodes.num),
|
||||
snr=COALESCE(excluded.snr,nodes.snr),
|
||||
@@ -1019,6 +1046,12 @@ def update_node_from_position(db, node_id, node_num, rx_time, position_time, loc
|
||||
THEN excluded.location_source
|
||||
ELSE nodes.location_source
|
||||
END,
|
||||
precision_bits=CASE
|
||||
WHEN COALESCE(excluded.position_time,0) >= COALESCE(nodes.position_time,0)
|
||||
AND excluded.precision_bits IS NOT NULL
|
||||
THEN excluded.precision_bits
|
||||
ELSE nodes.precision_bits
|
||||
END,
|
||||
latitude=CASE
|
||||
WHEN COALESCE(excluded.position_time,0) >= COALESCE(nodes.position_time,0)
|
||||
AND excluded.latitude IS NOT NULL
|
||||
@@ -1212,6 +1245,7 @@ def insert_position(db, payload)
|
||||
rx_time,
|
||||
position_time,
|
||||
location_source,
|
||||
precision_bits,
|
||||
lat,
|
||||
lon,
|
||||
alt,
|
||||
|
||||
@@ -77,6 +77,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
"latitude" => node["latitude"],
|
||||
"longitude" => node["longitude"],
|
||||
"altitude" => node["altitude"],
|
||||
"locationSource" => node["location_source"],
|
||||
"precisionBits" => node["precision_bits"],
|
||||
)
|
||||
payload["position"] = position unless position.empty?
|
||||
|
||||
@@ -104,6 +106,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
"channel_utilization" => node["channel_utilization"],
|
||||
"air_util_tx" => node["air_util_tx"],
|
||||
"position_time" => node["position_time"],
|
||||
"location_source" => node["location_source"],
|
||||
"precision_bits" => node["precision_bits"],
|
||||
"latitude" => node["latitude"],
|
||||
"longitude" => node["longitude"],
|
||||
"altitude" => node["altitude"],
|
||||
@@ -717,7 +721,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
node_row = db.get_first_row(
|
||||
"SELECT last_heard, position_time, latitude, longitude, altitude, location_source, snr FROM nodes WHERE node_id = ?",
|
||||
"SELECT last_heard, position_time, latitude, longitude, altitude, location_source, precision_bits, snr FROM nodes WHERE node_id = ?",
|
||||
[node_id],
|
||||
)
|
||||
expect(node_row["last_heard"]).to eq(rx_time)
|
||||
@@ -726,6 +730,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect_same_value(node_row["longitude"], 13.4)
|
||||
expect_same_value(node_row["altitude"], 42.0)
|
||||
expect(node_row["location_source"]).to eq("LOC_INTERNAL")
|
||||
expect(node_row["precision_bits"]).to eq(15)
|
||||
expect_same_value(node_row["snr"], -8.5)
|
||||
end
|
||||
end
|
||||
@@ -1405,6 +1410,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect_same_value(actual_row["channel_utilization"], expected["channel_utilization"])
|
||||
expect_same_value(actual_row["air_util_tx"], expected["air_util_tx"])
|
||||
expect_same_value(actual_row["position_time"], expected["position_time"])
|
||||
expect(actual_row["location_source"]).to eq(expected["location_source"])
|
||||
expect_same_value(actual_row["precision_bits"], expected["precision_bits"])
|
||||
expect_same_value(actual_row["latitude"], expected["latitude"])
|
||||
expect_same_value(actual_row["longitude"], expected["longitude"])
|
||||
expect_same_value(actual_row["altitude"], expected["altitude"])
|
||||
@@ -1664,6 +1671,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
"position_time" => rx_time - 5,
|
||||
"latitude" => 52.0 + idx,
|
||||
"longitude" => 13.0 + idx,
|
||||
"location_source" => "LOC_TEST",
|
||||
"precision_bits" => 7 + idx,
|
||||
"payload_b64" => "AQI=",
|
||||
}
|
||||
post "/api/positions", payload.to_json, auth_headers
|
||||
@@ -1684,6 +1693,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(entry["position_time_iso"]).to eq(Time.at(rx_times.last - 5).utc.iso8601)
|
||||
expect(entry["latitude"]).to eq(53.0)
|
||||
expect(entry["longitude"]).to eq(14.0)
|
||||
expect(entry["location_source"]).to eq("LOC_TEST")
|
||||
expect(entry["precision_bits"]).to eq(8)
|
||||
expect(entry["payload_b64"]).to eq("AQI=")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1813,12 +1813,88 @@ var(--fg); }
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function fetchPositions(limit = NODE_LIMIT) {
|
||||
const r = await fetch(`/api/positions?limit=${limit}`, { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function toFiniteNumber(value) {
|
||||
if (value == null || value === '') return null;
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function resolveTimestampSeconds(numeric, isoString) {
|
||||
const parsedNumeric = toFiniteNumber(numeric);
|
||||
if (parsedNumeric != null) return parsedNumeric;
|
||||
if (typeof isoString === 'string' && isoString.length) {
|
||||
const parsedIso = Date.parse(isoString);
|
||||
if (Number.isFinite(parsedIso)) {
|
||||
return parsedIso / 1000;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergePositionsIntoNodes(nodes, positions) {
|
||||
if (!Array.isArray(nodes) || !Array.isArray(positions) || nodes.length === 0) return;
|
||||
|
||||
const nodesById = new Map();
|
||||
for (const node of nodes) {
|
||||
if (!node || typeof node !== 'object') continue;
|
||||
const key = typeof node.node_id === 'string' ? node.node_id : null;
|
||||
if (key) nodesById.set(key, node);
|
||||
}
|
||||
|
||||
if (nodesById.size === 0) return;
|
||||
|
||||
const updated = new Set();
|
||||
for (const pos of positions) {
|
||||
if (!pos || typeof pos !== 'object') continue;
|
||||
const nodeId = typeof pos.node_id === 'string' ? pos.node_id : null;
|
||||
if (!nodeId || updated.has(nodeId)) continue;
|
||||
const node = nodesById.get(nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
const lat = toFiniteNumber(pos.latitude);
|
||||
const lon = toFiniteNumber(pos.longitude);
|
||||
if (lat == null || lon == null) continue;
|
||||
|
||||
const currentTimestamp = resolveTimestampSeconds(node.position_time, node.pos_time_iso);
|
||||
const incomingTimestamp = resolveTimestampSeconds(pos.position_time, pos.position_time_iso);
|
||||
if (currentTimestamp != null) {
|
||||
if (incomingTimestamp == null || incomingTimestamp <= currentTimestamp) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
updated.add(nodeId);
|
||||
node.latitude = lat;
|
||||
node.longitude = lon;
|
||||
|
||||
const alt = toFiniteNumber(pos.altitude);
|
||||
if (alt != null) node.altitude = alt;
|
||||
|
||||
const posTime = toFiniteNumber(pos.position_time);
|
||||
if (posTime != null) {
|
||||
node.position_time = posTime;
|
||||
node.pos_time_iso = typeof pos.position_time_iso === 'string' && pos.position_time_iso.length
|
||||
? pos.position_time_iso
|
||||
: new Date(posTime * 1000).toISOString();
|
||||
} else if (typeof pos.position_time_iso === 'string' && pos.position_time_iso.length) {
|
||||
node.pos_time_iso = pos.position_time_iso;
|
||||
}
|
||||
|
||||
if (pos.location_source != null && pos.location_source !== '') {
|
||||
node.location_source = pos.location_source;
|
||||
}
|
||||
|
||||
const precision = toFiniteNumber(pos.precision_bits);
|
||||
if (precision != null) node.precision_bits = precision;
|
||||
}
|
||||
}
|
||||
|
||||
function buildTelemetryIndex(entries) {
|
||||
const byNodeId = new Map();
|
||||
const byNodeNum = new Map();
|
||||
@@ -1938,6 +2014,8 @@ var(--fg); }
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const n of nodes) {
|
||||
const tr = document.createElement('tr');
|
||||
const lastPositionTime = toFiniteNumber(n.position_time ?? n.positionTime);
|
||||
const lastPositionCell = lastPositionTime != null ? timeAgo(lastPositionTime, nowSec) : '';
|
||||
tr.innerHTML = `
|
||||
<td class="mono">${n.node_id || ""}</td>
|
||||
<td>${renderShortHtml(n.short_name, n.role, n.long_name, n)}</td>
|
||||
@@ -1956,7 +2034,7 @@ var(--fg); }
|
||||
<td>${fmtCoords(n.latitude)}</td>
|
||||
<td>${fmtCoords(n.longitude)}</td>
|
||||
<td>${fmtAlt(n.altitude, "m")}</td>
|
||||
<td class="mono">${n.pos_time_iso ? `${timeAgo(n.position_time, nowSec)}` : ""}</td>`;
|
||||
<td class="mono">${lastPositionCell}</td>`;
|
||||
frag.appendChild(tr);
|
||||
}
|
||||
tb.replaceChildren(frag);
|
||||
@@ -2112,8 +2190,9 @@ var(--fg); }
|
||||
if (n.last_heard) {
|
||||
lines.push(`Last seen: ${timeAgo(n.last_heard, nowSec)}`);
|
||||
}
|
||||
if (n.pos_time_iso) {
|
||||
lines.push(`Last Position: ${timeAgo(n.position_time, nowSec)}`);
|
||||
const lastPositionTime = toFiniteNumber(n.position_time ?? n.positionTime);
|
||||
if (lastPositionTime != null) {
|
||||
lines.push(`Last Position: ${timeAgo(lastPositionTime, nowSec)}`);
|
||||
}
|
||||
if (n.uptime_seconds) {
|
||||
lines.push(`Uptime: ${timeHum(n.uptime_seconds)}`);
|
||||
@@ -2208,13 +2287,19 @@ var(--fg); }
|
||||
console.warn('telemetry refresh failed; continuing without telemetry', err);
|
||||
return [];
|
||||
});
|
||||
const [nodes, neighborTuples, messages, telemetryEntries] = await Promise.all([
|
||||
const positionsPromise = fetchPositions().catch(err => {
|
||||
console.warn('position refresh failed; continuing without updates', err);
|
||||
return [];
|
||||
});
|
||||
const [nodes, positions, neighborTuples, messages, telemetryEntries] = await Promise.all([
|
||||
fetchNodes(),
|
||||
positionsPromise,
|
||||
neighborPromise,
|
||||
fetchMessages(),
|
||||
telemetryPromise,
|
||||
]);
|
||||
nodes.forEach(applyNodeNameFallback);
|
||||
mergePositionsIntoNodes(nodes, positions);
|
||||
computeDistances(nodes);
|
||||
mergeTelemetryIntoNodes(nodes, telemetryEntries);
|
||||
if (Array.isArray(messages)) {
|
||||
|
||||
Reference in New Issue
Block a user