Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions configure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ configure_noninteractive() {
valid_longitude "$RECEIVERLONGITUDE" || { echo "Longitude must be a decimal number between -180 and 180." >&2; exit 1; }
valid_altitude "$ALT" || { echo "Altitude must be an integer with optional ft or m suffix." >&2; exit 1; }

RECEIVERALTITUDE="$(normalize_altitude "$ALT")"
RECEIVERALTITUDE="$(altitude_to_bare_metres "$ALT")"
detect_receiver_input
write_feed_env
}
Expand Down Expand Up @@ -293,7 +293,7 @@ or in meters like this: 78m\n" \
12 78 3>&1 1>&2 2>&3) || abort
done

ALT="$(normalize_altitude "$ALT")"
ALT="$(altitude_to_bare_metres "$ALT")"

RECEIVERALTITUDE="$ALT"

Expand Down
22 changes: 11 additions & 11 deletions scripts/apl-feed.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,17 @@ if [[ -r "$APL_FEED_DAEMON_LIB_DIR/configure-validators.sh" ]]; then
# shellcheck source=scripts/lib/configure-validators.sh
source "$APL_FEED_DAEMON_LIB_DIR/configure-validators.sh"
else
valid_latitude() { echo "configure-validators.sh missing at $APL_FEED_DAEMON_LIB_DIR; reinstall feed" >&2; return 2; }
valid_longitude() { valid_latitude "$@"; }
valid_altitude() { valid_latitude "$@"; }
normalize_altitude() { valid_latitude "$@"; }
sanitize_mlat_user() { valid_latitude "$@"; }
valid_mlat_user_strict() { valid_latitude "$@"; }
valid_bool() { valid_latitude "$@"; }
valid_gain() { valid_latitude "$@"; }
valid_uat_input() { valid_latitude "$@"; }
valid_dump978_serial() { valid_latitude "$@"; }
valid_dump978_gain() { valid_latitude "$@"; }
valid_latitude() { echo "configure-validators.sh missing at $APL_FEED_DAEMON_LIB_DIR; reinstall feed" >&2; return 2; }
valid_longitude() { valid_latitude "$@"; }
valid_altitude() { valid_latitude "$@"; }
altitude_to_bare_metres() { valid_latitude "$@"; }
sanitize_mlat_user() { valid_latitude "$@"; }
valid_mlat_user_strict() { valid_latitude "$@"; }
valid_bool() { valid_latitude "$@"; }
valid_gain() { valid_latitude "$@"; }
valid_uat_input() { valid_latitude "$@"; }
valid_dump978_serial() { valid_latitude "$@"; }
valid_dump978_gain() { valid_latitude "$@"; }
fi

# Feed-env key registry + apply library. Pure data + pure functions, no
Expand Down
51 changes: 35 additions & 16 deletions scripts/apl-feed/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -223,18 +223,32 @@ _config_sync_build_payload() {
)
filter+=' | .fields.position = {value: $pos_value, edited_at: $pos_at, edited_by: $pos_by}'

# alt — nullable string.
# alt — nullable number (float metres). Operator/legacy disks may
# carry either bare metres (post-migration) or a suffixed string
# (pre-migration, or a hand-edit); altitude_to_bare_metres
# canonicalizes both shapes to a clean numeric on the wire. An
# unparseable on-disk value omits .fields.alt entirely and emits a
# journal warning — emitting `null` would be a tombstone, and combined
# with a fresh feeder-side edited_at could wipe a valid website value.
if _config_sync_has_key "$feed_env" ALTITUDE; then
local alt_at alt_by alt_meta
alt_meta="$(_config_sync_resolve_field_meta ALTITUDE meta_at meta_by)"
alt_at="${alt_meta%%$'\t'*}"
alt_by="${alt_meta##*$'\t'}"
jq_args+=(--arg alt_at "$alt_at" --arg alt_by "$alt_by")
if [[ -z "$alt" ]]; then
jq_args+=(--arg alt_at "$alt_at" --arg alt_by "$alt_by")
filter+=' | .fields.alt = {value: null, edited_at: $alt_at, edited_by: $alt_by}'
else
jq_args+=(--arg alt_v "$alt")
filter+=' | .fields.alt = {value: $alt_v, edited_at: $alt_at, edited_by: $alt_by}'
local alt_metres alt_rc=0
alt_metres="$(altitude_to_bare_metres "$alt")" || alt_rc=$?
if (( alt_rc != 0 )); then
local _alt_truncated="${alt:0:32}"
_alt_truncated="${_alt_truncated//\"/\\\"}"
_config_sync_log warn "reason=alt_unparseable value=\"$_alt_truncated\""
else
jq_args+=(--arg alt_at "$alt_at" --arg alt_by "$alt_by" --arg alt_v "$alt_metres")
filter+=' | .fields.alt = {value: ($alt_v | tonumber), edited_at: $alt_at, edited_by: $alt_by}'
fi
fi
fi

Expand Down Expand Up @@ -324,29 +338,34 @@ _config_sync_translate_response() {
APL_APPLY_INCOMING_META_EDITED_AT=()
APL_APPLY_INCOMING_META_EDITED_BY=()

# jq extracts one TAB-separated line per API field with: name,
# jq extracts one US-separated line per API field with: name,
# is_tombstone, value, edited_at, edited_by. Position's value is
# rendered as `lat|lon` so a single line carries both axes; bool
# values come out as `true`/`false`; null values render as empty
# string in the `value` column with `is_tombstone=1`.
#
# The separator is ASCII US (\x1F), not TAB, because bash `read`
# treats TAB as whitespace and collapses adjacent tabs (so a null
# value column would silently merge with the next field). US is a
# non-whitespace control character that never appears in valid feed
# data and that `read -r` treats as a single field boundary.
local entries
if ! entries="$(jq -r '
if ! entries="$(jq -j --arg sep $'\x1f' '
.fields | to_entries[] |
[.key,
(if .value.value == null then "1" else "0" end),
(if .value.value == null then ""
elif .key == "position" then "\(.value.value.lat)|\(.value.value.lon)"
elif (.value.value | type) == "boolean" then (.value.value | tostring)
else (.value.value | tostring) end),
.value.edited_at,
.value.edited_by] |
@tsv
([.key,
(if .value.value == null then "1" else "0" end),
(if .value.value == null then ""
elif .key == "position" then "\(.value.value.lat)|\(.value.value.lon)"
elif (.value.value | type) == "boolean" then (.value.value | tostring)
else (.value.value | tostring) end),
.value.edited_at,
.value.edited_by] | join($sep)) + "\n"
' "$response_file" 2>/dev/null)"; then
return 1
fi

local api_field is_null value edited_at edited_by
while IFS=$'\t' read -r api_field is_null value edited_at edited_by; do
while IFS=$'\x1f' read -r api_field is_null value edited_at edited_by; do
[[ -z "$api_field" ]] && continue
# The server-side serializer enforces this allowlist on inbound
# writes; mirror it on the response so a misbehaving server cannot
Expand Down
24 changes: 13 additions & 11 deletions scripts/apl-feed/mlat.sh
Original file line number Diff line number Diff line change
Expand Up @@ -236,17 +236,17 @@ apl_feed_mlat_geo() {

valid_latitude "$lat" || die "LATITUDE must be a decimal number in [-90, 90]"
valid_longitude "$lon" || die "LONGITUDE must be a decimal number in [-180, 180]"
valid_altitude "$alt" || die "ALTITUDE must match -?\\d+(\\.\\d+)?(m|ft)? in [-1000, 10000]"

local norm_alt
norm_alt="$(normalize_altitude "$alt")"
valid_altitude "$alt" || die "ALTITUDE must parse as a metric or imperial altitude in [-1000, 10000] metres (e.g. 120m, 400ft, 0)"

# The library auto-derives GEO_CONFIGURED from (lat, lon) when neither
# is explicitly set by the payload. Identical to configure.sh /
# webconfig semantics: both axes numerically zero → false, anything
# else → true.
_mlat_apply LATITUDE="$lat" LONGITUDE="$lon" ALTITUDE="$norm_alt"
_mlat_emit_result "LATITUDE=\"$lat\" LONGITUDE=\"$lon\" ALTITUDE=\"$norm_alt\""
# else → true. apl_feed_apply's canonicalizer flips suffixed input to
# bare metres on disk; no pre-conversion needed here.
_mlat_apply LATITUDE="$lat" LONGITUDE="$lon" ALTITUDE="$alt"
local on_disk_alt
on_disk_alt="$(altitude_to_bare_metres "$alt")"
_mlat_emit_result "LATITUDE=\"$lat\" LONGITUDE=\"$lon\" ALTITUDE=\"$on_disk_alt\""
}

# Interactive setup. Collects every input first (no partial writes if the
Expand Down Expand Up @@ -379,14 +379,16 @@ apl_feed_mlat_setup() {
;;
esac

local norm_alt
norm_alt="$(normalize_altitude "$alt")"
# Preview reflects what apl_feed_apply's canonicalizer will write to
# disk (bare metres). Operator typed `400ft`; on-disk lands as `121.92`.
local preview_alt
preview_alt="$(altitude_to_bare_metres "$alt")"

echo
echo "About to write:"
echo " LATITUDE=$lat"
echo " LONGITUDE=$lon"
echo " ALTITUDE=$norm_alt"
echo " ALTITUDE=$preview_alt"
echo " GEO_CONFIGURED=true"
echo " MLAT_USER=\"$name\""
echo " MLAT_PRIVATE=$private"
Expand All @@ -400,7 +402,7 @@ apl_feed_mlat_setup() {
_mlat_apply \
LATITUDE="$lat" \
LONGITUDE="$lon" \
ALTITUDE="$norm_alt" \
ALTITUDE="$alt" \
GEO_CONFIGURED=true \
MLAT_USER="$name" \
MLAT_PRIVATE="$private" \
Expand Down
84 changes: 58 additions & 26 deletions scripts/lib/configure-validators.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
# exit status (validators) or stdout (normalizer). No global-state reads
# or writes — safe to source anywhere.
#
# Altitude unit policy (intentional split):
# `valid_altitude` accepts unitless integers (e.g. `0`, `123`) so build-mode
# feeders configured non-interactively with AIRPLANES_ALTITUDE=0 don't get
# rejected. The interactive whiptail loop in configure.sh enforces a
# stricter `^-?[0-9]+(ft|m)$` shape because the operator is being asked
# for an antenna altitude and units must be explicit. Both rules coexist
# on purpose.
# Altitude unit policy:
# `valid_altitude` accepts the union of operator-input shapes (`120`,
# `120m`, `400ft`, `42.5`, `42.5m`) plus the empty string (tombstone
# passthrough for the inbound `alt.value: null` round-trip). Conversion
# to canonical bare metres happens through `altitude_to_bare_metres`,
# which owns the regex AND the post-conversion `[-1000, 10000]` metres
# range gate. The interactive whiptail loop in configure.sh still
# enforces a stricter `^-?[0-9]+(ft|m)$` shape for explicit-units UX,
# but the validator and canonicalizer are unit-tolerant.
#
# `sanitize_mlat_user` character set:
# The `tr -c '[a-zA-Z0-9]_\- ' '_'` filter replaces every character NOT
Expand Down Expand Up @@ -43,16 +45,56 @@ valid_longitude() {
&& awk -v LON="$1" 'BEGIN { exit !(LON <= 180 && LON >= -180) }'
}

# Altitude accepts integers and decimals, optional `m`/`ft` suffix, and is
# numerically range-checked against [-1000, 10000] to match Go configspec.
# The previous integer-only rule rejected legitimate decimal antenna
# heights (e.g. `120.5m`) and skipped the range check entirely.
# Convert an altitude input to bare metres on stdout. Single source of
# truth across configure.sh, apl-feed apply, apl-feed config sync (outbound),
# and the on-disk migrator. The Go and JS mirrors in image-webconfig consume
# the same fixture file (test/fixtures/altitude-canonicalization.json) so
# byte-exact equality holds across every writer.
#
# Inputs (regex shape):
# "" — empty stdout, rc 0 (tombstone passthrough)
# <n> — bare metres, stdout <n> (already bare)
# <n>m — strip m, stdout <n>
# <n>ft — multiply by 0.3048, stdout <result>
# Range gate (POST-CONVERSION metres): [-1000, 10000] closed; out-of-range
# returns rc 1 with empty stdout. Regex-shape failures also return rc 1.
#
# Output format: fixed-point (%.10f) with trailing-zero-after-decimal trim
# and bare-trailing-dot trim. Never exponential. Examples:
# "120m" -> "120"
# "400ft" -> "121.92"
# "32808ft" -> "9999.8784"
# "-50ft" -> "-15.24"
# "33000ft" -> "" + rc 1 (out of range; ~10058m)
altitude_to_bare_metres() {
local raw="$1"
if [[ -z "$raw" ]]; then
return 0
fi
[[ "$raw" =~ ^(-?[0-9]+([.][0-9]+)?)(ft|m)?$ ]] || return 1
local num="${BASH_REMATCH[1]}"
local suffix="${BASH_REMATCH[3]}"
local mult=1
if [[ "$suffix" == "ft" ]]; then
mult="0.3048"
fi
local metres
metres="$(awk -v V="$num" -v MULT="$mult" 'BEGIN { printf "%.10f\n", V * MULT }')"
awk -v ALT="$metres" 'BEGIN { exit !(ALT >= -1000 && ALT <= 10000) }' || return 1
# Trim trailing zeros after the decimal point, and a bare trailing dot.
metres="$(printf '%s' "$metres" | sed -E 's/\.?0+$//')"
printf '%s' "$metres"
}

# Altitude validator: empty is accepted (tombstone passthrough from a server
# `alt.value: null` round-trip), non-empty delegates to altitude_to_bare_metres
# which owns both the regex shape and the post-conversion metres range gate.
# Range matches airplanes-live/website's accounts/serializers/feeder.py alt
# validator (`[-1000, 10000]` metres) — so `20000ft` (~6096m) is now accepted
# and `33000ft` (~10058m) is now rejected.
valid_altitude() {
[[ "$1" =~ ^-?[0-9]+([.][0-9]+)?(ft|m)?$ ]] || return 1
local num="${BASH_REMATCH[0]}"
num="${num%ft}"
num="${num%m}"
awk -v ALT="$num" 'BEGIN { exit !(ALT >= -1000 && ALT <= 10000) }'
[[ -z "$1" ]] && return 0
altitude_to_bare_metres "$1" >/dev/null
}

# Strict shape match for canonical MLAT_USER input. Mirrors Go
Expand Down Expand Up @@ -105,13 +147,3 @@ valid_dump978_gain() {
awk -v G="$1" 'BEGIN { exit !(G >= 0 && G <= 60) }'
}

normalize_altitude() {
local alt="$1"
if [[ $alt =~ ^-([0-9]+)ft$ ]]; then
awk -v NUM="${BASH_REMATCH[1]}" 'BEGIN { printf "-%0.2f", NUM / 3.28 }'
elif [[ $alt =~ ^-([0-9]+)m$ ]]; then
printf -- '-%s' "${BASH_REMATCH[1]}"
else
printf '%s' "$alt"
fi
}
19 changes: 9 additions & 10 deletions scripts/lib/feed-env-apply.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Public entry point: apl_feed_apply
#
# Helper deps the caller must source first:
# - configure-validators.sh (valid_*, sanitize_mlat_user, normalize_altitude)
# - configure-validators.sh (valid_*, sanitize_mlat_user, altitude_to_bare_metres)
# - feed-env-keys.sh (APL_FEED_WRITABLE_KEYS, APL_FEED_KEY_TYPE, etc.)
#
# Optional helper deps (consulted defensively):
Expand Down Expand Up @@ -158,7 +158,7 @@ _apl_feed_apply_validate_one() {
;;
altitude)
if ! valid_altitude "$value"; then
APL_APPLY_ERRORS[$key]='must match -?\d+(\.\d+)?(m|ft)? in [-1000, 10000]'
APL_APPLY_ERRORS[$key]='must parse as a metric or imperial altitude in [-1000, 10000] metres (e.g. 120m, 400ft, 0)'
return 1
fi
;;
Expand Down Expand Up @@ -441,15 +441,14 @@ _apl_feed_apply_restart_services() {
(( ${#APL_APPLY_PENDING_RESTART[@]} == 0 ))
}

# ALTITUDE canonicalization: ensure an explicit `m`/`ft` suffix on disk.
# Mirrors Go configspec.Canonicalize. Validate must succeed first.
# ALTITUDE canonicalization: store as bare metres (no `m`/`ft` suffix).
# Thin wrapper through altitude_to_bare_metres; valid_altitude must have
# succeeded first so any non-empty input that reaches this function
# already passes regex + post-conversion range. Empty values
# pass through unchanged (tombstone semantics; `alt.value: null` round-trip
# from the server lands as ALTITUDE="" on disk).
_apl_feed_apply_canonicalize_altitude() {
local v="$1"
case "$v" in
*m) printf '%s' "$v" ;;
*ft) printf '%s' "$v" ;;
*) printf '%sm' "$v" ;;
esac
altitude_to_bare_metres "$1"
}

# Predicate: returns 0 if the given key is one of the sidecar-tracked keys.
Expand Down
Loading