From 0a26e4252a9a12e2ce939f135c11ddfec0d0567d Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sat, 11 Oct 2025 12:36:28 +0200 Subject: [PATCH] Add targeted API endpoints and expose version metadata (#271) * Add per-node API endpoints and version route * Adjust version metadata and node lookup route --- web/app.rb | 359 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 309 insertions(+), 50 deletions(-) diff --git a/web/app.rb b/web/app.rb index bcb6c69..3f46a9f 100644 --- a/web/app.rb +++ b/web/app.rb @@ -120,6 +120,20 @@ APP_VERSION = determine_app_version set :public_folder, File.join(__dir__, "public") set :views, File.join(__dir__, "views") +def latest_node_update_timestamp + return nil unless File.exist?(DB_PATH) + + db = open_database(readonly: true) + value = db.get_first_value( + "SELECT MAX(COALESCE(last_heard, first_heard, position_time)) FROM nodes", + ) + value&.to_i +rescue SQLite3::Exception + nil +ensure + db&.close +end + get "/favicon.ico" do cache_control :public, max_age: WEEK_SECONDS ico_path = File.join(settings.public_folder, "favicon.ico") @@ -130,6 +144,30 @@ get "/favicon.ico" do end end +get "/version" do + content_type :json + last_update = latest_node_update_timestamp + payload = { + name: sanitized_site_name, + version: APP_VERSION, + lastNodeUpdate: last_update, + config: { + siteName: sanitized_site_name, + defaultChannel: sanitized_default_channel, + defaultFrequency: sanitized_default_frequency, + refreshIntervalSeconds: REFRESH_INTERVAL_SECONDS, + mapCenter: { + lat: MAP_CENTER_LAT, + lon: MAP_CENTER_LON, + }, + maxNodeDistanceKm: MAX_NODE_DISTANCE_KM, + matrixRoom: sanitized_matrix_room, + privateMode: private_mode?, + }, + } + payload.to_json +end + SITE_NAME = fetch_config_string("SITE_NAME", "Meshtastic Berlin") DEFAULT_CHANNEL = fetch_config_string("DEFAULT_CHANNEL", "#MediumFast") DEFAULT_FREQUENCY = fetch_config_string("DEFAULT_FREQUENCY", "868MHz") @@ -566,16 +604,126 @@ end ensure_schema_upgrades +# Derive canonical string and numeric representations for a node reference. +# +# @param node_ref [Object] raw identifier provided by the caller. +# @return [Hash] hash containing ``:string_values`` and ``:numeric_values`` arrays. +def node_reference_tokens(node_ref) + parts = canonical_node_parts(node_ref) + canonical_id, numeric_id = parts ? parts[0, 2] : [nil, nil] + + string_values = [] + numeric_values = [] + + case node_ref + when Integer + numeric_values << node_ref + string_values << node_ref.to_s + when Numeric + coerced = node_ref.to_i + numeric_values << coerced + string_values << coerced.to_s + when String + trimmed = node_ref.strip + unless trimmed.empty? + string_values << trimmed + numeric_values << trimmed.to_i if trimmed.match?(/\A-?\d+\z/) + end + when nil + # no-op + else + coerced = node_ref.to_s.strip + string_values << coerced unless coerced.empty? + end + + if canonical_id + string_values << canonical_id + string_values << canonical_id.upcase + end + + if numeric_id + numeric_values << numeric_id + string_values << numeric_id.to_s + end + + cleaned_strings = string_values.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq + cleaned_numbers = numeric_values.compact.map do |value| + begin + Integer(value, 10) + rescue ArgumentError, TypeError + nil + end + end.compact.uniq + + { + string_values: cleaned_strings, + numeric_values: cleaned_numbers, + } +end + +# Build a SQL predicate limiting results to the provided node reference. +# +# @param node_ref [Object] identifier used to match the node. +# @param string_columns [Array] columns compared against string forms. +# @param numeric_columns [Array] columns compared against numeric forms. +# @return [Array(String, Array), nil] tuple containing the SQL fragment and +# bound parameters, or nil when no valid tokens can be derived. +def node_lookup_clause(node_ref, string_columns:, numeric_columns: []) + tokens = node_reference_tokens(node_ref) + string_values = tokens[:string_values] + numeric_values = tokens[:numeric_values] + + clauses = [] + params = [] + + unless string_columns.empty? || string_values.empty? + string_columns.each do |column| + placeholders = Array.new(string_values.length, "?").join(", ") + clauses << "#{column} IN (#{placeholders})" + params.concat(string_values) + end + end + + unless numeric_columns.empty? || numeric_values.empty? + numeric_columns.each do |column| + placeholders = Array.new(numeric_values.length, "?").join(", ") + clauses << "#{column} IN (#{placeholders})" + params.concat(numeric_values) + end + end + + return nil if clauses.empty? + + ["(#{clauses.join(" OR ")})", params] +end + # Retrieve recently heard nodes ordered by their last contact time. # # @param limit [Integer] maximum number of rows returned. +# @param node_ref [Object, nil] optional identifier restricting the query. # @return [Array] collection of node records formatted for the API. -def query_nodes(limit) +def query_nodes(limit, node_ref: nil) db = open_database(readonly: true) db.results_as_hash = true now = Time.now.to_i min_last_heard = now - WEEK_SECONDS - params = [min_last_heard] + params = [] + where_clauses = [] + + if node_ref + clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["num"]) + return [] unless clause + where_clauses << clause.first + params.concat(clause.last) + else + where_clauses << "last_heard >= ?" + params << min_last_heard + end + + if private_mode? + where_clauses << "(role IS NULL OR role <> 'CLIENT_HIDDEN')" + end + sql = <<~SQL SELECT node_id, short_name, long_name, hw_model, role, snr, battery_level, voltage, last_heard, first_heard, @@ -583,11 +731,8 @@ def query_nodes(limit) position_time, location_source, precision_bits, latitude, longitude, altitude FROM nodes - WHERE last_heard >= ? SQL - if private_mode? - sql += " AND (role IS NULL OR role <> 'CLIENT_HIDDEN')\n" - end + sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any? sql += <<~SQL ORDER BY last_heard DESC LIMIT ? @@ -622,27 +767,52 @@ get "/api/nodes" do query_nodes(limit).to_json end +get "/api/nodes/:id" do + content_type :json + node_ref = string_or_nil(params["id"]) + halt 400, { error: "missing node id" }.to_json unless node_ref + limit = [params["limit"]&.to_i || 200, 1000].min + rows = query_nodes(limit, node_ref: node_ref) + halt 404, { error: "not found" }.to_json if rows.empty? + rows.first.to_json +end + # Retrieve recent text messages joined with related node information. # # @param limit [Integer] maximum number of rows returned. +# @param node_ref [Object, nil] optional identifier restricting the query. # @return [Array] collection of message rows suitable for serialisation. -def query_messages(limit) +def query_messages(limit, node_ref: nil) db = open_database(readonly: true) db.results_as_hash = true - rows = db.execute <<~SQL, [limit] - SELECT m.*, n.*, m.snr AS msg_snr - FROM messages m - LEFT JOIN nodes n ON ( - m.from_id IS NOT NULL AND TRIM(m.from_id) <> '' AND ( - m.from_id = n.node_id OR ( - m.from_id GLOB '[0-9]*' AND CAST(m.from_id AS INTEGER) = n.num - ) - ) - ) - WHERE COALESCE(TRIM(m.encrypted), '') = '' - ORDER BY m.rx_time DESC - LIMIT ? - SQL + params = [] + where_clauses = ["COALESCE(TRIM(m.encrypted), '') = ''"] + + if node_ref + clause = node_lookup_clause(node_ref, string_columns: ["m.from_id", "m.to_id"]) + return [] unless clause + where_clauses << clause.first + params.concat(clause.last) + end + + sql = <<~SQL + SELECT m.*, n.*, m.snr AS msg_snr + FROM messages m + LEFT JOIN nodes n ON ( + m.from_id IS NOT NULL AND TRIM(m.from_id) <> '' AND ( + m.from_id = n.node_id OR ( + m.from_id GLOB '[0-9]*' AND CAST(m.from_id AS INTEGER) = n.num + ) + ) + ) + SQL + sql += " WHERE #{where_clauses.join(" AND ")}\n" + sql += <<~SQL + ORDER BY m.rx_time DESC + LIMIT ? + 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| if DEBUG && (r["from_id"].nil? || r["from_id"].to_s.empty?) @@ -702,20 +872,40 @@ end # Retrieve recorded position packets ordered by receive time. # # @param limit [Integer] maximum number of rows returned. +# @param node_ref [Object, nil] optional identifier restricting the query. # @return [Array] collection of position rows formatted for the API. -def query_positions(limit) +def query_positions(limit, node_ref: nil) db = open_database(readonly: true) db.results_as_hash = true - rows = db.execute <<~SQL, [limit] - SELECT id, node_id, node_num, rx_time, rx_iso, position_time, - to_id, latitude, longitude, altitude, location_source, - precision_bits, sats_in_view, pdop, ground_speed, - ground_track, snr, rssi, hop_limit, bitfield, - payload_b64 - FROM positions - ORDER BY rx_time DESC - LIMIT ? - SQL + params = [] + where_clauses = [] + + if node_ref + clause = node_lookup_clause( + node_ref, + string_columns: ["node_id", "to_id"], + numeric_columns: ["node_num"], + ) + return [] unless clause + where_clauses << clause.first + params.concat(clause.last) + end + + sql = <<~SQL + SELECT id, node_id, node_num, rx_time, rx_iso, position_time, + to_id, latitude, longitude, altitude, location_source, + precision_bits, sats_in_view, pdop, ground_speed, + ground_track, snr, rssi, hop_limit, bitfield, + payload_b64 + FROM positions + SQL + sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any? + sql += <<~SQL + ORDER BY rx_time DESC + LIMIT ? + SQL + params << limit + rows = db.execute(sql, params) rows.each do |r| pt = r["position_time"] if pt @@ -738,16 +928,32 @@ end # Retrieve recent neighbour signal reports ordered by the recorded time. # # @param limit [Integer] maximum number of rows returned. +# @param node_ref [Object, nil] optional identifier restricting the query. # @return [Array] neighbour tuples formatted for the API response. -def query_neighbors(limit) +def query_neighbors(limit, node_ref: nil) db = open_database(readonly: true) db.results_as_hash = true - rows = db.execute <<~SQL, [limit] - SELECT node_id, neighbor_id, snr, rx_time - FROM neighbors - ORDER BY rx_time DESC - LIMIT ? - SQL + params = [] + where_clauses = [] + + if node_ref + clause = node_lookup_clause(node_ref, string_columns: ["node_id", "neighbor_id"]) + return [] unless clause + where_clauses << clause.first + params.concat(clause.last) + end + + sql = <<~SQL + SELECT node_id, neighbor_id, snr, rx_time + FROM neighbors + SQL + sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any? + sql += <<~SQL + ORDER BY rx_time DESC + LIMIT ? + SQL + params << limit + rows = db.execute(sql, params) rows.each do |r| rx_time = coerce_integer(r["rx_time"]) r["rx_time"] = rx_time if rx_time @@ -762,20 +968,40 @@ end # Retrieve telemetry packets enriched with parsed numeric values. # # @param limit [Integer] maximum number of rows returned. +# @param node_ref [Object, nil] optional identifier restricting the query. # @return [Array] telemetry rows suitable for serialisation. -def query_telemetry(limit) +def query_telemetry(limit, node_ref: nil) db = open_database(readonly: true) db.results_as_hash = true - rows = db.execute <<~SQL, [limit] - SELECT id, node_id, node_num, from_id, to_id, rx_time, rx_iso, - telemetry_time, channel, portnum, hop_limit, snr, rssi, - bitfield, payload_b64, battery_level, voltage, - channel_utilization, air_util_tx, uptime_seconds, - temperature, relative_humidity, barometric_pressure - FROM telemetry - ORDER BY rx_time DESC - LIMIT ? - SQL + params = [] + where_clauses = [] + + if node_ref + clause = node_lookup_clause( + node_ref, + string_columns: ["node_id", "from_id", "to_id"], + numeric_columns: ["node_num"], + ) + return [] unless clause + where_clauses << clause.first + params.concat(clause.last) + end + + sql = <<~SQL + SELECT id, node_id, node_num, from_id, to_id, rx_time, rx_iso, + telemetry_time, channel, portnum, hop_limit, snr, rssi, + bitfield, payload_b64, battery_level, voltage, + channel_utilization, air_util_tx, uptime_seconds, + temperature, relative_humidity, barometric_pressure + FROM telemetry + SQL + sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any? + sql += <<~SQL + ORDER BY rx_time DESC + LIMIT ? + SQL + params << limit + rows = db.execute(sql, params) now = Time.now.to_i rows.each do |r| rx_time = coerce_integer(r["rx_time"]) @@ -819,6 +1045,15 @@ get "/api/messages" do query_messages(limit).to_json end +get "/api/messages/:id" do + halt 404 if private_mode? + content_type :json + node_ref = string_or_nil(params["id"]) + halt 400, { error: "missing node id" }.to_json unless node_ref + limit = [params["limit"]&.to_i || 200, 1000].min + query_messages(limit, node_ref: node_ref).to_json +end + # GET /api/positions # # Returns a JSON array of recorded position packets. @@ -828,6 +1063,14 @@ get "/api/positions" do query_positions(limit).to_json end +get "/api/positions/:id" do + content_type :json + node_ref = string_or_nil(params["id"]) + halt 400, { error: "missing node id" }.to_json unless node_ref + limit = [params["limit"]&.to_i || 200, 1000].min + query_positions(limit, node_ref: node_ref).to_json +end + # GET /api/neighbors # # Returns the most recent neighbor tuples describing mesh health. @@ -837,6 +1080,14 @@ get "/api/neighbors" do query_neighbors(limit).to_json end +get "/api/neighbors/:id" do + content_type :json + node_ref = string_or_nil(params["id"]) + halt 400, { error: "missing node id" }.to_json unless node_ref + limit = [params["limit"]&.to_i || 200, 1000].min + query_neighbors(limit, node_ref: node_ref).to_json +end + # GET /api/telemetry # # Returns a JSON array of recorded telemetry packets. @@ -846,6 +1097,14 @@ get "/api/telemetry" do query_telemetry(limit).to_json end +get "/api/telemetry/:id" do + content_type :json + node_ref = string_or_nil(params["id"]) + halt 400, { error: "missing node id" }.to_json unless node_ref + limit = [params["limit"]&.to_i || 200, 1000].min + query_telemetry(limit, node_ref: node_ref).to_json +end + # Determine the numeric node reference for a canonical node identifier. # # The Meshtastic protobuf encodes the node ID as a hexadecimal string prefixed