Files
potato-mesh/web/lib/potato_mesh/application/data_processing/identity.rb
T
l5y 03caf391e7 web: refactor 1/7 data processing (#772)
* web: refactor 1/7 data processing

* web: close coverage gaps in data_processing submodules

Bring every file under lib/potato_mesh/application/data_processing/ to
100% line coverage so codecov/patch passes on the 1/7 refactor PR. The
gap was a relocation of pre-existing untested branches; closing them
here keeps the subsequent refactor PRs in the series unblocked.

* Add unit tests covering canonical sender/recipient overrides,
  reply_id/emoji updates on existing rows, and the rare INSERT
  ConstraintException recovery path inside +insert_message+.
* Cover the non-canonical reporter and per-neighbour resolution
  branches in +insert_neighbors+.
* Cover the SQLException rescue in +upsert_ingestor+, the
  fallback_num branch in +touch_node_last_seen+, the limit fallback
  in +read_json_body+, the unrecognised-type branch in
  +store_decrypted_payload+, the +power+ telemetry_type fallback,
  the default-coercion path in +resolve_numeric_metric+, and the
  numeric/bare-hex paths in +canonical_node_parts+ and
  +coerce_trace_node_id+.

Drop dead code surfaced while pinning behaviour:

* +clear_encrypted+ in +insert_message+ has been initialised to
  +false+ and never reassigned since #633 dropped the
  decrypted-text override; remove it and the four dependent
  branches.
* The +rescue ArgumentError; nil+ tails in
  +identity.resolve_node_num+ and +traces.coerce_trace_node_id+ are
  unreachable because every +Integer(...)+ call inside is guarded by
  a regex pre-check.

Add a comment to the +data_processing.rb+ shim explaining that the
+require_relative+ list is ordered by dependency rather than
alphabetically, addressing review nit #5.
2026-05-02 22:08:21 +02:00

200 lines
7.1 KiB
Ruby

# Copyright © 2025-26 l5yth & contributors
#
# 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.
# frozen_string_literal: true
module PotatoMesh
module App
module DataProcessing
# Resolve the numeric representation of a node identifier from a packet payload.
#
# The +payload["num"]+ field may arrive as an Integer, a decimal string, or
# a hexadecimal string (with or without an +0x+ prefix). When the field is
# absent or ambiguous the method falls back to decoding the hex portion of
# +node_id+.
#
# @param node_id [String, nil] canonical node identifier in +!xxxxxxxx+ form.
# @param payload [Hash] inbound message payload that may carry a +num+ field.
# @return [Integer, nil] resolved 32-bit node number or +nil+ when undecidable.
def resolve_node_num(node_id, payload)
raw = payload["num"]
case raw
when Integer
return raw
when Numeric
return raw.to_i
when String
trimmed = raw.strip
return nil if trimmed.empty?
return Integer(trimmed, 10) if trimmed.match?(/\A[0-9]+\z/)
return Integer(trimmed.delete_prefix("0x").delete_prefix("0X"), 16) if trimmed.match?(/\A0[xX][0-9A-Fa-f]+\z/)
if trimmed.match?(/\A[0-9A-Fa-f]+\z/)
canonical = node_id.is_a?(String) ? node_id.strip : ""
return Integer(trimmed, 16) if canonical.match?(/\A!?[0-9A-Fa-f]+\z/)
end
end
return nil unless node_id.is_a?(String)
hex = node_id.strip
return nil if hex.empty?
hex = hex.delete_prefix("!")
return nil unless hex.match?(/\A[0-9A-Fa-f]+\z/)
Integer(hex, 16)
end
# Derive the canonical triplet for a node reference.
#
# Accepts an Integer node number, a hex string with or without the +!+
# sigil, a decimal numeric string, or a +0x+-prefixed hex string. A
# +fallback_num+ may be provided when +node_ref+ is nil.
#
# @param node_ref [Integer, String, nil] raw node identifier from a packet.
# @param fallback_num [Integer, nil] numeric fallback when +node_ref+ is nil.
# @return [Array(String, Integer, String), nil] tuple of
# +[canonical_id, node_num, short_id]+ or +nil+ when the reference cannot
# be resolved. +canonical_id+ is prefixed with +!+ and zero-padded to
# eight lowercase hex digits. +short_id+ is the upper-case last four
# hex digits used for display.
def canonical_node_parts(node_ref, fallback_num = nil)
fallback = coerce_integer(fallback_num)
hex = nil
num = nil
case node_ref
when Integer
num = node_ref
when Numeric
num = node_ref.to_i
when String
trimmed = node_ref.strip
return nil if trimmed.empty?
if trimmed.start_with?("!")
hex = trimmed.delete_prefix("!")
elsif trimmed.match?(/\A0[xX][0-9A-Fa-f]+\z/)
hex = trimmed[2..].to_s
elsif trimmed.match?(/\A-?\d+\z/)
num = trimmed.to_i
elsif trimmed.match?(/\A[0-9A-Fa-f]+\z/)
hex = trimmed
else
return nil
end
when nil
num = fallback if fallback
else
return nil
end
num ||= fallback if fallback
if hex
begin
num ||= Integer(hex, 16)
rescue ArgumentError
return nil
end
elsif num
return nil if num.negative?
hex = format("%08x", num & 0xFFFFFFFF)
else
return nil
end
return nil if hex.nil? || hex.empty?
begin
parsed = Integer(hex, 16)
rescue ArgumentError
return nil
end
parsed &= 0xFFFFFFFF
canonical_hex = format("%08x", parsed)
short_id = canonical_hex[-4, 4].upcase
["!#{canonical_hex}", parsed, short_id]
end
# Detect whether a node reference resolves to the broadcast address.
#
# @param node_ref [Integer, String, nil] raw node reference.
# @param fallback_num [Integer, nil] optional numeric fallback.
# @return [Boolean] true when the reference matches the broadcast address.
def broadcast_node_ref?(node_ref, fallback_num = nil)
return true if fallback_num == 0xFFFFFFFF
trimmed = string_or_nil(node_ref)
return false unless trimmed
normalized = trimmed.delete_prefix("!").strip.downcase
normalized == "ffffffff"
end
# Converts a protocol identifier such as +meshtastic+ or +mesh-core+ into
# the display label used in generated node names: capitalised parts joined
# without a separator (e.g. +Meshtastic+, +MeshCore+).
#
# @param protocol [String] protocol identifier.
# @return [String] formatted display label.
def protocol_display_label(protocol)
protocol.split(/[-_]/).map(&:capitalize).join
end
# Returns true if +long_name+ is the synthetic placeholder generated by
# +ensure_unknown_node+ for the given +node_id+ and +protocol+. Such
# names carry no real information and must not overwrite a known name
# already on record.
#
# @param long_name [String, nil] candidate long name.
# @param node_id [String, nil] canonical node identifier.
# @param protocol [String] protocol identifier the placeholder was generated for.
# @return [Boolean] true when the long name is a generic placeholder.
def generic_fallback_name?(long_name, node_id, protocol)
return false unless long_name && !long_name.empty?
parts = canonical_node_parts(node_id)
return false unless parts
short_id = parts[2]
long_name == "#{protocol_display_label(protocol)} #{short_id}"
end
# Resolve a raw node reference to its canonical row in the +nodes+ table.
#
# @param db [SQLite3::Database] open database handle.
# @param node_ref [Object] raw reference (string, integer, or hex string).
# @return [String, nil] canonical +node_id+ or nil when no match exists.
def normalize_node_id(db, node_ref)
return nil if node_ref.nil?
ref_str = node_ref.to_s.strip
return nil if ref_str.empty?
node_id = db.get_first_value("SELECT node_id FROM nodes WHERE node_id = ?", [ref_str])
return node_id if node_id
begin
ref_num = Integer(ref_str, 10)
rescue ArgumentError
return nil
end
db.get_first_value("SELECT node_id FROM nodes WHERE num = ?", [ref_num])
end
end
end
end