Add LoRa metadata fields to nodes and messages (#331)

* Add LoRa metadata fields to nodes and messages

* Filter numeric SQLite keys from message rows
This commit is contained in:
l5y
2025-10-14 14:51:28 +02:00
committed by GitHub
parent e97498d09f
commit 03b5a10fe4
9 changed files with 159 additions and 20 deletions
+4 -1
View File
@@ -24,7 +24,10 @@ CREATE TABLE IF NOT EXISTS messages (
encrypted TEXT,
snr REAL,
rssi INTEGER,
hop_limit INTEGER
hop_limit INTEGER,
lora_freq INTEGER,
modem_preset TEXT,
channel_name TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_rx_time ON messages(rx_time);
@@ -0,0 +1,22 @@
-- 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.
--
-- Extend the nodes and messages tables with LoRa metadata columns.
BEGIN;
ALTER TABLE nodes ADD COLUMN lora_freq INTEGER;
ALTER TABLE nodes ADD COLUMN modem_preset TEXT;
ALTER TABLE messages ADD COLUMN lora_freq INTEGER;
ALTER TABLE messages ADD COLUMN modem_preset TEXT;
ALTER TABLE messages ADD COLUMN channel_name TEXT;
COMMIT;
+3 -1
View File
@@ -39,7 +39,9 @@ CREATE TABLE IF NOT EXISTS nodes (
precision_bits INTEGER,
latitude REAL,
longitude REAL,
altitude REAL
altitude REAL,
lora_freq INTEGER,
modem_preset TEXT
);
CREATE INDEX IF NOT EXISTS idx_nodes_last_heard ON nodes(last_heard);
+6
View File
@@ -11,6 +11,9 @@
"rssi": -121,
"hop_limit": 1,
"snr": -13.25,
"lora_freq": 915,
"modem_preset": "LONG_FAST",
"channel_name": "SpecChannel",
"node": {
"snr": -13.25,
"node_id": "!bba83318",
@@ -50,6 +53,9 @@
"rssi": -117,
"hop_limit": 3,
"snr": -12.0,
"lora_freq": 868,
"modem_preset": "MEDIUM_SLOW",
"channel_name": "SpecChannel",
"node": {
"snr": -12.0,
"node_id": "!43b6e530",
+6 -2
View File
@@ -20,7 +20,9 @@
"last_seen_iso": "2025-09-16T12:05:30Z",
"pos_time_iso": "2025-09-16T12:05:30Z",
"location_source": "LOC_FIXTURE_0",
"precision_bits": 10
"precision_bits": 10,
"lora_freq": 915,
"modem_preset": "LONG_FAST"
},
{
"node_id": "!d1edc388",
@@ -65,7 +67,9 @@
"last_seen_iso": "2025-09-16T12:05:05Z",
"pos_time_iso": "2025-09-16T12:05:05Z",
"location_source": "LOC_FIXTURE_2",
"precision_bits": 12
"precision_bits": 12,
"lora_freq": 868,
"modem_preset": "MEDIUM_SLOW"
},
{
"node_id": "!33602324",
@@ -220,6 +220,9 @@ module PotatoMesh
update_prometheus_metrics(node_id, user, role, met, pos)
lora_freq = coerce_integer(n["lora_freq"] || n["loraFrequency"])
modem_preset = string_or_nil(n["modem_preset"] || n["modemPreset"])
row = [
node_id,
node_num,
@@ -250,13 +253,15 @@ module PotatoMesh
pos["latitude"],
pos["longitude"],
pos["altitude"],
lora_freq,
modem_preset,
]
with_busy_retry do
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,precision_bits,latitude,longitude,altitude)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
position_time,location_source,precision_bits,latitude,longitude,altitude,lora_freq,modem_preset)
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,
@@ -265,7 +270,7 @@ module PotatoMesh
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, precision_bits=excluded.precision_bits, latitude=excluded.latitude, longitude=excluded.longitude,
altitude=excluded.altitude
altitude=excluded.altitude, lora_freq=excluded.lora_freq, modem_preset=excluded.modem_preset
WHERE COALESCE(excluded.last_heard,0) >= COALESCE(nodes.last_heard,0)
SQL
end
@@ -941,6 +946,10 @@ module PotatoMesh
source: :message,
)
lora_freq = coerce_integer(message["lora_freq"] || message["loraFrequency"])
modem_preset = string_or_nil(message["modem_preset"] || message["modemPreset"])
channel_name = string_or_nil(message["channel_name"] || message["channelName"])
row = [
msg_id,
rx_time,
@@ -954,11 +963,14 @@ module PotatoMesh
message["snr"],
message["rssi"],
message["hop_limit"],
lora_freq,
modem_preset,
channel_name,
]
with_busy_retry do
existing = db.get_first_row(
"SELECT from_id, to_id, encrypted FROM messages WHERE id = ?",
"SELECT from_id, to_id, encrypted, lora_freq, modem_preset, channel_name FROM messages WHERE id = ?",
[msg_id],
)
if existing
@@ -988,6 +1000,27 @@ module PotatoMesh
updates["encrypted"] = encrypted if should_update
end
unless lora_freq.nil?
existing_lora = existing.is_a?(Hash) ? existing["lora_freq"] : existing[3]
updates["lora_freq"] = lora_freq if existing_lora != lora_freq
end
if modem_preset
existing_preset = existing.is_a?(Hash) ? existing["modem_preset"] : existing[4]
existing_preset_str = existing_preset&.to_s
should_update = existing_preset_str.nil? || existing_preset_str.strip.empty?
should_update ||= existing_preset != modem_preset
updates["modem_preset"] = modem_preset if should_update
end
if channel_name
existing_channel = existing.is_a?(Hash) ? existing["channel_name"] : existing[5]
existing_channel_str = existing_channel&.to_s
should_update = existing_channel_str.nil? || existing_channel_str.strip.empty?
should_update ||= existing_channel != channel_name
updates["channel_name"] = channel_name if should_update
end
unless updates.empty?
assignments = updates.keys.map { |column| "#{column} = ?" }.join(", ")
db.execute("UPDATE messages SET #{assignments} WHERE id = ?", updates.values + [msg_id])
@@ -997,14 +1030,17 @@ module PotatoMesh
begin
db.execute <<~SQL, row
INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit,lora_freq,modem_preset,channel_name)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
SQL
rescue SQLite3::ConstraintException
fallback_updates = {}
fallback_updates["from_id"] = from_id if from_id
fallback_updates["to_id"] = to_id if to_id
fallback_updates["encrypted"] = encrypted if encrypted
fallback_updates["lora_freq"] = lora_freq unless lora_freq.nil?
fallback_updates["modem_preset"] = modem_preset if modem_preset
fallback_updates["channel_name"] = channel_name if channel_name
unless fallback_updates.empty?
assignments = fallback_updates.keys.map { |column| "#{column} = ?" }.join(", ")
db.execute("UPDATE messages SET #{assignments} WHERE id = ?", fallback_updates.values + [msg_id])
@@ -89,6 +89,29 @@ module PotatoMesh
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")
node_columns << "precision_bits"
end
unless node_columns.include?("lora_freq")
db.execute("ALTER TABLE nodes ADD COLUMN lora_freq INTEGER")
end
unless node_columns.include?("modem_preset")
db.execute("ALTER TABLE nodes ADD COLUMN modem_preset TEXT")
end
message_columns = db.execute("PRAGMA table_info(messages)").map { |row| row[1] }
unless message_columns.include?("lora_freq")
db.execute("ALTER TABLE messages ADD COLUMN lora_freq INTEGER")
end
unless message_columns.include?("modem_preset")
db.execute("ALTER TABLE messages ADD COLUMN modem_preset TEXT")
end
unless message_columns.include?("channel_name")
db.execute("ALTER TABLE messages ADD COLUMN channel_name TEXT")
end
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='instances'").flatten
+32 -8
View File
@@ -148,7 +148,7 @@ module PotatoMesh
battery_level, voltage, last_heard, first_heard,
uptime_seconds, channel_utilization, air_util_tx,
position_time, location_source, precision_bits,
latitude, longitude, altitude
latitude, longitude, altitude, lora_freq, modem_preset
FROM nodes
SQL
sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any?
@@ -203,7 +203,28 @@ module PotatoMesh
end
sql = <<~SQL
SELECT m.*, n.*, m.snr AS msg_snr
SELECT m.id, m.rx_time, m.rx_iso, m.from_id, m.to_id, m.channel,
m.portnum, m.text, m.encrypted, m.rssi, m.hop_limit,
m.lora_freq AS msg_lora_freq, m.modem_preset AS msg_modem_preset,
m.channel_name AS msg_channel_name, m.snr AS msg_snr,
n.node_id AS node_node_id, n.num AS node_num,
n.short_name AS node_short_name, n.long_name AS node_long_name,
n.macaddr AS node_macaddr, n.hw_model AS node_hw_model,
n.role AS node_role, n.public_key AS node_public_key,
n.is_unmessagable AS node_is_unmessagable,
n.is_favorite AS node_is_favorite,
n.hops_away AS node_hops_away, n.snr AS node_snr,
n.last_heard AS node_last_heard, n.first_heard AS node_first_heard,
n.battery_level AS node_battery_level, n.voltage AS node_voltage,
n.channel_utilization AS node_channel_utilization,
n.air_util_tx AS node_air_util_tx,
n.uptime_seconds AS node_uptime_seconds,
n.position_time AS node_position_time,
n.location_source AS node_location_source,
n.precision_bits AS node_precision_bits,
n.latitude AS node_latitude, n.longitude AS node_longitude,
n.altitude AS node_altitude,
n.lora_freq AS node_lora_freq, n.modem_preset AS node_modem_preset
FROM messages m
LEFT JOIN nodes n ON (
m.from_id IS NOT NULL AND TRIM(m.from_id) <> '' AND (
@@ -220,8 +241,12 @@ module PotatoMesh
SQL
params << limit
rows = db.execute(sql, params)
msg_fields = %w[id rx_time rx_iso from_id to_id channel portnum text encrypted msg_snr rssi hop_limit]
rows.each do |r|
r.delete_if { |key, _| key.is_a?(Integer) }
r["lora_freq"] = r.delete("msg_lora_freq")
r["modem_preset"] = r.delete("msg_modem_preset")
r["channel_name"] = r.delete("msg_channel_name")
snr_value = r.delete("msg_snr")
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.empty?)
raw = db.execute("SELECT * FROM messages WHERE id = ?", [r["id"]]).first
debug_log(
@@ -238,11 +263,11 @@ module PotatoMesh
)
end
node = {}
r.keys.each do |k|
next if msg_fields.include?(k)
node[k] = r.delete(k)
r.keys.grep(/^node_/).each do |k|
attribute = k.delete_prefix("node_")
node[attribute] = r.delete(k)
end
r["snr"] = r.delete("msg_snr")
r["snr"] = snr_value
references = [r["from_id"]].compact
if references.any? && (node["node_id"].nil? || node["node_id"].to_s.empty?)
lookup_keys = []
@@ -260,7 +285,6 @@ module PotatoMesh
if fallback
fallback.each do |key, value|
next unless key.is_a?(String)
next if msg_fields.include?(key)
node[key] = value if node[key].nil?
end
end
+21 -2
View File
@@ -122,6 +122,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
)
payload["position"] = position unless position.empty?
payload["lora_freq"] = node["lora_freq"] if node.key?("lora_freq")
payload["modem_preset"] = node["modem_preset"] if node.key?("modem_preset")
payload
end
@@ -159,6 +162,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
"latitude" => node["latitude"],
"longitude" => node["longitude"],
"altitude" => node["altitude"],
"lora_freq" => node["lora_freq"],
"modem_preset" => node["modem_preset"],
}
end
@@ -1151,7 +1156,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
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, lora_freq, modem_preset
FROM nodes
ORDER BY node_id
SQL
@@ -1173,9 +1179,13 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect_same_value(row["channel_utilization"], expected["channel_utilization"])
expect_same_value(row["air_util_tx"], expected["air_util_tx"])
expect_same_value(row["position_time"], expected["position_time"])
expect(row["location_source"]).to eq(expected["location_source"])
expect_same_value(row["precision_bits"], expected["precision_bits"])
expect_same_value(row["latitude"], expected["latitude"])
expect_same_value(row["longitude"], expected["longitude"])
expect_same_value(row["altitude"], expected["altitude"])
expect_same_value(row["lora_freq"], expected["lora_freq"])
expect(row["modem_preset"]).to eq(expected["modem_preset"])
end
end
end
@@ -1408,7 +1418,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
db.results_as_hash = true
rows = db.execute(<<~SQL)
SELECT id, rx_time, rx_iso, from_id, to_id, channel,
portnum, text, snr, rssi, hop_limit
portnum, text, snr, rssi, hop_limit,
lora_freq, modem_preset, channel_name
FROM messages
ORDER BY id
SQL
@@ -1427,6 +1438,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect_same_value(row["snr"], expected["snr"])
expect(row["rssi"]).to eq(expected["rssi"])
expect(row["hop_limit"]).to eq(expected["hop_limit"])
expect(row["lora_freq"]).to eq(expected["lora_freq"])
expect(row["modem_preset"]).to eq(expected["modem_preset"])
expect(row["channel_name"]).to eq(expected["channel_name"])
end
end
end
@@ -2419,6 +2433,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect_same_value(actual_row["snr"], expected["snr"])
expect(actual_row["rssi"]).to eq(expected["rssi"])
expect(actual_row["hop_limit"]).to eq(expected["hop_limit"])
expect(actual_row["lora_freq"]).to eq(expected["lora_freq"])
expect(actual_row["modem_preset"]).to eq(expected["modem_preset"])
expect(actual_row["channel_name"]).to eq(expected["channel_name"])
if expected["from_id"]
lookup_id = expected["from_id"]
@@ -2438,6 +2455,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(node_actual["long_name"]).to eq(node_expected["long_name"])
expect(node_actual["role"]).to eq(node_expected["role"])
expect_same_value(node_actual["snr"], node_expected["snr"])
expect(node_actual["lora_freq"]).to eq(node_expected["lora_freq"])
expect(node_actual["modem_preset"]).to eq(node_expected["modem_preset"])
expect_same_value(node_actual["battery_level"], node_expected["battery_level"])
expect_same_value(node_actual["voltage"], node_expected["voltage"])
expected_last_heard = node_expected["last_heard"]