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:
l5y
2025-10-05 23:08:57 +02:00
committed by GitHub
parent a3fb9b0d5c
commit 09a2d849ec
5 changed files with 706 additions and 219 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View File

@@ -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

View File

@@ -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)) {