mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-09 10:22:52 +02:00
Ensure APIs filter stale data and refresh node details from latest sources (#312)
* Ensure fresh API data and richer node refresh details * Refresh map markers with latest node data
This commit is contained in:
@@ -15,6 +15,29 @@
|
||||
module PotatoMesh
|
||||
module App
|
||||
module Queries
|
||||
MAX_QUERY_LIMIT = 1000
|
||||
|
||||
# Normalise a caller-provided limit to a sane, positive integer.
|
||||
#
|
||||
# @param limit [Object] value coerced to an integer.
|
||||
# @param default [Integer] fallback used when coercion fails.
|
||||
# @return [Integer] limit clamped between 1 and MAX_QUERY_LIMIT.
|
||||
def coerce_query_limit(limit, default: 200)
|
||||
coerced = begin
|
||||
if limit.is_a?(Integer)
|
||||
limit
|
||||
else
|
||||
Integer(limit, 10)
|
||||
end
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
coerced = default if coerced.nil? || coerced <= 0
|
||||
coerced = MAX_QUERY_LIMIT if coerced > MAX_QUERY_LIMIT
|
||||
coerced
|
||||
end
|
||||
|
||||
def node_reference_tokens(node_ref)
|
||||
parts = canonical_node_parts(node_ref)
|
||||
canonical_id, numeric_id = parts ? parts[0, 2] : [nil, nil]
|
||||
@@ -98,6 +121,7 @@ module PotatoMesh
|
||||
end
|
||||
|
||||
def query_nodes(limit, node_ref: nil)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
now = Time.now.to_i
|
||||
@@ -135,6 +159,13 @@ module PotatoMesh
|
||||
params << limit
|
||||
|
||||
rows = db.execute(sql, params)
|
||||
rows.select! do |r|
|
||||
last_candidate = [r["last_heard"], r["position_time"], r["first_heard"]]
|
||||
.map { |value| coerce_integer(value) }
|
||||
.compact
|
||||
.max
|
||||
last_candidate && last_candidate >= min_last_heard
|
||||
end
|
||||
rows.each do |r|
|
||||
r["role"] ||= "CLIENT"
|
||||
lh = r["last_heard"]&.to_i
|
||||
@@ -154,10 +185,15 @@ module PotatoMesh
|
||||
end
|
||||
|
||||
def query_messages(limit, node_ref: nil)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
where_clauses = ["COALESCE(TRIM(m.encrypted), '') = ''"]
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
where_clauses << "m.rx_time >= ?"
|
||||
params << min_rx_time
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["m.from_id", "m.to_id"])
|
||||
@@ -257,10 +293,15 @@ module PotatoMesh
|
||||
end
|
||||
|
||||
def query_positions(limit, node_ref: nil)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
where_clauses << "COALESCE(rx_time, position_time, 0) >= ?"
|
||||
params << min_rx_time
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
|
||||
@@ -279,7 +320,6 @@ module PotatoMesh
|
||||
SQL
|
||||
params << limit
|
||||
rows = db.execute(sql, params)
|
||||
now = Time.now.to_i
|
||||
rows.each do |r|
|
||||
rx_time = coerce_integer(r["rx_time"])
|
||||
r["rx_time"] = rx_time if rx_time
|
||||
@@ -304,10 +344,15 @@ module PotatoMesh
|
||||
end
|
||||
|
||||
def query_neighbors(limit, node_ref: nil)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
where_clauses << "COALESCE(rx_time, 0) >= ?"
|
||||
params << min_rx_time
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id", "neighbor_id"])
|
||||
@@ -326,7 +371,6 @@ module PotatoMesh
|
||||
SQL
|
||||
params << limit
|
||||
rows = db.execute(sql, params)
|
||||
now = Time.now.to_i
|
||||
rows.each do |r|
|
||||
rx_time = coerce_integer(r["rx_time"])
|
||||
rx_time = now if rx_time && rx_time > now
|
||||
@@ -340,10 +384,15 @@ module PotatoMesh
|
||||
end
|
||||
|
||||
def query_telemetry(limit, node_ref: nil)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
where_clauses << "COALESCE(rx_time, telemetry_time, 0) >= ?"
|
||||
params << min_rx_time
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
|
||||
@@ -362,7 +411,6 @@ module PotatoMesh
|
||||
SQL
|
||||
params << limit
|
||||
rows = db.execute(sql, params)
|
||||
now = Time.now.to_i
|
||||
rows.each do |r|
|
||||
rx_time = coerce_integer(r["rx_time"])
|
||||
r["rx_time"] = rx_time if rx_time
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright (C) 2025 l5yth
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from '../map-marker-node-info.js';
|
||||
|
||||
function createFakeMarker(anchor) {
|
||||
const handlers = {};
|
||||
return {
|
||||
handlers,
|
||||
on(name, handler) {
|
||||
if (!handlers[name]) handlers[name] = [];
|
||||
handlers[name].push(handler);
|
||||
return this;
|
||||
},
|
||||
getElement() {
|
||||
return anchor;
|
||||
},
|
||||
trigger(name, payload) {
|
||||
for (const handler of handlers[name] || []) {
|
||||
handler(payload);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('attachNodeInfoRefreshToMarker refreshes markers with merged overlay details', async () => {
|
||||
const anchor = { id: 'anchor-el' };
|
||||
const marker = createFakeMarker(anchor);
|
||||
const popupUpdates = [];
|
||||
const detailCalls = [];
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
let token = 0;
|
||||
const refreshCalls = [];
|
||||
|
||||
attachNodeInfoRefreshToMarker({
|
||||
marker,
|
||||
getOverlayFallback: () => ({ nodeId: '!foo', shortName: 'Foo', role: 'CLIENT', neighbors: [] }),
|
||||
refreshNodeInformation: async reference => {
|
||||
refreshCalls.push(reference);
|
||||
return { battery: 55.5, telemetryTime: 123, neighbors: [{ neighbor_id: '!bar', snr: 9.5 }] };
|
||||
},
|
||||
mergeOverlayDetails: (primary, fallback) => ({ ...fallback, ...primary }),
|
||||
createRequestToken: () => ++token,
|
||||
isTokenCurrent: candidate => candidate === token,
|
||||
showLoading: (el, info) => {
|
||||
assert.equal(el, anchor);
|
||||
assert.equal(info.nodeId, '!foo');
|
||||
},
|
||||
showDetails: (el, info) => {
|
||||
detailCalls.push({ el, info });
|
||||
},
|
||||
showError: () => {
|
||||
assert.fail('showError should not be invoked on success');
|
||||
},
|
||||
updatePopup: info => {
|
||||
popupUpdates.push(info);
|
||||
},
|
||||
});
|
||||
|
||||
const clickEvent = {
|
||||
originalEvent: {
|
||||
preventDefault() {
|
||||
prevented = true;
|
||||
},
|
||||
stopPropagation() {
|
||||
stopped = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
marker.trigger('click', clickEvent);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(stopped, true);
|
||||
assert.equal(refreshCalls.length, 1);
|
||||
assert.deepEqual(refreshCalls[0], {
|
||||
nodeId: '!foo',
|
||||
fallback: { nodeId: '!foo', shortName: 'Foo', role: 'CLIENT', neighbors: [] },
|
||||
});
|
||||
assert.ok(popupUpdates.length >= 1);
|
||||
const merged = popupUpdates[popupUpdates.length - 1];
|
||||
assert.equal(merged.battery, 55.5);
|
||||
assert.equal(merged.telemetryTime, 123);
|
||||
assert.equal(detailCalls.length, 1);
|
||||
assert.equal(detailCalls[0].el, anchor);
|
||||
assert.equal(detailCalls[0].info.battery, 55.5);
|
||||
});
|
||||
|
||||
test('attachNodeInfoRefreshToMarker surfaces errors with fallback overlays', async () => {
|
||||
const anchor = { id: 'anchor' };
|
||||
const marker = createFakeMarker(anchor);
|
||||
let token = 0;
|
||||
let errorCaptured = null;
|
||||
let detailCalls = 0;
|
||||
let updateCalls = 0;
|
||||
|
||||
attachNodeInfoRefreshToMarker({
|
||||
marker,
|
||||
getOverlayFallback: () => ({ nodeId: '!oops', shortName: 'Oops' }),
|
||||
refreshNodeInformation: async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
mergeOverlayDetails: (primary, fallback) => ({ ...fallback, ...primary }),
|
||||
createRequestToken: () => ++token,
|
||||
isTokenCurrent: candidate => candidate === token,
|
||||
showLoading: () => {},
|
||||
showDetails: () => {
|
||||
detailCalls += 1;
|
||||
},
|
||||
showError: (el, info, error) => {
|
||||
assert.equal(el, anchor);
|
||||
assert.equal(info.nodeId, '!oops');
|
||||
errorCaptured = error;
|
||||
},
|
||||
updatePopup: () => {
|
||||
updateCalls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
marker.trigger('click', { originalEvent: {} });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
assert.ok(errorCaptured instanceof Error);
|
||||
assert.equal(errorCaptured.message, 'boom');
|
||||
assert.equal(detailCalls, 0);
|
||||
assert.equal(updateCalls, 2);
|
||||
});
|
||||
|
||||
test('attachNodeInfoRefreshToMarker skips refresh when identifiers are missing', async () => {
|
||||
const anchor = { id: 'anchor' };
|
||||
const marker = createFakeMarker(anchor);
|
||||
let token = 0;
|
||||
let refreshed = false;
|
||||
let detailsShown = 0;
|
||||
|
||||
attachNodeInfoRefreshToMarker({
|
||||
marker,
|
||||
getOverlayFallback: () => ({ shortName: 'Unknown' }),
|
||||
refreshNodeInformation: async () => {
|
||||
refreshed = true;
|
||||
},
|
||||
mergeOverlayDetails: (primary, fallback) => ({ ...fallback, ...primary }),
|
||||
createRequestToken: () => ++token,
|
||||
isTokenCurrent: candidate => candidate === token,
|
||||
showLoading: () => {
|
||||
assert.fail('showLoading should not run without identifiers');
|
||||
},
|
||||
showDetails: (el, info) => {
|
||||
assert.equal(el, anchor);
|
||||
assert.equal(info.shortName, 'Unknown');
|
||||
detailsShown += 1;
|
||||
},
|
||||
});
|
||||
|
||||
marker.trigger('click', { originalEvent: {} });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
assert.equal(refreshed, false);
|
||||
assert.equal(detailsShown, 1);
|
||||
});
|
||||
|
||||
test('attachNodeInfoRefreshToMarker honours shouldHandleClick predicate', async () => {
|
||||
const marker = createFakeMarker({ id: 'anchor' });
|
||||
let token = 0;
|
||||
let refreshed = false;
|
||||
|
||||
attachNodeInfoRefreshToMarker({
|
||||
marker,
|
||||
getOverlayFallback: () => ({ nodeId: '!skip' }),
|
||||
refreshNodeInformation: async () => {
|
||||
refreshed = true;
|
||||
},
|
||||
mergeOverlayDetails: (primary, fallback) => ({ ...fallback, ...primary }),
|
||||
createRequestToken: () => ++token,
|
||||
isTokenCurrent: candidate => candidate === token,
|
||||
shouldHandleClick: () => false,
|
||||
});
|
||||
|
||||
marker.trigger('click', { originalEvent: {} });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
assert.equal(refreshed, false);
|
||||
});
|
||||
|
||||
test('overlayToPopupNode normalises raw overlay payloads', () => {
|
||||
const overlay = {
|
||||
nodeId: '!foo',
|
||||
nodeNum: 42,
|
||||
shortName: 'Foo',
|
||||
role: 'ROUTER',
|
||||
battery: '77.5',
|
||||
neighbors: [
|
||||
{ neighbor_id: '!bar', snr: '12.5', neighbor_short_name: 'Bar' },
|
||||
null,
|
||||
],
|
||||
};
|
||||
|
||||
const popupNode = overlayToPopupNode(overlay);
|
||||
assert.equal(popupNode.node_id, '!foo');
|
||||
assert.equal(popupNode.node_num, 42);
|
||||
assert.equal(popupNode.short_name, 'Foo');
|
||||
assert.equal(popupNode.role, 'ROUTER');
|
||||
assert.equal(popupNode.battery_level, 77.5);
|
||||
assert.equal(Array.isArray(popupNode.neighbors), true);
|
||||
assert.equal(popupNode.neighbors.length, 1);
|
||||
assert.equal(popupNode.neighbors[0].node.node_id, '!bar');
|
||||
assert.equal(popupNode.neighbors[0].snr, 12.5);
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
* Copyright (C) 2025 l5yth
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { refreshNodeInformation, __testUtils } from '../node-details.js';
|
||||
|
||||
const {
|
||||
toTrimmedString,
|
||||
toFiniteNumber,
|
||||
extractString,
|
||||
extractNumber,
|
||||
assignString,
|
||||
assignNumber,
|
||||
mergeNodeFields,
|
||||
mergeTelemetry,
|
||||
mergePosition,
|
||||
parseFallback,
|
||||
normalizeReference,
|
||||
} = __testUtils;
|
||||
|
||||
function createResponse(status, body) {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
|
||||
test('refreshNodeInformation merges telemetry metrics when the base node lacks them', async () => {
|
||||
const calls = [];
|
||||
const responses = new Map([
|
||||
['/api/nodes/!test', createResponse(200, {
|
||||
node_id: '!test',
|
||||
short_name: 'TST',
|
||||
battery_level: null,
|
||||
last_heard: 1_000,
|
||||
})],
|
||||
['/api/telemetry/!test?limit=1', createResponse(200, [{
|
||||
node_id: '!test',
|
||||
battery_level: 73.5,
|
||||
rx_time: 1_200,
|
||||
telemetry_time: 1_180,
|
||||
voltage: 4.1,
|
||||
}])],
|
||||
['/api/positions/!test?limit=1', createResponse(200, [{
|
||||
node_id: '!test',
|
||||
latitude: 52.5,
|
||||
longitude: 13.4,
|
||||
rx_time: 1_100,
|
||||
}])],
|
||||
['/api/neighbors/!test?limit=1000', createResponse(200, [{
|
||||
node_id: '!test',
|
||||
neighbor_id: '!peer',
|
||||
snr: 9.5,
|
||||
rx_time: 1_150,
|
||||
}])],
|
||||
]);
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
const response = responses.get(url);
|
||||
if (!response) {
|
||||
return createResponse(404, { error: 'not found' });
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const fallback = { shortName: 'fallback', role: 'CLIENT' };
|
||||
const node = await refreshNodeInformation({ nodeId: '!test', fallback }, { fetchImpl });
|
||||
|
||||
assert.equal(node.nodeId, '!test');
|
||||
assert.equal(node.shortName, 'TST');
|
||||
assert.equal(node.battery, 73.5);
|
||||
assert.equal(node.voltage, 4.1);
|
||||
assert.equal(node.role, 'CLIENT');
|
||||
assert.equal(node.lastHeard, 1_200);
|
||||
assert.equal(node.telemetryTime, 1_180);
|
||||
assert.equal(node.latitude, 52.5);
|
||||
assert.equal(node.longitude, 13.4);
|
||||
assert.deepEqual(node.neighbors, [{
|
||||
node_id: '!test',
|
||||
neighbor_id: '!peer',
|
||||
snr: 9.5,
|
||||
rx_time: 1_150,
|
||||
}]);
|
||||
assert.ok(node.rawSources);
|
||||
assert.ok(node.rawSources.node);
|
||||
assert.ok(node.rawSources.telemetry);
|
||||
assert.ok(node.rawSources.position);
|
||||
|
||||
assert.equal(calls.length, 4);
|
||||
calls.forEach(call => {
|
||||
assert.deepEqual(call.options, { cache: 'no-store' });
|
||||
});
|
||||
});
|
||||
|
||||
test('refreshNodeInformation preserves fallback metrics when telemetry is unavailable', async () => {
|
||||
const responses = new Map([
|
||||
['/api/nodes/42', createResponse(200, {
|
||||
node_id: '!num',
|
||||
short_name: 'NUM',
|
||||
})],
|
||||
['/api/telemetry/42?limit=1', createResponse(404, { error: 'not found' })],
|
||||
['/api/positions/42?limit=1', createResponse(404, { error: 'not found' })],
|
||||
['/api/neighbors/42?limit=1000', createResponse(404, { error: 'not found' })],
|
||||
]);
|
||||
const fetchImpl = async (url, options) => {
|
||||
const response = responses.get(url);
|
||||
return response ?? createResponse(404, { error: 'not found' });
|
||||
};
|
||||
|
||||
const fallback = { nodeNum: 42, battery: 12.5, role: 'CLIENT' };
|
||||
const node = await refreshNodeInformation({ nodeNum: 42, fallback }, { fetchImpl });
|
||||
|
||||
assert.equal(node.nodeId, '!num');
|
||||
assert.equal(node.nodeNum, 42);
|
||||
assert.equal(node.shortName, 'NUM');
|
||||
assert.equal(node.battery, 12.5);
|
||||
assert.equal(node.role, 'CLIENT');
|
||||
assert.equal(Array.isArray(node.neighbors) && node.neighbors.length, 0);
|
||||
});
|
||||
|
||||
test('refreshNodeInformation requires a node identifier', async () => {
|
||||
await assert.rejects(() => refreshNodeInformation(null), /node identifier/i);
|
||||
});
|
||||
|
||||
test('refreshNodeInformation handles missing node records by falling back to telemetry data', async () => {
|
||||
const responses = new Map([
|
||||
['/api/nodes/!missing', createResponse(404, { error: 'not found' })],
|
||||
['/api/telemetry/!missing?limit=1', createResponse(200, [{
|
||||
node_id: '!missing',
|
||||
node_num: 77,
|
||||
battery_level: 66,
|
||||
rx_time: 2_000,
|
||||
telemetry_time: 1_950,
|
||||
}])],
|
||||
['/api/positions/!missing?limit=1', createResponse(200, [{
|
||||
node_id: '!missing',
|
||||
latitude: 1.23,
|
||||
longitude: 3.21,
|
||||
altitude: 42,
|
||||
position_time: 1_960,
|
||||
rx_time: 1_970,
|
||||
}])],
|
||||
['/api/neighbors/!missing?limit=1000', createResponse(200, [null, 'skip', {
|
||||
node_id: '!missing',
|
||||
neighbor_id: '!ally',
|
||||
snr: 8.5,
|
||||
}])],
|
||||
]);
|
||||
|
||||
const fetchImpl = async url => responses.get(url) ?? createResponse(404, { error: 'not found' });
|
||||
|
||||
const node = await refreshNodeInformation({ nodeId: '!missing' }, { fetchImpl });
|
||||
|
||||
assert.equal(node.nodeId, '!missing');
|
||||
assert.equal(node.nodeNum, 77);
|
||||
assert.equal(node.battery, 66);
|
||||
assert.equal(node.lastHeard, 2_000);
|
||||
assert.equal(node.telemetryTime, 1_950);
|
||||
assert.equal(node.positionTime, 1_960);
|
||||
assert.equal(node.latitude, 1.23);
|
||||
assert.equal(node.longitude, 3.21);
|
||||
assert.equal(node.altitude, 42);
|
||||
assert.equal(node.role, 'CLIENT');
|
||||
assert.deepEqual(node.neighbors, [{
|
||||
node_id: '!missing',
|
||||
neighbor_id: '!ally',
|
||||
snr: 8.5,
|
||||
}]);
|
||||
});
|
||||
|
||||
test('refreshNodeInformation enforces a fetch implementation', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
// eslint-disable-next-line no-global-assign
|
||||
globalThis.fetch = undefined;
|
||||
try {
|
||||
await assert.rejects(() => refreshNodeInformation('!test', { fetchImpl: null }), /fetch implementation/i);
|
||||
} finally {
|
||||
// eslint-disable-next-line no-global-assign
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('helper utilities normalise primitive values', () => {
|
||||
assert.equal(toTrimmedString(' hello '), 'hello');
|
||||
assert.equal(toTrimmedString(''), null);
|
||||
assert.equal(toTrimmedString(null), null);
|
||||
|
||||
assert.equal(toFiniteNumber('42.5'), 42.5);
|
||||
assert.equal(toFiniteNumber('bad'), null);
|
||||
assert.equal(toFiniteNumber(Infinity), null);
|
||||
|
||||
assert.equal(extractString({ name: ' Alice ' }, ['missing', 'name']), 'Alice');
|
||||
assert.equal(extractString(null, ['name']), null);
|
||||
|
||||
assert.equal(extractNumber({ value: ' 13 ' }, ['missing', 'value']), 13);
|
||||
assert.equal(extractNumber({}, ['value']), null);
|
||||
});
|
||||
|
||||
test('assign helpers respect preferExisting semantics', () => {
|
||||
const target = {};
|
||||
assignString(target, 'name', ' primary ');
|
||||
assignString(target, 'name', 'secondary', { preferExisting: true });
|
||||
assignString(target, 'description', '');
|
||||
assignNumber(target, 'count', '25');
|
||||
assignNumber(target, 'count', 13, { preferExisting: true });
|
||||
assignNumber(target, 'ignored', 'oops');
|
||||
|
||||
assert.deepEqual(target, { name: 'primary', count: 25 });
|
||||
});
|
||||
|
||||
test('merge helpers combine node, telemetry, and position data', () => {
|
||||
const node = {};
|
||||
mergeNodeFields(node, {
|
||||
node_id: '!node',
|
||||
node_num: 55,
|
||||
short_name: 'NODE',
|
||||
battery_level: null,
|
||||
last_heard: 1_000,
|
||||
position_time: 900,
|
||||
});
|
||||
|
||||
node.battery = 50;
|
||||
|
||||
mergeTelemetry(node, {
|
||||
node_id: '!node',
|
||||
battery_level: 75,
|
||||
voltage: 3.8,
|
||||
rx_time: 1_200,
|
||||
rx_iso: '2025-01-01T00:00:00Z',
|
||||
telemetry_time: 1_150,
|
||||
});
|
||||
|
||||
mergePosition(node, {
|
||||
node_id: '!node',
|
||||
latitude: 52.5,
|
||||
longitude: 13.4,
|
||||
altitude: 80,
|
||||
position_time: 1_180,
|
||||
position_time_iso: '2025-01-01T00:19:40Z',
|
||||
rx_time: 1_100,
|
||||
rx_iso: '2025-01-01T00:18:20Z',
|
||||
});
|
||||
|
||||
assert.equal(node.nodeId, '!node');
|
||||
assert.equal(node.nodeNum, 55);
|
||||
assert.equal(node.shortName, 'NODE');
|
||||
assert.equal(node.battery, 50);
|
||||
assert.equal(node.voltage, 3.8);
|
||||
assert.equal(node.lastHeard, 1_200);
|
||||
assert.equal(node.lastSeenIso, '2025-01-01T00:00:00Z');
|
||||
assert.equal(node.telemetryTime, 1_150);
|
||||
assert.equal(node.positionTime, 1_180);
|
||||
assert.equal(node.positionTimeIso, '2025-01-01T00:19:40Z');
|
||||
assert.equal(node.latitude, 52.5);
|
||||
assert.equal(node.longitude, 13.4);
|
||||
assert.equal(node.altitude, 80);
|
||||
assert.ok(node.telemetry);
|
||||
assert.ok(node.position);
|
||||
});
|
||||
|
||||
test('normalizeReference extracts identifiers and tolerates malformed fallback payloads', () => {
|
||||
const originalWarn = console.warn;
|
||||
const warnings = [];
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
|
||||
try {
|
||||
const parsed = normalizeReference({
|
||||
nodeId: ' ',
|
||||
fallback: '{"node_id":"!parsed","nodeNum":99}',
|
||||
});
|
||||
assert.equal(parsed.nodeId, '!parsed');
|
||||
assert.equal(parsed.nodeNum, 99);
|
||||
assert.ok(parsed.fallback);
|
||||
|
||||
const invalid = normalizeReference({ fallback: '{not json}' });
|
||||
assert.equal(invalid.nodeId, null);
|
||||
assert.equal(invalid.nodeNum, null);
|
||||
assert.equal(invalid.fallback, null);
|
||||
|
||||
const strRef = normalizeReference('!direct');
|
||||
assert.equal(strRef.nodeId, '!direct');
|
||||
assert.equal(strRef.nodeNum, null);
|
||||
|
||||
const numRef = normalizeReference(57);
|
||||
assert.equal(numRef.nodeId, null);
|
||||
assert.equal(numRef.nodeNum, 57);
|
||||
|
||||
const emptyRef = normalizeReference(undefined);
|
||||
assert.equal(emptyRef.nodeId, null);
|
||||
assert.equal(emptyRef.nodeNum, null);
|
||||
assert.equal(emptyRef.fallback, null);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
assert.ok(warnings.length >= 1);
|
||||
});
|
||||
|
||||
test('parseFallback duplicates object references and rejects primitives', () => {
|
||||
const fallbackObject = { nodeId: '!object' };
|
||||
const parsedObject = parseFallback(fallbackObject);
|
||||
assert.notEqual(parsedObject, fallbackObject);
|
||||
assert.deepEqual(parsedObject, fallbackObject);
|
||||
|
||||
const parsedString = parseFallback('{"nodeId":"!string"}');
|
||||
assert.ok(parsedString);
|
||||
assert.equal(parsedString.nodeId, '!string');
|
||||
assert.equal(parseFallback('not json'), null);
|
||||
assert.equal(parseFallback(42), null);
|
||||
});
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
import { computeBoundingBox, computeBoundsForPoints, haversineDistanceKm } from './map-bounds.js';
|
||||
import { createMapAutoFitController } from './map-auto-fit-controller.js';
|
||||
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
|
||||
import { refreshNodeInformation } from './node-details.js';
|
||||
|
||||
/**
|
||||
* Entry point for the interactive dashboard. Wires up event listeners,
|
||||
@@ -100,6 +102,8 @@ export function initializeApp(config) {
|
||||
let nodesById = new Map();
|
||||
/** @type {HTMLElement|null} */
|
||||
let shortInfoAnchor = null;
|
||||
/** @type {number} */
|
||||
let shortInfoRequestToken = 0;
|
||||
/** @type {string|undefined} */
|
||||
let lastChatDate;
|
||||
const NODE_LIMIT = 1000;
|
||||
@@ -1397,27 +1401,86 @@ export function initializeApp(config) {
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const shortTarget = event.target.closest('.short-name');
|
||||
if (shortTarget && shortTarget.dataset && shortTarget.dataset.nodeInfo) {
|
||||
if (
|
||||
shortTarget &&
|
||||
shortTarget.dataset &&
|
||||
(shortTarget.dataset.nodeInfo || shortTarget.dataset.nodeId || shortTarget.dataset.nodeNum)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
let info = null;
|
||||
try {
|
||||
info = JSON.parse(shortTarget.dataset.nodeInfo);
|
||||
} catch (err) {
|
||||
console.warn('Failed to parse node info payload', err);
|
||||
|
||||
let fallbackInfo = null;
|
||||
if (shortTarget.dataset.nodeInfo) {
|
||||
try {
|
||||
fallbackInfo = JSON.parse(shortTarget.dataset.nodeInfo);
|
||||
} catch (err) {
|
||||
console.warn('Failed to parse node info payload', err);
|
||||
}
|
||||
}
|
||||
if (!info) return;
|
||||
if (!info.shortName && shortTarget.textContent) {
|
||||
info.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
|
||||
if (!fallbackInfo || typeof fallbackInfo !== 'object') {
|
||||
fallbackInfo = {};
|
||||
}
|
||||
if (!info.role) {
|
||||
info.role = 'CLIENT';
|
||||
|
||||
const datasetNodeId = typeof shortTarget.dataset.nodeId === 'string'
|
||||
? shortTarget.dataset.nodeId.trim()
|
||||
: '';
|
||||
if (datasetNodeId && !fallbackInfo.nodeId && !fallbackInfo.node_id) {
|
||||
fallbackInfo.nodeId = datasetNodeId;
|
||||
}
|
||||
|
||||
if (fallbackInfo.nodeNum == null && fallbackInfo.node_num == null && shortTarget.dataset.nodeNum != null) {
|
||||
const parsedDatasetNum = Number(shortTarget.dataset.nodeNum);
|
||||
if (Number.isFinite(parsedDatasetNum)) {
|
||||
fallbackInfo.nodeNum = parsedDatasetNum;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackInfo.shortName && shortTarget.textContent) {
|
||||
fallbackInfo.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
|
||||
}
|
||||
|
||||
const fallbackDetails = mergeOverlayDetails(null, fallbackInfo);
|
||||
if (!fallbackDetails.shortName && shortTarget.textContent) {
|
||||
fallbackDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
|
||||
fallbackInfo.shortName = fallbackDetails.shortName;
|
||||
}
|
||||
|
||||
if (shortInfoOverlay && !shortInfoOverlay.hidden && shortInfoAnchor === shortTarget) {
|
||||
closeShortInfoOverlay();
|
||||
} else {
|
||||
openShortInfoOverlay(shortTarget, info);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeId = typeof fallbackDetails.nodeId === 'string' && fallbackDetails.nodeId.trim().length
|
||||
? fallbackDetails.nodeId.trim()
|
||||
: '';
|
||||
const nodeNum = Number.isFinite(fallbackDetails.nodeNum) ? fallbackDetails.nodeNum : null;
|
||||
|
||||
if (!nodeId && !nodeNum) {
|
||||
openShortInfoOverlay(shortTarget, fallbackDetails);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++shortInfoRequestToken;
|
||||
showShortInfoLoading(shortTarget, fallbackDetails);
|
||||
|
||||
refreshNodeInformation({ nodeId: nodeId || undefined, nodeNum: nodeNum ?? undefined, fallback: fallbackInfo })
|
||||
.then(details => {
|
||||
if (requestId !== shortInfoRequestToken) return;
|
||||
const overlayDetails = mergeOverlayDetails(details, fallbackInfo);
|
||||
if (!overlayDetails.shortName && shortTarget.textContent) {
|
||||
overlayDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
|
||||
}
|
||||
openShortInfoOverlay(shortTarget, overlayDetails);
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('Failed to refresh node information', err);
|
||||
if (requestId !== shortInfoRequestToken) return;
|
||||
const overlayDetails = mergeOverlayDetails(null, fallbackInfo);
|
||||
if (!overlayDetails.shortName && shortTarget.textContent) {
|
||||
overlayDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
|
||||
}
|
||||
openShortInfoOverlay(shortTarget, overlayDetails);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('.neighbor-connection-line')) {
|
||||
@@ -1473,6 +1536,7 @@ export function initializeApp(config) {
|
||||
if (nodeData && typeof nodeData === 'object') {
|
||||
const info = {
|
||||
nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
|
||||
nodeNum: nodeData.num ?? nodeData.node_num ?? nodeData.nodeNum ?? null,
|
||||
shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
|
||||
longName: nodeData.long_name ?? longName ?? '',
|
||||
role: roleValue,
|
||||
@@ -1487,7 +1551,16 @@ export function initializeApp(config) {
|
||||
pressure: nodeData.barometric_pressure ?? nodeData.barometricPressure ?? nodeData.pressure ?? null,
|
||||
telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null,
|
||||
};
|
||||
infoAttr = ` data-node-info="${escapeHtml(JSON.stringify(info))}"`;
|
||||
const attrParts = [` data-node-info="${escapeHtml(JSON.stringify(info))}"`];
|
||||
const attrNodeIdRaw = info.nodeId != null ? String(info.nodeId).trim() : '';
|
||||
if (attrNodeIdRaw) {
|
||||
attrParts.push(` data-node-id="${escapeHtml(attrNodeIdRaw)}"`);
|
||||
}
|
||||
const attrNodeNum = Number(info.nodeNum);
|
||||
if (Number.isFinite(attrNodeNum)) {
|
||||
attrParts.push(` data-node-num="${escapeHtml(String(attrNodeNum))}"`);
|
||||
}
|
||||
infoAttr = attrParts.join('');
|
||||
}
|
||||
if (!short) {
|
||||
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>? </span>`;
|
||||
@@ -1581,6 +1654,87 @@ export function initializeApp(config) {
|
||||
return `${shortHtml}<span class="neighbor-snr">(SNR ${escapeHtml(snrText)})</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTML markup describing a node for a Leaflet popup.
|
||||
*
|
||||
* @param {Object} node Map node payload with snake_case keys.
|
||||
* @param {number} nowSec Reference timestamp for relative calculations.
|
||||
* @returns {string} HTML snippet rendered inside the popup.
|
||||
*/
|
||||
function buildMapPopupHtml(node, nowSec) {
|
||||
const lines = [];
|
||||
const longName = node && node.long_name ? escapeHtml(String(node.long_name)) : '';
|
||||
if (longName) {
|
||||
lines.push(`<b>${longName}</b>`);
|
||||
}
|
||||
|
||||
const shortHtml = renderShortHtml(node?.short_name, node?.role, node?.long_name, node);
|
||||
const nodeIdText = node && node.node_id ? `<span class="mono">${escapeHtml(String(node.node_id))}</span>` : '';
|
||||
const shortParts = [];
|
||||
if (shortHtml) shortParts.push(shortHtml);
|
||||
if (nodeIdText) shortParts.push(nodeIdText);
|
||||
if (shortParts.length) {
|
||||
lines.push(shortParts.join(' '));
|
||||
}
|
||||
|
||||
const hardwareText = fmtHw(node?.hw_model);
|
||||
if (hardwareText) {
|
||||
lines.push(`Model: ${escapeHtml(hardwareText)}`);
|
||||
}
|
||||
|
||||
const roleValue = node?.role || 'CLIENT';
|
||||
if (roleValue) {
|
||||
lines.push(`Role: ${escapeHtml(roleValue)}`);
|
||||
}
|
||||
|
||||
const batteryParts = [];
|
||||
const batteryText = fmtAlt(node?.battery_level, '%');
|
||||
if (batteryText) batteryParts.push(batteryText);
|
||||
const voltageText = fmtAlt(node?.voltage, 'V');
|
||||
if (voltageText) batteryParts.push(voltageText);
|
||||
if (batteryParts.length) {
|
||||
lines.push(`Battery: ${batteryParts.join(', ')}`);
|
||||
}
|
||||
|
||||
const temperatureText = fmtTemperature(node?.temperature);
|
||||
if (temperatureText) {
|
||||
lines.push(`Temperature: ${temperatureText}`);
|
||||
}
|
||||
const humidityText = fmtHumidity(node?.relative_humidity);
|
||||
if (humidityText) {
|
||||
lines.push(`Humidity: ${humidityText}`);
|
||||
}
|
||||
const pressureText = fmtPressure(node?.barometric_pressure);
|
||||
if (pressureText) {
|
||||
lines.push(`Pressure: ${pressureText}`);
|
||||
}
|
||||
|
||||
const lastHeardNum = Number(node?.last_heard);
|
||||
if (Number.isFinite(lastHeardNum) && lastHeardNum > 0) {
|
||||
lines.push(`Last seen: ${timeAgo(lastHeardNum, nowSec)}`);
|
||||
}
|
||||
|
||||
const uptimeNum = Number(node?.uptime_seconds);
|
||||
if (Number.isFinite(uptimeNum) && uptimeNum > 0) {
|
||||
lines.push(`Uptime: ${timeHum(uptimeNum)}`);
|
||||
}
|
||||
|
||||
const overlayNeighbors = Array.isArray(node?.neighbors) ? node.neighbors : [];
|
||||
const neighborEntries = overlayNeighbors.length
|
||||
? overlayNeighbors
|
||||
: getNeighborNodesFor(node?.node_id ?? '');
|
||||
if (neighborEntries.length) {
|
||||
const neighborParts = neighborEntries
|
||||
.map(renderNeighborWithSnrHtml)
|
||||
.filter(html => html && html.length);
|
||||
if (neighborParts.length) {
|
||||
lines.push(`Neighbors: ${neighborParts.join(' ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('<br/>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format uptime values for the short-info overlay.
|
||||
*
|
||||
@@ -1621,12 +1775,129 @@ export function initializeApp(config) {
|
||||
lines.push(`${escapeHtml(label)}: ${escapeHtml(String(formatted))}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a node-shaped payload into the overlay data format.
|
||||
*
|
||||
* @param {*} source Arbitrary node data.
|
||||
* @returns {Object} Normalized overlay payload.
|
||||
*/
|
||||
function normalizeOverlaySource(source) {
|
||||
if (!source || typeof source !== 'object') return {};
|
||||
const normalized = {};
|
||||
const nodeIdRaw = source.nodeId ?? source.node_id;
|
||||
if (typeof nodeIdRaw === 'string' && nodeIdRaw.trim().length > 0) {
|
||||
normalized.nodeId = nodeIdRaw.trim();
|
||||
}
|
||||
const nodeNumRaw = source.nodeNum ?? source.node_num ?? source.num;
|
||||
const nodeNumParsed = Number(nodeNumRaw);
|
||||
if (Number.isFinite(nodeNumParsed)) {
|
||||
normalized.nodeNum = nodeNumParsed;
|
||||
}
|
||||
const shortRaw = source.shortName ?? source.short_name;
|
||||
if (shortRaw != null && String(shortRaw).trim().length > 0) {
|
||||
normalized.shortName = String(shortRaw).trim();
|
||||
}
|
||||
const longRaw = source.longName ?? source.long_name;
|
||||
if (longRaw != null && String(longRaw).trim().length > 0) {
|
||||
normalized.longName = String(longRaw).trim();
|
||||
}
|
||||
if (source.role && String(source.role).trim().length > 0) {
|
||||
normalized.role = String(source.role).trim();
|
||||
}
|
||||
if (source.hwModel ?? source.hw_model) {
|
||||
normalized.hwModel = source.hwModel ?? source.hw_model;
|
||||
}
|
||||
|
||||
const numericPairs = [
|
||||
['battery', source.battery ?? source.battery_level],
|
||||
['voltage', source.voltage],
|
||||
['uptime', source.uptime ?? source.uptime_seconds],
|
||||
['channel', source.channel ?? source.channel_utilization],
|
||||
['airUtil', source.airUtil ?? source.air_util_tx],
|
||||
['temperature', source.temperature],
|
||||
['humidity', source.humidity ?? source.relative_humidity],
|
||||
['pressure', source.pressure ?? source.barometric_pressure],
|
||||
['telemetryTime', source.telemetryTime ?? source.telemetry_time],
|
||||
['lastHeard', source.lastHeard ?? source.last_heard],
|
||||
['latitude', source.latitude],
|
||||
['longitude', source.longitude],
|
||||
['altitude', source.altitude],
|
||||
['positionTime', source.positionTime ?? source.position_time],
|
||||
];
|
||||
for (const [key, value] of numericPairs) {
|
||||
if (value == null || value === '') continue;
|
||||
const num = Number(value);
|
||||
if (Number.isFinite(num)) {
|
||||
normalized[key] = num;
|
||||
}
|
||||
}
|
||||
|
||||
const lastSeenRaw = source.lastSeenIso ?? source.last_seen_iso;
|
||||
if (typeof lastSeenRaw === 'string' && lastSeenRaw.trim().length > 0) {
|
||||
normalized.lastSeenIso = lastSeenRaw.trim();
|
||||
}
|
||||
const positionIsoRaw = source.positionTimeIso ?? source.position_time_iso;
|
||||
if (typeof positionIsoRaw === 'string' && positionIsoRaw.trim().length > 0) {
|
||||
normalized.positionTimeIso = positionIsoRaw.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(source.neighbors)) {
|
||||
const overlayNeighbors = overlayToPopupNode({ neighbors: source.neighbors }).neighbors;
|
||||
if (overlayNeighbors.length) {
|
||||
normalized.neighbors = overlayNeighbors;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine primary and fallback node information into an overlay payload.
|
||||
*
|
||||
* @param {*} primary Primary node details (e.g. fetched from the API).
|
||||
* @param {*} fallback Fallback node details rendered with the page.
|
||||
* @returns {Object} Overlay payload ready for rendering.
|
||||
*/
|
||||
function mergeOverlayDetails(primary, fallback) {
|
||||
const fallbackNormalized = normalizeOverlaySource(fallback);
|
||||
const primaryNormalized = normalizeOverlaySource(primary);
|
||||
const merged = { ...fallbackNormalized, ...primaryNormalized };
|
||||
const neighborList = primaryNormalized.neighbors ?? fallbackNormalized.neighbors;
|
||||
if (neighborList) {
|
||||
merged.neighbors = neighborList;
|
||||
}
|
||||
if (!merged.role || merged.role === '') {
|
||||
merged.role = 'CLIENT';
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a temporary loading state while node details are fetched.
|
||||
*
|
||||
* @param {HTMLElement} target Anchor element associated with the overlay.
|
||||
* @param {Object} [info] Optional fallback information describing the node.
|
||||
* @returns {void}
|
||||
*/
|
||||
function showShortInfoLoading(target, info) {
|
||||
if (!shortInfoOverlay || !shortInfoContent) return;
|
||||
const normalized = normalizeOverlaySource(info || {});
|
||||
const heading = normalized.longName || normalized.shortName || normalized.nodeId || '';
|
||||
const headingHtml = heading ? `<strong>${escapeHtml(heading)}</strong><br/>` : '';
|
||||
shortInfoContent.innerHTML = `${headingHtml}Loading…`;
|
||||
shortInfoAnchor = target;
|
||||
shortInfoOverlay.hidden = false;
|
||||
shortInfoOverlay.style.visibility = 'hidden';
|
||||
requestAnimationFrame(positionShortInfoOverlay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the short-info overlay used for inline node details.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function closeShortInfoOverlay() {
|
||||
shortInfoRequestToken += 1;
|
||||
if (!shortInfoOverlay) return;
|
||||
shortInfoOverlay.hidden = true;
|
||||
shortInfoOverlay.style.visibility = 'visible';
|
||||
@@ -1668,29 +1939,35 @@ export function initializeApp(config) {
|
||||
*/
|
||||
function openShortInfoOverlay(target, info) {
|
||||
if (!shortInfoOverlay || !shortInfoContent || !info) return;
|
||||
const overlayInfo = normalizeOverlaySource(info);
|
||||
if (!overlayInfo.role || overlayInfo.role === '') {
|
||||
overlayInfo.role = 'CLIENT';
|
||||
}
|
||||
const lines = [];
|
||||
const longNameValue = shortInfoValueOrDash(info.longName ?? '');
|
||||
const longNameValue = shortInfoValueOrDash(overlayInfo.longName ?? '');
|
||||
if (longNameValue !== '—') {
|
||||
lines.push(`<strong>${escapeHtml(longNameValue)}</strong>`);
|
||||
}
|
||||
const shortParts = [];
|
||||
const shortHtml = renderShortHtml(info.shortName, info.role, info.longName);
|
||||
const shortHtml = renderShortHtml(overlayInfo.shortName, overlayInfo.role, overlayInfo.longName);
|
||||
if (shortHtml) {
|
||||
shortParts.push(shortHtml);
|
||||
}
|
||||
const nodeIdValue = shortInfoValueOrDash(info.nodeId ?? '');
|
||||
const nodeIdValue = shortInfoValueOrDash(overlayInfo.nodeId ?? '');
|
||||
if (nodeIdValue !== '—') {
|
||||
shortParts.push(`<span class="mono">${escapeHtml(nodeIdValue)}</span>`);
|
||||
}
|
||||
if (shortParts.length) {
|
||||
lines.push(shortParts.join(' '));
|
||||
}
|
||||
const roleValue = shortInfoValueOrDash(info.role || 'CLIENT');
|
||||
const roleValue = shortInfoValueOrDash(overlayInfo.role || 'CLIENT');
|
||||
if (roleValue !== '—') {
|
||||
lines.push(`Role: ${escapeHtml(roleValue)}`);
|
||||
}
|
||||
let neighborLineHtml = '';
|
||||
const neighborEntries = getNeighborNodesFor(info.nodeId);
|
||||
const neighborEntries = Array.isArray(overlayInfo.neighbors) && overlayInfo.neighbors.some(entry => entry && entry.node)
|
||||
? overlayInfo.neighbors
|
||||
: getNeighborNodesFor(overlayInfo.nodeId);
|
||||
if (neighborEntries.length) {
|
||||
const neighborParts = neighborEntries
|
||||
.map(renderNeighborWithSnrHtml)
|
||||
@@ -1699,18 +1976,18 @@ export function initializeApp(config) {
|
||||
neighborLineHtml = `Neighbors: ${neighborParts.join(' ')}`;
|
||||
}
|
||||
}
|
||||
const modelValue = fmtHw(info.hwModel);
|
||||
const modelValue = fmtHw(overlayInfo.hwModel);
|
||||
if (modelValue) {
|
||||
lines.push(`Model: ${escapeHtml(modelValue)}`);
|
||||
}
|
||||
appendTelemetryLine(lines, 'Battery', info.battery, value => fmtAlt(value, '%'));
|
||||
appendTelemetryLine(lines, 'Voltage', info.voltage, value => fmtAlt(value, 'V'));
|
||||
appendTelemetryLine(lines, 'Uptime', info.uptime, formatShortInfoUptime);
|
||||
appendTelemetryLine(lines, 'Channel Util', info.channel, fmtTx);
|
||||
appendTelemetryLine(lines, 'Air Util Tx', info.airUtil, fmtTx);
|
||||
appendTelemetryLine(lines, 'Temperature', info.temperature, fmtTemperature);
|
||||
appendTelemetryLine(lines, 'Humidity', info.humidity, fmtHumidity);
|
||||
appendTelemetryLine(lines, 'Pressure', info.pressure, fmtPressure);
|
||||
appendTelemetryLine(lines, 'Battery', overlayInfo.battery, value => fmtAlt(value, '%'));
|
||||
appendTelemetryLine(lines, 'Voltage', overlayInfo.voltage, value => fmtAlt(value, 'V'));
|
||||
appendTelemetryLine(lines, 'Uptime', overlayInfo.uptime, formatShortInfoUptime);
|
||||
appendTelemetryLine(lines, 'Channel Util', overlayInfo.channel, fmtTx);
|
||||
appendTelemetryLine(lines, 'Air Util Tx', overlayInfo.airUtil, fmtTx);
|
||||
appendTelemetryLine(lines, 'Temperature', overlayInfo.temperature, fmtTemperature);
|
||||
appendTelemetryLine(lines, 'Humidity', overlayInfo.humidity, fmtHumidity);
|
||||
appendTelemetryLine(lines, 'Pressure', overlayInfo.pressure, fmtPressure);
|
||||
if (neighborLineHtml) {
|
||||
lines.push(neighborLineHtml);
|
||||
}
|
||||
@@ -2554,51 +2831,65 @@ export function initializeApp(config) {
|
||||
fillOpacity: 0.7,
|
||||
opacity: 0.7
|
||||
});
|
||||
const lines = [];
|
||||
lines.push(`<b>${n.long_name || ''}</b>`);
|
||||
lines.push(`${renderShortHtml(n.short_name, n.role, n.long_name, n)} <span class="mono">${n.node_id || ''}</span>`);
|
||||
if (n.hw_model) {
|
||||
lines.push(`Model: ${fmtHw(n.hw_model)}`);
|
||||
}
|
||||
lines.push(`Role: ${n.role || 'CLIENT'}`);
|
||||
const batteryParts = [];
|
||||
const batteryText = fmtAlt(n.battery_level, "%");
|
||||
if (batteryText) batteryParts.push(batteryText);
|
||||
const voltageText = fmtAlt(n.voltage, "V");
|
||||
if (voltageText) batteryParts.push(voltageText);
|
||||
if (batteryParts.length) {
|
||||
lines.push(`Battery: ${batteryParts.join(', ')}`);
|
||||
}
|
||||
const tempText = fmtTemperature(n.temperature);
|
||||
if (tempText) {
|
||||
lines.push(`Temperature: ${tempText}`);
|
||||
}
|
||||
const humidityText = fmtHumidity(n.relative_humidity);
|
||||
if (humidityText) {
|
||||
lines.push(`Humidity: ${humidityText}`);
|
||||
}
|
||||
const pressureText = fmtPressure(n.barometric_pressure);
|
||||
if (pressureText) {
|
||||
lines.push(`Pressure: ${pressureText}`);
|
||||
}
|
||||
if (n.last_heard) {
|
||||
lines.push(`Last seen: ${timeAgo(n.last_heard, nowSec)}`);
|
||||
}
|
||||
if (n.uptime_seconds) {
|
||||
lines.push(`Uptime: ${timeHum(n.uptime_seconds)}`);
|
||||
}
|
||||
const mapNeighborEntries = getNeighborNodesFor(n.node_id ?? n.nodeId ?? '');
|
||||
if (mapNeighborEntries.length) {
|
||||
const neighborParts = mapNeighborEntries
|
||||
.map(renderNeighborWithSnrHtml)
|
||||
.filter(html => html && html.length);
|
||||
if (neighborParts.length) {
|
||||
lines.push(`Neighbors: ${neighborParts.join(' ')}`);
|
||||
}
|
||||
}
|
||||
marker.bindPopup(lines.join('<br/>'));
|
||||
|
||||
const fallbackOverlayProvider = () => mergeOverlayDetails(null, n);
|
||||
const initialOverlay = fallbackOverlayProvider();
|
||||
const initialPopupHtml = buildMapPopupHtml(overlayToPopupNode(initialOverlay), nowSec);
|
||||
|
||||
marker.bindPopup(initialPopupHtml);
|
||||
marker.addTo(markersLayer);
|
||||
pts.push([lat, lon]);
|
||||
|
||||
const updateMarkerPopup = overlayDetails => {
|
||||
const popupNode = overlayToPopupNode(overlayDetails);
|
||||
const html = buildMapPopupHtml(popupNode, Math.floor(Date.now() / 1000));
|
||||
if (typeof marker.setPopupContent === 'function') {
|
||||
marker.setPopupContent(html);
|
||||
return;
|
||||
}
|
||||
if (typeof marker.getPopup === 'function') {
|
||||
const popup = marker.getPopup();
|
||||
if (popup && typeof popup.setContent === 'function') {
|
||||
popup.setContent(html);
|
||||
return;
|
||||
}
|
||||
}
|
||||
marker.bindPopup(html);
|
||||
};
|
||||
|
||||
attachNodeInfoRefreshToMarker({
|
||||
marker,
|
||||
getOverlayFallback: fallbackOverlayProvider,
|
||||
refreshNodeInformation,
|
||||
mergeOverlayDetails,
|
||||
createRequestToken: () => ++shortInfoRequestToken,
|
||||
isTokenCurrent: token => token === shortInfoRequestToken,
|
||||
showLoading: (anchor, info) => {
|
||||
if (anchor) {
|
||||
showShortInfoLoading(anchor, info);
|
||||
}
|
||||
},
|
||||
showDetails: (anchor, info) => {
|
||||
if (anchor) {
|
||||
openShortInfoOverlay(anchor, info);
|
||||
}
|
||||
},
|
||||
showError: (anchor, info, error) => {
|
||||
console.warn('Failed to refresh node information for map marker', error);
|
||||
if (anchor) {
|
||||
openShortInfoOverlay(anchor, info);
|
||||
}
|
||||
},
|
||||
updatePopup: updateMarkerPopup,
|
||||
shouldHandleClick: anchor => {
|
||||
if (!anchor) return true;
|
||||
if (shortInfoOverlay && !shortInfoOverlay.hidden && shortInfoAnchor === anchor) {
|
||||
closeShortInfoOverlay();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
if (pts.length && fitBoundsEl && fitBoundsEl.checked) {
|
||||
const bounds = computeBoundsForPoints(pts, {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* Copyright (C) 2025 l5yth
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determine whether the provided value behaves like a plain object.
|
||||
*
|
||||
* @param {*} value Candidate value.
|
||||
* @returns {boolean} True when ``value`` is a non-null object.
|
||||
*/
|
||||
function isObject(value) {
|
||||
return value != null && typeof value === 'object';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value to a trimmed string when possible.
|
||||
*
|
||||
* @param {*} value Input value.
|
||||
* @returns {string|null} Trimmed string or ``null`` when blank.
|
||||
*/
|
||||
function toTrimmedString(value) {
|
||||
if (value == null) return null;
|
||||
const str = String(value).trim();
|
||||
return str.length === 0 ? null : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to coerce the provided value into a finite number.
|
||||
*
|
||||
* @param {*} value Raw value.
|
||||
* @returns {number|null} Finite number or ``null`` when coercion fails.
|
||||
*/
|
||||
function toFiniteNumber(value) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (value == null || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a neighbour entry so that downstream consumers can display it.
|
||||
*
|
||||
* @param {*} entry Raw neighbour entry.
|
||||
* @returns {Object|null} Normalised neighbour reference or ``null`` when invalid.
|
||||
*/
|
||||
function normaliseNeighbor(entry) {
|
||||
if (!isObject(entry)) return null;
|
||||
const neighborId = toTrimmedString(entry.neighbor_id ?? entry.neighborId ?? entry.nodeId ?? entry.node_id);
|
||||
if (!neighborId) return null;
|
||||
const neighborShort = toTrimmedString(entry.neighbor_short_name ?? entry.neighborShortName ?? entry.short_name ?? entry.shortName);
|
||||
const neighborLong = toTrimmedString(entry.neighbor_long_name ?? entry.neighborLongName ?? entry.long_name ?? entry.longName);
|
||||
const neighborRole = toTrimmedString(entry.neighbor_role ?? entry.neighborRole ?? entry.role) || 'CLIENT';
|
||||
const node = {
|
||||
node_id: neighborId,
|
||||
short_name: neighborShort ?? '',
|
||||
long_name: neighborLong ?? '',
|
||||
role: neighborRole,
|
||||
};
|
||||
const snr = toFiniteNumber(entry.snr);
|
||||
const rxTime = toFiniteNumber(entry.rx_time ?? entry.rxTime);
|
||||
const result = { node };
|
||||
if (snr != null) {
|
||||
result.snr = snr;
|
||||
}
|
||||
if (rxTime != null) {
|
||||
result.rxTime = rxTime;
|
||||
result.rx_time = rxTime;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert overlay node details into a map friendly payload.
|
||||
*
|
||||
* @param {*} source Raw overlay details.
|
||||
* @returns {Object} Map node payload containing snake_case keys.
|
||||
*/
|
||||
export function overlayToPopupNode(source) {
|
||||
if (!isObject(source)) {
|
||||
return {
|
||||
node_id: '',
|
||||
node_num: null,
|
||||
short_name: '',
|
||||
long_name: '',
|
||||
role: 'CLIENT',
|
||||
neighbors: [],
|
||||
};
|
||||
}
|
||||
|
||||
const nodeId = toTrimmedString(source.nodeId ?? source.node_id ?? source.id) ?? '';
|
||||
const nodeNum = toFiniteNumber(source.nodeNum ?? source.node_num ?? source.num);
|
||||
const role = toTrimmedString(source.role) || 'CLIENT';
|
||||
const neighbours = Array.isArray(source.neighbors)
|
||||
? source.neighbors.map(normaliseNeighbor).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const payload = {
|
||||
node_id: nodeId,
|
||||
node_num: nodeNum,
|
||||
short_name: toTrimmedString(source.shortName ?? source.short_name ?? source.name) ?? '',
|
||||
long_name: toTrimmedString(source.longName ?? source.long_name ?? source.fullName ?? '') ?? '',
|
||||
role,
|
||||
hw_model: toTrimmedString(source.hwModel ?? source.hw_model ?? source.hardware) ?? '',
|
||||
battery_level: toFiniteNumber(source.battery ?? source.battery_level),
|
||||
voltage: toFiniteNumber(source.voltage),
|
||||
uptime_seconds: toFiniteNumber(source.uptime ?? source.uptime_seconds),
|
||||
channel_utilization: toFiniteNumber(source.channel ?? source.channel_utilization),
|
||||
air_util_tx: toFiniteNumber(source.airUtil ?? source.air_util_tx),
|
||||
temperature: toFiniteNumber(source.temperature),
|
||||
relative_humidity: toFiniteNumber(source.humidity ?? source.relative_humidity),
|
||||
barometric_pressure: toFiniteNumber(source.pressure ?? source.barometric_pressure),
|
||||
telemetry_time: toFiniteNumber(source.telemetryTime ?? source.telemetry_time),
|
||||
last_heard: toFiniteNumber(source.lastHeard ?? source.last_heard),
|
||||
position_time: toFiniteNumber(source.positionTime ?? source.position_time),
|
||||
latitude: toFiniteNumber(source.latitude),
|
||||
longitude: toFiniteNumber(source.longitude),
|
||||
altitude: toFiniteNumber(source.altitude),
|
||||
neighbors: neighbours,
|
||||
};
|
||||
|
||||
if (!payload.long_name && payload.short_name) {
|
||||
payload.long_name = payload.short_name;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach an asynchronous refresh handler to a Leaflet marker so that
|
||||
* up-to-date node information is fetched whenever the marker is clicked.
|
||||
*
|
||||
* @param {Object} options Behaviour configuration.
|
||||
* @param {Object} options.marker Leaflet marker instance supporting ``on``.
|
||||
* @param {Function} options.getOverlayFallback Returns the fallback overlay payload.
|
||||
* @param {Function} options.refreshNodeInformation Async function fetching node details.
|
||||
* @param {Function} options.mergeOverlayDetails Merge function combining fetched and fallback details.
|
||||
* @param {Function} options.createRequestToken Generates a token for cancellation tracking.
|
||||
* @param {Function} options.isTokenCurrent Tests whether a request token is still current.
|
||||
* @param {Function} [options.showLoading] Callback invoked before refreshing.
|
||||
* @param {Function} [options.showDetails] Callback invoked with merged overlay details.
|
||||
* @param {Function} [options.showError] Callback invoked when refreshing fails.
|
||||
* @param {Function} [options.updatePopup] Callback updating the marker popup contents.
|
||||
* @param {Function} [options.shouldHandleClick] Predicate that decides whether the click should trigger a refresh.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function attachNodeInfoRefreshToMarker({
|
||||
marker,
|
||||
getOverlayFallback,
|
||||
refreshNodeInformation,
|
||||
mergeOverlayDetails,
|
||||
createRequestToken,
|
||||
isTokenCurrent,
|
||||
showLoading,
|
||||
showDetails,
|
||||
showError,
|
||||
updatePopup,
|
||||
shouldHandleClick,
|
||||
}) {
|
||||
if (!isObject(marker) || typeof marker.on !== 'function') {
|
||||
throw new TypeError('A Leaflet marker with an on() method is required');
|
||||
}
|
||||
if (typeof refreshNodeInformation !== 'function') {
|
||||
throw new TypeError('A refreshNodeInformation function must be provided');
|
||||
}
|
||||
if (typeof mergeOverlayDetails !== 'function') {
|
||||
throw new TypeError('A mergeOverlayDetails function must be provided');
|
||||
}
|
||||
if (typeof createRequestToken !== 'function' || typeof isTokenCurrent !== 'function') {
|
||||
throw new TypeError('Token management callbacks must be provided');
|
||||
}
|
||||
|
||||
marker.on('click', event => {
|
||||
if (event && event.originalEvent) {
|
||||
const original = event.originalEvent;
|
||||
if (typeof original.preventDefault === 'function') {
|
||||
original.preventDefault();
|
||||
}
|
||||
if (typeof original.stopPropagation === 'function') {
|
||||
original.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackOverlay = typeof getOverlayFallback === 'function' ? getOverlayFallback() : null;
|
||||
const anchor = typeof marker.getElement === 'function' ? marker.getElement() : null;
|
||||
|
||||
if (!isObject(fallbackOverlay)) {
|
||||
if (anchor && typeof showDetails === 'function') {
|
||||
showDetails(anchor, {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof shouldHandleClick === 'function' && !shouldHandleClick(anchor, fallbackOverlay)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof updatePopup === 'function') {
|
||||
updatePopup(fallbackOverlay);
|
||||
}
|
||||
|
||||
const nodeId = toTrimmedString(fallbackOverlay.nodeId ?? fallbackOverlay.node_id ?? fallbackOverlay.id);
|
||||
const nodeNum = toFiniteNumber(fallbackOverlay.nodeNum ?? fallbackOverlay.node_num ?? fallbackOverlay.num);
|
||||
|
||||
if (!nodeId && nodeNum == null) {
|
||||
if (anchor && typeof showDetails === 'function') {
|
||||
showDetails(anchor, fallbackOverlay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const requestToken = createRequestToken();
|
||||
|
||||
if (anchor && typeof showLoading === 'function') {
|
||||
showLoading(anchor, fallbackOverlay);
|
||||
}
|
||||
|
||||
const reference = { fallback: fallbackOverlay };
|
||||
if (nodeId) reference.nodeId = nodeId;
|
||||
if (nodeNum != null) reference.nodeNum = nodeNum;
|
||||
|
||||
let refreshPromise;
|
||||
try {
|
||||
refreshPromise = Promise.resolve(refreshNodeInformation(reference));
|
||||
} catch (error) {
|
||||
if (isTokenCurrent(requestToken)) {
|
||||
if (anchor && typeof showError === 'function') {
|
||||
showError(anchor, fallbackOverlay, error);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
refreshPromise
|
||||
.then(details => {
|
||||
if (!isTokenCurrent(requestToken)) {
|
||||
return;
|
||||
}
|
||||
const merged = mergeOverlayDetails(details, fallbackOverlay);
|
||||
if (typeof updatePopup === 'function') {
|
||||
updatePopup(merged);
|
||||
}
|
||||
if (anchor && typeof showDetails === 'function') {
|
||||
showDetails(anchor, merged);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (!isTokenCurrent(requestToken)) {
|
||||
return;
|
||||
}
|
||||
if (typeof updatePopup === 'function') {
|
||||
updatePopup(fallbackOverlay);
|
||||
}
|
||||
if (anchor && typeof showError === 'function') {
|
||||
showError(anchor, fallbackOverlay, error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const __testUtils = {
|
||||
isObject,
|
||||
toTrimmedString,
|
||||
toFiniteNumber,
|
||||
normaliseNeighbor,
|
||||
};
|
||||
@@ -0,0 +1,416 @@
|
||||
/*
|
||||
* Copyright (C) 2025 l5yth
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const DEFAULT_FETCH_OPTIONS = Object.freeze({ cache: 'no-store' });
|
||||
const TELEMETRY_LIMIT = 1;
|
||||
const POSITION_LIMIT = 1;
|
||||
const NEIGHBOR_LIMIT = 1000;
|
||||
|
||||
/**
|
||||
* Determine whether the supplied value behaves like a plain object.
|
||||
*
|
||||
* @param {*} value Candidate value.
|
||||
* @returns {boolean} True when ``value`` is an object instance.
|
||||
*/
|
||||
function isObject(value) {
|
||||
return value != null && typeof value === 'object';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a candidate value into a trimmed string representation.
|
||||
*
|
||||
* @param {*} value Raw value from an API payload.
|
||||
* @returns {string|null} Trimmed string or ``null`` when blank.
|
||||
*/
|
||||
function toTrimmedString(value) {
|
||||
if (value == null) return null;
|
||||
const str = String(value).trim();
|
||||
return str.length === 0 ? null : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a candidate value to a finite number when possible.
|
||||
*
|
||||
* @param {*} value Raw value from an API payload.
|
||||
* @returns {number|null} Finite number or ``null`` when coercion fails.
|
||||
*/
|
||||
function toFiniteNumber(value) {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (value == null || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first non-empty string associated with one of the provided keys.
|
||||
*
|
||||
* @param {Object} record Source record inspected for values.
|
||||
* @param {Array<string>} keys Candidate property names.
|
||||
* @returns {string|null} First non-empty string or ``null``.
|
||||
*/
|
||||
function extractString(record, keys) {
|
||||
if (!isObject(record)) return null;
|
||||
for (const key of keys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, key)) continue;
|
||||
const value = toTrimmedString(record[key]);
|
||||
if (value != null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first finite number associated with the provided keys.
|
||||
*
|
||||
* @param {Object} record Source record inspected for values.
|
||||
* @param {Array<string>} keys Candidate property names.
|
||||
* @returns {number|null} First finite number or ``null``.
|
||||
*/
|
||||
function extractNumber(record, keys) {
|
||||
if (!isObject(record)) return null;
|
||||
for (const key of keys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, key)) continue;
|
||||
const value = toFiniteNumber(record[key]);
|
||||
if (value != null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a string property when the supplied value is present.
|
||||
*
|
||||
* @param {Object} target Destination object mutated with the value.
|
||||
* @param {string} key Property name to assign.
|
||||
* @param {*} value Raw value to assign.
|
||||
* @param {Object} [options] Behaviour modifiers.
|
||||
* @param {boolean} [options.preferExisting=false] When true, only assign when the target lacks a value.
|
||||
* @returns {void}
|
||||
*/
|
||||
function assignString(target, key, value, { preferExisting = false } = {}) {
|
||||
const stringValue = toTrimmedString(value);
|
||||
if (stringValue == null) return;
|
||||
if (preferExisting) {
|
||||
const existing = toTrimmedString(target[key]);
|
||||
if (existing != null) return;
|
||||
}
|
||||
target[key] = stringValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a numeric property when the supplied value parses successfully.
|
||||
*
|
||||
* @param {Object} target Destination object mutated with the value.
|
||||
* @param {string} key Property name to assign.
|
||||
* @param {*} value Raw value to assign.
|
||||
* @param {Object} [options] Behaviour modifiers.
|
||||
* @param {boolean} [options.preferExisting=false] When true, only assign when the target lacks a value.
|
||||
* @returns {void}
|
||||
*/
|
||||
function assignNumber(target, key, value, { preferExisting = false } = {}) {
|
||||
const numericValue = toFiniteNumber(value);
|
||||
if (numericValue == null) return;
|
||||
if (preferExisting) {
|
||||
const existing = toFiniteNumber(target[key]);
|
||||
if (existing != null) return;
|
||||
}
|
||||
target[key] = numericValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge base node fields from an arbitrary record into the aggregate node object.
|
||||
*
|
||||
* @param {Object} target Mutable aggregate node reference.
|
||||
* @param {Object} record Source record providing base attributes.
|
||||
* @returns {void}
|
||||
*/
|
||||
function mergeNodeFields(target, record) {
|
||||
if (!isObject(record)) return;
|
||||
assignString(target, 'nodeId', extractString(record, ['nodeId', 'node_id']));
|
||||
assignNumber(target, 'nodeNum', extractNumber(record, ['nodeNum', 'node_num', 'num']));
|
||||
assignString(target, 'shortName', extractString(record, ['shortName', 'short_name']));
|
||||
assignString(target, 'longName', extractString(record, ['longName', 'long_name']));
|
||||
assignString(target, 'role', extractString(record, ['role']));
|
||||
assignString(target, 'hwModel', extractString(record, ['hwModel', 'hw_model']));
|
||||
assignNumber(target, 'snr', extractNumber(record, ['snr']));
|
||||
assignNumber(target, 'battery', extractNumber(record, ['battery', 'battery_level', 'batteryLevel']));
|
||||
assignNumber(target, 'voltage', extractNumber(record, ['voltage']));
|
||||
assignNumber(target, 'uptime', extractNumber(record, ['uptime', 'uptime_seconds', 'uptimeSeconds']));
|
||||
assignNumber(target, 'channel', extractNumber(record, ['channel', 'channel_utilization', 'channelUtilization']));
|
||||
assignNumber(target, 'airUtil', extractNumber(record, ['airUtil', 'air_util_tx', 'airUtilTx']));
|
||||
assignNumber(target, 'temperature', extractNumber(record, ['temperature']));
|
||||
assignNumber(target, 'humidity', extractNumber(record, ['humidity', 'relative_humidity', 'relativeHumidity']));
|
||||
assignNumber(target, 'pressure', extractNumber(record, ['pressure', 'barometric_pressure', 'barometricPressure']));
|
||||
assignNumber(target, 'lastHeard', extractNumber(record, ['lastHeard', 'last_heard']));
|
||||
assignString(target, 'lastSeenIso', extractString(record, ['lastSeenIso', 'last_seen_iso']));
|
||||
assignNumber(target, 'positionTime', extractNumber(record, ['position_time', 'positionTime']));
|
||||
assignString(target, 'positionTimeIso', extractString(record, ['position_time_iso', 'positionTimeIso']));
|
||||
assignNumber(target, 'telemetryTime', extractNumber(record, ['telemetry_time', 'telemetryTime']));
|
||||
assignNumber(target, 'latitude', extractNumber(record, ['latitude']));
|
||||
assignNumber(target, 'longitude', extractNumber(record, ['longitude']));
|
||||
assignNumber(target, 'altitude', extractNumber(record, ['altitude']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge telemetry metrics into the aggregate node object when missing.
|
||||
*
|
||||
* @param {Object} target Mutable aggregate node reference.
|
||||
* @param {Object} telemetry Telemetry record returned by the API.
|
||||
* @returns {void}
|
||||
*/
|
||||
function mergeTelemetry(target, telemetry) {
|
||||
if (!isObject(telemetry)) return;
|
||||
target.telemetry = telemetry;
|
||||
assignString(target, 'nodeId', extractString(telemetry, ['node_id', 'nodeId']), { preferExisting: true });
|
||||
assignNumber(target, 'nodeNum', extractNumber(telemetry, ['node_num', 'nodeNum']), { preferExisting: true });
|
||||
assignNumber(target, 'battery', extractNumber(telemetry, ['battery_level', 'batteryLevel']), { preferExisting: true });
|
||||
assignNumber(target, 'voltage', extractNumber(telemetry, ['voltage']), { preferExisting: true });
|
||||
assignNumber(target, 'uptime', extractNumber(telemetry, ['uptime_seconds', 'uptimeSeconds']), { preferExisting: true });
|
||||
assignNumber(target, 'channel', extractNumber(telemetry, ['channel', 'channel_utilization', 'channelUtilization']), { preferExisting: true });
|
||||
assignNumber(target, 'airUtil', extractNumber(telemetry, ['air_util_tx', 'airUtilTx', 'airUtil']), { preferExisting: true });
|
||||
assignNumber(target, 'temperature', extractNumber(telemetry, ['temperature']), { preferExisting: true });
|
||||
assignNumber(target, 'humidity', extractNumber(telemetry, ['relative_humidity', 'relativeHumidity', 'humidity']), { preferExisting: true });
|
||||
assignNumber(target, 'pressure', extractNumber(telemetry, ['barometric_pressure', 'barometricPressure', 'pressure']), { preferExisting: true });
|
||||
|
||||
const telemetryTime = extractNumber(telemetry, ['telemetry_time', 'telemetryTime']);
|
||||
if (telemetryTime != null) {
|
||||
const existingTelemetryTime = toFiniteNumber(target.telemetryTime);
|
||||
if (existingTelemetryTime == null || telemetryTime > existingTelemetryTime) {
|
||||
target.telemetryTime = telemetryTime;
|
||||
}
|
||||
}
|
||||
|
||||
const rxTime = extractNumber(telemetry, ['rx_time', 'rxTime']);
|
||||
if (rxTime != null) {
|
||||
const existingLastHeard = toFiniteNumber(target.lastHeard);
|
||||
if (existingLastHeard == null || rxTime > existingLastHeard) {
|
||||
target.lastHeard = rxTime;
|
||||
assignString(target, 'lastSeenIso', extractString(telemetry, ['rx_iso', 'rxIso']));
|
||||
} else {
|
||||
assignString(target, 'lastSeenIso', extractString(telemetry, ['rx_iso', 'rxIso']), { preferExisting: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge position data into the aggregate node object when missing.
|
||||
*
|
||||
* @param {Object} target Mutable aggregate node reference.
|
||||
* @param {Object} position Position record returned by the API.
|
||||
* @returns {void}
|
||||
*/
|
||||
function mergePosition(target, position) {
|
||||
if (!isObject(position)) return;
|
||||
target.position = position;
|
||||
assignString(target, 'nodeId', extractString(position, ['node_id', 'nodeId']), { preferExisting: true });
|
||||
assignNumber(target, 'nodeNum', extractNumber(position, ['node_num', 'nodeNum']), { preferExisting: true });
|
||||
assignNumber(target, 'latitude', extractNumber(position, ['latitude']), { preferExisting: true });
|
||||
assignNumber(target, 'longitude', extractNumber(position, ['longitude']), { preferExisting: true });
|
||||
assignNumber(target, 'altitude', extractNumber(position, ['altitude']), { preferExisting: true });
|
||||
|
||||
const positionTime = extractNumber(position, ['position_time', 'positionTime']);
|
||||
if (positionTime != null) {
|
||||
const existingPositionTime = toFiniteNumber(target.positionTime);
|
||||
if (existingPositionTime == null || positionTime > existingPositionTime) {
|
||||
target.positionTime = positionTime;
|
||||
assignString(target, 'positionTimeIso', extractString(position, ['position_time_iso', 'positionTimeIso']));
|
||||
} else {
|
||||
assignString(target, 'positionTimeIso', extractString(position, ['position_time_iso', 'positionTimeIso']), { preferExisting: true });
|
||||
}
|
||||
}
|
||||
|
||||
const rxTime = extractNumber(position, ['rx_time', 'rxTime']);
|
||||
if (rxTime != null) {
|
||||
const existingLastHeard = toFiniteNumber(target.lastHeard);
|
||||
if (existingLastHeard == null || rxTime > existingLastHeard) {
|
||||
target.lastHeard = rxTime;
|
||||
assignString(target, 'lastSeenIso', extractString(position, ['rx_iso', 'rxIso']));
|
||||
} else {
|
||||
assignString(target, 'lastSeenIso', extractString(position, ['rx_iso', 'rxIso']), { preferExisting: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse a fallback payload used as an initial node reference.
|
||||
*
|
||||
* @param {*} fallback User-provided fallback data.
|
||||
* @returns {Object|null} Parsed fallback object or ``null``.
|
||||
*/
|
||||
function parseFallback(fallback) {
|
||||
if (isObject(fallback)) return { ...fallback };
|
||||
if (typeof fallback === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(fallback);
|
||||
return isObject(parsed) ? parsed : null;
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse node fallback payload', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a node reference into a canonical structure used by the fetcher.
|
||||
*
|
||||
* @param {*} reference Raw reference passed to {@link refreshNodeInformation}.
|
||||
* @returns {{nodeId: (string|null), nodeNum: (number|null), fallback: (Object|null)}} Normalised reference data.
|
||||
*/
|
||||
function normalizeReference(reference) {
|
||||
if (reference == null) {
|
||||
return { nodeId: null, nodeNum: null, fallback: null };
|
||||
}
|
||||
if (typeof reference === 'string') {
|
||||
return { nodeId: toTrimmedString(reference), nodeNum: null, fallback: null };
|
||||
}
|
||||
if (typeof reference === 'number') {
|
||||
const nodeNum = toFiniteNumber(reference);
|
||||
return { nodeId: null, nodeNum, fallback: null };
|
||||
}
|
||||
|
||||
if (!isObject(reference)) {
|
||||
return { nodeId: null, nodeNum: null, fallback: null };
|
||||
}
|
||||
|
||||
const fallback = parseFallback(reference.fallback ?? reference.nodeInfo ?? null);
|
||||
let nodeId = toTrimmedString(reference.nodeId ?? reference.node_id ?? null);
|
||||
if (nodeId == null) {
|
||||
nodeId = toTrimmedString(fallback?.nodeId ?? fallback?.node_id ?? null);
|
||||
}
|
||||
let nodeNum = reference.nodeNum ?? reference.node_num ?? null;
|
||||
if (nodeNum == null) {
|
||||
nodeNum = fallback?.nodeNum ?? fallback?.node_num ?? null;
|
||||
}
|
||||
nodeNum = toFiniteNumber(nodeNum);
|
||||
|
||||
return { nodeId, nodeNum, fallback };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve and merge node, telemetry, position, and neighbor information.
|
||||
*
|
||||
* @param {*} reference Node identifier string/number or an object containing ``nodeId``/``nodeNum``.
|
||||
* @param {{fetchImpl?: Function}} [options] Optional overrides such as a custom ``fetch`` implementation.
|
||||
* @returns {Promise<Object>} Normalised node payload enriched with telemetry, position, and neighbor data.
|
||||
*/
|
||||
export async function refreshNodeInformation(reference, options = {}) {
|
||||
const normalized = normalizeReference(reference);
|
||||
const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch;
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
throw new TypeError('A fetch implementation is required to refresh node information');
|
||||
}
|
||||
|
||||
const identifier = normalized.nodeId ?? normalized.nodeNum;
|
||||
if (identifier == null) {
|
||||
throw new Error('A node identifier or numeric reference must be provided');
|
||||
}
|
||||
|
||||
const encodedId = encodeURIComponent(String(identifier));
|
||||
|
||||
const [nodeRecord, telemetryRecords, positionRecords, neighborRecords] = await Promise.all([
|
||||
(async () => {
|
||||
const response = await fetchImpl(`/api/nodes/${encodedId}`, DEFAULT_FETCH_OPTIONS);
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load node information (HTTP ${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
})(),
|
||||
(async () => {
|
||||
const response = await fetchImpl(`/api/telemetry/${encodedId}?limit=${TELEMETRY_LIMIT}`, DEFAULT_FETCH_OPTIONS);
|
||||
if (response.status === 404) return [];
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load telemetry information (HTTP ${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
})(),
|
||||
(async () => {
|
||||
const response = await fetchImpl(`/api/positions/${encodedId}?limit=${POSITION_LIMIT}`, DEFAULT_FETCH_OPTIONS);
|
||||
if (response.status === 404) return [];
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load position information (HTTP ${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
})(),
|
||||
(async () => {
|
||||
const response = await fetchImpl(`/api/neighbors/${encodedId}?limit=${NEIGHBOR_LIMIT}`, DEFAULT_FETCH_OPTIONS);
|
||||
if (response.status === 404) return [];
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load neighbor information (HTTP ${response.status})`);
|
||||
}
|
||||
return response.json();
|
||||
})(),
|
||||
]);
|
||||
|
||||
const telemetryEntry = Array.isArray(telemetryRecords) ? telemetryRecords[0] ?? null : telemetryRecords ?? null;
|
||||
const positionEntry = Array.isArray(positionRecords) ? positionRecords[0] ?? null : positionRecords ?? null;
|
||||
const neighborEntries = Array.isArray(neighborRecords) ? neighborRecords.filter(isObject) : [];
|
||||
|
||||
const node = { neighbors: neighborEntries };
|
||||
|
||||
if (normalized.fallback) {
|
||||
mergeNodeFields(node, normalized.fallback);
|
||||
}
|
||||
if (nodeRecord) {
|
||||
mergeNodeFields(node, nodeRecord);
|
||||
}
|
||||
if (normalized.nodeId && !node.nodeId) {
|
||||
node.nodeId = normalized.nodeId;
|
||||
}
|
||||
if (normalized.nodeNum != null && toFiniteNumber(node.nodeNum) == null) {
|
||||
node.nodeNum = normalized.nodeNum;
|
||||
}
|
||||
|
||||
mergeTelemetry(node, telemetryEntry);
|
||||
mergePosition(node, positionEntry);
|
||||
|
||||
const derivedLastHeardValues = [
|
||||
toFiniteNumber(node.lastHeard),
|
||||
toFiniteNumber(node.telemetryTime),
|
||||
toFiniteNumber(node.positionTime),
|
||||
].filter(value => value != null);
|
||||
if (derivedLastHeardValues.length > 0) {
|
||||
node.lastHeard = Math.max(...derivedLastHeardValues);
|
||||
}
|
||||
|
||||
if (!node.role) {
|
||||
node.role = 'CLIENT';
|
||||
}
|
||||
|
||||
node.rawSources = {
|
||||
node: nodeRecord,
|
||||
telemetry: telemetryEntry,
|
||||
position: positionEntry,
|
||||
neighbors: neighborEntries,
|
||||
};
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export const __testUtils = {
|
||||
toTrimmedString,
|
||||
toFiniteNumber,
|
||||
extractString,
|
||||
extractNumber,
|
||||
assignString,
|
||||
assignNumber,
|
||||
mergeNodeFields,
|
||||
mergeTelemetry,
|
||||
mergePosition,
|
||||
parseFallback,
|
||||
normalizeReference,
|
||||
};
|
||||
@@ -2252,6 +2252,40 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "excludes nodes whose last activity is older than a week" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
stale_last = now - (PotatoMesh::Config.week_seconds + 60)
|
||||
fresh_last = now - 30
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!stale-node", "stal", "Stale", "TBEAM", "CLIENT", 0.0, stale_last, stale_last],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!fresh-node", "frsh", "Fresh", "TBEAM", "CLIENT", 0.0, fresh_last, fresh_last],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/nodes"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
ids = JSON.parse(last_response.body).map { |row| row["node_id"] }
|
||||
expect(ids).to include("!fresh-node")
|
||||
expect(ids).not_to include("!stale-node")
|
||||
|
||||
get "/api/nodes/!stale-node"
|
||||
expect(last_response.status).to eq(404)
|
||||
|
||||
get "/api/nodes/!fresh-node"
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload["node_id"]).to eq("!fresh-node")
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /api/messages" do
|
||||
@@ -2443,6 +2477,43 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(messages.first["from_id"]).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
it "omits messages received more than seven days ago" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
stale_rx = now - (PotatoMesh::Config.week_seconds + 120)
|
||||
fresh_rx = now - 15
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!fresh", "frsh", "Fresh", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text, snr, rssi, hop_limit) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[1, stale_rx, Time.at(stale_rx).utc.iso8601, "!old", "!fresh", 0, "TEXT_MESSAGE_APP", "stale", 1.0, -70, 3],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text, snr, rssi, hop_limit) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[2, fresh_rx, Time.at(fresh_rx).utc.iso8601, "!fresh", "!old", 0, "TEXT_MESSAGE_APP", "fresh", 2.0, -60, 3],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/messages"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
ids = payload.map { |row| row["id"] }
|
||||
expect(ids).to include(2)
|
||||
expect(ids).not_to include(1)
|
||||
|
||||
get "/api/messages/!old"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["id"] }).to eq([2])
|
||||
end
|
||||
end
|
||||
|
||||
context "when private mode is enabled" do
|
||||
@@ -2533,6 +2604,87 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(entry["precision_bits"]).to eq(8)
|
||||
expect(entry["payload_b64"]).to eq("AQI=")
|
||||
end
|
||||
|
||||
it "excludes position entries older than seven days" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
stale_rx = now - (PotatoMesh::Config.week_seconds + 10)
|
||||
fresh_rx = now - 20
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[1, "!pos", 42, stale_rx, Time.at(stale_rx).utc.iso8601, stale_rx - 5, 52.0, 13.0],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[2, "!pos", 42, fresh_rx, Time.at(fresh_rx).utc.iso8601, fresh_rx - 5, 53.0, 14.0],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/positions"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
ids = payload.map { |row| row["id"] }
|
||||
expect(ids).to eq([2])
|
||||
|
||||
get "/api/positions/!pos"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["id"] }).to eq([2])
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /api/neighbors" do
|
||||
it "excludes neighbor records older than seven days" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
stale_rx = now - (PotatoMesh::Config.week_seconds + 45)
|
||||
fresh_rx = now - 10
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!root", "root", "Root", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!neighbor-old", "oldn", "Neighbor Old", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!neighbor-new", "newn", "Neighbor New", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
|
||||
["!root", "!neighbor-old", 1.0, stale_rx],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
|
||||
["!root", "!neighbor-new", 8.0, fresh_rx],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/neighbors"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.length).to eq(1)
|
||||
expect(payload.first["neighbor_id"]).to eq("!neighbor-new")
|
||||
expect(payload.first["rx_time"]).to eq(fresh_rx)
|
||||
|
||||
get "/api/neighbors/!root"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.length).to eq(1)
|
||||
expect(filtered.first["neighbor_id"]).to eq("!neighbor-new")
|
||||
expect(filtered.first["rx_time"]).to eq(fresh_rx)
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /api/telemetry" do
|
||||
@@ -2565,5 +2717,37 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(second_entry["relative_humidity"]).to be_within(1e-6).of(second_latest["environment_metrics"]["relativeHumidity"])
|
||||
expect(second_entry["barometric_pressure"]).to be_within(1e-6).of(second_latest["environment_metrics"]["barometricPressure"])
|
||||
end
|
||||
|
||||
it "excludes telemetry entries older than seven days" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
stale_rx = now - (PotatoMesh::Config.week_seconds + 30)
|
||||
fresh_rx = now - 5
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[1, "!tele", 7, stale_rx, Time.at(stale_rx).utc.iso8601, stale_rx - 60, 10.0, 3.9],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[2, "!tele", 7, fresh_rx, Time.at(fresh_rx).utc.iso8601, fresh_rx - 60, 90.0, 4.1],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/telemetry"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
ids = payload.map { |row| row["id"] }
|
||||
expect(ids).to eq([2])
|
||||
|
||||
get "/api/telemetry/!tele"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["id"] }).to eq([2])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user