Files

113 lines
5.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
# Allowed values for the +telemetry_type+ discriminator column.
VALID_TELEMETRY_TYPES = %w[device environment power air_quality].freeze
# Half-window (seconds) for the meshcore content-level message dedup
# in +insert_message+ and the matching one-shot backfill. Two
# co-operating ingestors timestamp the same physical packet with their
# own host clock, and those clocks drift: on potatomesh.net a fleet of
# two live ingestors showed a consistent ~126 s offset (median 126 s,
# p90 133 s, p99 221 s), so a 30 s window missed 89.6% of the duplicate
# pairs and 28% of all meshcore rows were duplicates. 300 s covers
# ~99.5% of the observed skew. **Accepted tradeoff:** a sender repeating
# the *identical* text to the same channel within 300 s collapses to one
# row — chosen over the 28% duplicate rate. (The one-shot purge in
# +PotatoMesh::App::Database+ applies this transitively, so a chain of such
# repeats spanning longer than 300 s also collapses — a deliberately
# aggressive one-time cleanup; see that file's note.) See issues
# #756 / #825 and ``CONTRACTS.md`` for rationale.
#
# IMPORTANT: widening this value only takes effect at runtime — the
# one-shot backfill in +PotatoMesh::App::Database+ is frozen at
# +MESHCORE_CONTENT_DEDUP_BACKFILL_VERSION+. To re-sweep pre-existing
# rows that newly fall within an expanded window, bump the backfill
# version so the migration re-runs on the next deploy.
MESHCORE_CONTENT_DEDUP_WINDOW_SECONDS = 300
# Coerce a Ruby boolean into a SQLite integer (1/0) while passing through
# any other value unchanged. Used when writing boolean node fields.
#
# @param value [Boolean, Object] value to coerce.
# @return [Integer, Object] 1, 0, or the original value.
def coerce_bool(value)
case value
when true then 1
when false then 0
else value
end
end
# Pair-zero tolerance used when classifying a +(lat, lon)+ tuple as the
# Meshtastic "no GPS lock" sentinel. See +normalize_lat_lon+ and
# issue #782 for rationale.
NULL_ISLAND_EPSILON = 1e-9
# Collapse a Meshtastic +position.time+ candidate to +nil+ whenever it
# represents the firmware "no GPS lock" sentinel. Meshtastic emits
# +time = 0+ until a fresh GPS fix is acquired, and SQLite happily stores
# the zero — but downstream readers and the map renderer treat that as
# a real epoch timestamp. Routing every +position.time+ through this
# helper at the write boundary ensures we persist +NULL+ instead.
#
# Future-dated values (e.g. clock-skew from misconfigured radios) are
# also dropped so they cannot anchor the 7-day freshness filter beyond
# the present moment.
#
# @param value [Object] raw +position.time+ value.
# @param now [Integer] reference upper bound (seconds since the epoch).
# @return [Integer, nil] positive coerced integer, or +nil+ when the
# value is missing, non-positive, non-integer-coercible, or future.
def normalize_position_time(value, now:)
coerced = coerce_integer(value)
return nil if coerced.nil? || coerced <= 0
return nil if now && coerced > now
coerced
end
# Collapse a +(lat, lon)+ pair to +[nil, nil]+ when it represents the
# Meshtastic "no GPS lock" sentinel — i.e. *both* axes are within
# +NULL_ISLAND_EPSILON+ of zero. Single-axis zeros are preserved so a
# node legitimately at the equator (+lat = 0+) or the prime meridian
# (+lon = 0+) survives.
#
# Non-finite or non-numeric axes are returned as +nil+ on their own
# axis; the caller can then decide whether the surviving axis is enough
# to keep the row.
#
# @param lat [Object] raw latitude candidate.
# @param lon [Object] raw longitude candidate.
# @return [Array(Float, Float)] +[lat_f, lon_f]+ as floats, with +nil+
# substituted on either axis when sentinel/invalid.
def normalize_lat_lon(lat, lon)
lat_f = coerce_float(lat)
lon_f = coerce_float(lon)
return [lat_f, lon_f] if lat_f.nil? || lon_f.nil?
if lat_f.abs < NULL_ISLAND_EPSILON && lon_f.abs < NULL_ISLAND_EPSILON
return [nil, nil]
end
[lat_f, lon_f]
end
end
end
end