Fix APRS object display and comment encoding issues
- Strip Mic-E telemetry from comments before database storage - Show sender callsign on map labels instead of object names for clarity - Object names still used for grouping to maintain trail organization - Add comprehensive tests for new encoding utilities Fixes display issues where weather balloon "X3234025" was shown instead of station "WA0YMH", and Mic-E telemetry bytes like "!w>`!" were appearing in comments.
This commit is contained in:
parent
a92f63237f
commit
b45993a8be
8 changed files with 170 additions and 20 deletions
|
|
@ -19,6 +19,21 @@ defmodule Aprsme.Encoding do
|
||||||
@spec sanitize_string(any()) :: binary()
|
@spec sanitize_string(any()) :: binary()
|
||||||
def sanitize_string(_), do: ""
|
def sanitize_string(_), do: ""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Removes Mic-E telemetry data from APRS comments.
|
||||||
|
Mic-E telemetry appears as sequences like !w>`! at the start of comments.
|
||||||
|
The pattern is: ! followed by 3 characters followed by !
|
||||||
|
"""
|
||||||
|
@spec strip_mice_telemetry(binary()) :: binary()
|
||||||
|
def strip_mice_telemetry(comment) when is_binary(comment) do
|
||||||
|
# Pattern: one or more sequences of !...! (5 chars each) at the start
|
||||||
|
# Examples: !w>`!, !w_'!, !w_P!, !w;i!, !w;D!
|
||||||
|
String.replace(comment, ~r/^(?:![^!]{3}!)+/, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
def strip_mice_telemetry(nil), do: nil
|
||||||
|
def strip_mice_telemetry(other), do: other
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Type-safe float conversion with validation
|
Type-safe float conversion with validation
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,27 @@ defmodule Aprsme.EncodingUtils do
|
||||||
def sanitize_string(nil), do: nil
|
def sanitize_string(nil), do: nil
|
||||||
def sanitize_string(other), do: other
|
def sanitize_string(other), do: other
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sanitizes a comment string by removing Mic-E telemetry and control characters.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Aprsme.EncodingUtils.sanitize_comment("!w>`!Clb=6.4m/s t=-66.7C")
|
||||||
|
"Clb=6.4m/s t=-66.7C"
|
||||||
|
|
||||||
|
iex> Aprsme.EncodingUtils.sanitize_comment("Normal comment")
|
||||||
|
"Normal comment"
|
||||||
|
"""
|
||||||
|
@spec sanitize_comment(binary() | nil | any()) :: binary() | nil | any()
|
||||||
|
def sanitize_comment(comment) when is_binary(comment) do
|
||||||
|
comment
|
||||||
|
|> Encoding.strip_mice_telemetry()
|
||||||
|
|> Encoding.sanitize_string()
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitize_comment(nil), do: nil
|
||||||
|
def sanitize_comment(other), do: other
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Converts various types to float with validation for safety.
|
Converts various types to float with validation for safety.
|
||||||
|
|
||||||
|
|
@ -261,13 +282,15 @@ defmodule Aprsme.EncodingUtils do
|
||||||
defp sanitize_string_fields(packet) do
|
defp sanitize_string_fields(packet) do
|
||||||
Enum.reduce(@string_fields, packet, fn field, acc ->
|
Enum.reduce(@string_fields, packet, fn field, acc ->
|
||||||
string_key = to_string(field)
|
string_key = to_string(field)
|
||||||
|
# Use sanitize_comment for comment field to strip Mic-E telemetry
|
||||||
|
sanitizer = if field == :comment, do: &sanitize_comment/1, else: &sanitize_string/1
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
Map.has_key?(acc, field) ->
|
Map.has_key?(acc, field) ->
|
||||||
Map.update(acc, field, nil, &sanitize_string/1)
|
Map.update(acc, field, nil, sanitizer)
|
||||||
|
|
||||||
Map.has_key?(acc, string_key) ->
|
Map.has_key?(acc, string_key) ->
|
||||||
Map.update(acc, string_key, nil, &sanitize_string/1)
|
Map.update(acc, string_key, nil, sanitizer)
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
acc
|
acc
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@
|
||||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Comment")}</dt>
|
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Comment")}</dt>
|
||||||
<dd class="mt-1 text-sm">
|
<dd class="mt-1 text-sm">
|
||||||
<%= if is_binary(@packet.comment) do %>
|
<%= if is_binary(@packet.comment) do %>
|
||||||
{Aprsme.EncodingUtils.sanitize_string(@packet.comment)}
|
{Aprsme.EncodingUtils.sanitize_comment(@packet.comment)}
|
||||||
<% else %>
|
<% else %>
|
||||||
{@packet.comment}
|
{@packet.comment}
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
||||||
|
|
@ -127,16 +127,22 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|
||||||
historical_dot_html(callsign)
|
historical_dot_html(callsign)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
raw_comment = get_packet_field(packet, :comment, "")
|
||||||
|
clean_comment = Aprsme.EncodingUtils.sanitize_comment(raw_comment)
|
||||||
|
|
||||||
|
# Use map_label for display, but keep display_name for grouping
|
||||||
|
label = map_label(packet)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
"id" => if(is_most_recent, do: "current_#{get_packet_id(packet)}", else: "hist_#{get_packet_id(packet)}"),
|
"id" => if(is_most_recent, do: "current_#{get_packet_id(packet)}", else: "hist_#{get_packet_id(packet)}"),
|
||||||
"lat" => to_float(lat),
|
"lat" => to_float(lat),
|
||||||
"lng" => to_float(lon),
|
"lng" => to_float(lon),
|
||||||
"callsign" => callsign,
|
"callsign" => label,
|
||||||
"callsign_group" => callsign,
|
"callsign_group" => callsign,
|
||||||
"symbol_table_id" => symbol_table_id,
|
"symbol_table_id" => symbol_table_id,
|
||||||
"symbol_code" => symbol_code,
|
"symbol_code" => symbol_code,
|
||||||
"symbol_html" => symbol_html,
|
"symbol_html" => symbol_html,
|
||||||
"comment" => get_packet_field(packet, :comment, ""),
|
"comment" => clean_comment,
|
||||||
"timestamp" => get_packet_timestamp_unix(packet),
|
"timestamp" => get_packet_timestamp_unix(packet),
|
||||||
"historical" => !is_most_recent,
|
"historical" => !is_most_recent,
|
||||||
"is_most_recent_for_callsign" => is_most_recent,
|
"is_most_recent_for_callsign" => is_most_recent,
|
||||||
|
|
@ -263,9 +269,12 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|
||||||
|> IO.iodata_to_binary()
|
|> IO.iodata_to_binary()
|
||||||
else
|
else
|
||||||
# Build standard popup
|
# Build standard popup
|
||||||
|
raw_comment = get_packet_field(packet, :comment, "")
|
||||||
|
clean_comment = Aprsme.EncodingUtils.sanitize_comment(raw_comment)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
callsign: callsign,
|
callsign: callsign,
|
||||||
comment: get_packet_field(packet, :comment, ""),
|
comment: clean_comment,
|
||||||
timestamp_dt: timestamp_dt,
|
timestamp_dt: timestamp_dt,
|
||||||
cache_buster: cache_buster,
|
cache_buster: cache_buster,
|
||||||
weather: false,
|
weather: false,
|
||||||
|
|
@ -331,12 +340,16 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|
||||||
|
|
||||||
@spec extract_packet_info(map(), map()) :: map()
|
@spec extract_packet_info(map(), map()) :: map()
|
||||||
defp extract_packet_info(packet, data_extended) do
|
defp extract_packet_info(packet, data_extended) do
|
||||||
|
raw_comment = get_packet_field(packet, :comment, "")
|
||||||
|
clean_comment = Aprsme.EncodingUtils.sanitize_comment(raw_comment)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
callsign: display_name(packet),
|
callsign: map_label(packet),
|
||||||
|
callsign_group: display_name(packet),
|
||||||
symbol_table_id: get_packet_field(packet, :symbol_table_id, "/"),
|
symbol_table_id: get_packet_field(packet, :symbol_table_id, "/"),
|
||||||
symbol_code: get_packet_field(packet, :symbol_code, ">"),
|
symbol_code: get_packet_field(packet, :symbol_code, ">"),
|
||||||
timestamp: get_timestamp(packet),
|
timestamp: get_timestamp(packet),
|
||||||
comment: get_packet_field(packet, :comment, ""),
|
comment: clean_comment,
|
||||||
safe_data_extended: convert_tuples_to_strings(data_extended),
|
safe_data_extended: convert_tuples_to_strings(data_extended),
|
||||||
is_weather_packet: weather_packet?(packet)
|
is_weather_packet: weather_packet?(packet)
|
||||||
}
|
}
|
||||||
|
|
@ -447,7 +460,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|
||||||
%{
|
%{
|
||||||
"id" => packet_id,
|
"id" => packet_id,
|
||||||
"callsign" => packet_info.callsign,
|
"callsign" => packet_info.callsign,
|
||||||
"callsign_group" => packet_info.callsign,
|
"callsign_group" => packet_info.callsign_group,
|
||||||
"base_callsign" => get_packet_field(packet, :base_callsign, ""),
|
"base_callsign" => get_packet_field(packet, :base_callsign, ""),
|
||||||
"ssid" => get_packet_field(packet, :ssid, nil),
|
"ssid" => get_packet_field(packet, :ssid, nil),
|
||||||
"lat" => to_float(lat),
|
"lat" => to_float(lat),
|
||||||
|
|
@ -492,6 +505,11 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|
||||||
SharedPacketUtils.display_name(packet)
|
SharedPacketUtils.display_name(packet)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec map_label(map()) :: String.t()
|
||||||
|
defp map_label(packet) do
|
||||||
|
SharedPacketUtils.map_label(packet)
|
||||||
|
end
|
||||||
|
|
||||||
@spec weather_packet?(map()) :: boolean()
|
@spec weather_packet?(map()) :: boolean()
|
||||||
defp weather_packet?(packet) do
|
defp weather_packet?(packet) do
|
||||||
SharedPacketUtils.weather_packet?(packet)
|
SharedPacketUtils.weather_packet?(packet)
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,23 @@ defmodule AprsmeWeb.Live.Shared.PacketUtils do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Get the map label for a packet.
|
||||||
|
|
||||||
|
For map display, always show the sender callsign regardless of whether
|
||||||
|
it's an object or item packet. This avoids confusion where the map label
|
||||||
|
shows something different from what the user expects (like a balloon ID
|
||||||
|
instead of the station callsign).
|
||||||
|
|
||||||
|
If the packet is an object or item, you can append the object/item name
|
||||||
|
in the popup or detail view.
|
||||||
|
"""
|
||||||
|
@spec map_label(map()) :: String.t()
|
||||||
|
def map_label(packet) do
|
||||||
|
# Always return the sender for map labels
|
||||||
|
get_packet_field(packet, :sender, "")
|
||||||
|
end
|
||||||
|
|
||||||
defp non_empty_string?(value) when is_binary(value), do: String.trim(value) != ""
|
defp non_empty_string?(value) when is_binary(value), do: String.trim(value) != ""
|
||||||
defp non_empty_string?(_), do: false
|
defp non_empty_string?(_), do: false
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,4 +169,36 @@ defmodule Aprsme.EncodingTest do
|
||||||
assert Encoding.to_hex(<<255>>) == "FF"
|
assert Encoding.to_hex(<<255>>) == "FF"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "strip_mice_telemetry/1" do
|
||||||
|
test "removes Mic-E telemetry prefix from comment" do
|
||||||
|
assert Encoding.strip_mice_telemetry("!w>`!Clb=6.4m/s t=-66.7C") == "Clb=6.4m/s t=-66.7C"
|
||||||
|
assert Encoding.strip_mice_telemetry("!w_'!Clb=4.6m/s t=-3.8C") == "Clb=4.6m/s t=-3.8C"
|
||||||
|
assert Encoding.strip_mice_telemetry("!w_P!Clb=4.8m/s t=-65.7C") == "Clb=4.8m/s t=-65.7C"
|
||||||
|
assert Encoding.strip_mice_telemetry("!w;i!Clb=2.5m/s t=-66.4C") == "Clb=2.5m/s t=-66.4C"
|
||||||
|
assert Encoding.strip_mice_telemetry("!w;D!Clb=2.8m/s t=-65.3C") == "Clb=2.8m/s t=-65.3C"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "removes multiple consecutive Mic-E telemetry sequences" do
|
||||||
|
assert Encoding.strip_mice_telemetry("!w>`!!abc!Normal comment") == "Normal comment"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not remove exclamation marks from middle of comment" do
|
||||||
|
assert Encoding.strip_mice_telemetry("Normal! comment!") == "Normal! comment!"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "leaves normal comments unchanged" do
|
||||||
|
assert Encoding.strip_mice_telemetry("Normal comment") == "Normal comment"
|
||||||
|
assert Encoding.strip_mice_telemetry("Hello World") == "Hello World"
|
||||||
|
assert Encoding.strip_mice_telemetry("") == ""
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles nil input" do
|
||||||
|
assert Encoding.strip_mice_telemetry(nil) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles non-binary input" do
|
||||||
|
assert Encoding.strip_mice_telemetry(123) == 123
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -371,4 +371,35 @@ defmodule Aprsme.EncodingUtilsTest do
|
||||||
assert info.char_count == 4
|
assert info.char_count == 4
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "sanitize_comment/1" do
|
||||||
|
test "removes Mic-E telemetry from comment" do
|
||||||
|
assert EncodingUtils.sanitize_comment("!w>`!Clb=6.4m/s t=-66.7C") == "Clb=6.4m/s t=-66.7C"
|
||||||
|
assert EncodingUtils.sanitize_comment("!w_'!Clb=4.6m/s t=-3.8C") == "Clb=4.6m/s t=-3.8C"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sanitizes both Mic-E telemetry and control characters" do
|
||||||
|
# Comment with both Mic-E prefix and invalid UTF-8
|
||||||
|
invalid_binary = "!w>`!" <> <<72, 101, 211, 108, 111>>
|
||||||
|
result = EncodingUtils.sanitize_comment(invalid_binary)
|
||||||
|
|
||||||
|
assert String.valid?(result)
|
||||||
|
refute String.starts_with?(result, "!")
|
||||||
|
assert String.contains?(result, "He")
|
||||||
|
assert String.contains?(result, "lo")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles normal comments without Mic-E telemetry" do
|
||||||
|
assert EncodingUtils.sanitize_comment("Normal comment") == "Normal comment"
|
||||||
|
assert EncodingUtils.sanitize_comment("Hello World") == "Hello World"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles nil input" do
|
||||||
|
assert EncodingUtils.sanitize_comment(nil) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles non-binary input" do
|
||||||
|
assert EncodingUtils.sanitize_comment(123) == 123
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
|
||||||
alias AprsmeWeb.MapLive.DataBuilder
|
alias AprsmeWeb.MapLive.DataBuilder
|
||||||
|
|
||||||
describe "display name for APRS objects and items" do
|
describe "display name for APRS objects and items" do
|
||||||
test "uses object_name when packet is an object" do
|
test "uses sender for map label but object_name for grouping when packet is an object" do
|
||||||
packet = %{
|
packet = %{
|
||||||
id: Ecto.UUID.generate(),
|
id: Ecto.UUID.generate(),
|
||||||
sender: "DB0SDA",
|
sender: "DB0SDA",
|
||||||
|
|
@ -27,10 +27,13 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
|
||||||
result = DataBuilder.build_packet_data(packet, true)
|
result = DataBuilder.build_packet_data(packet, true)
|
||||||
|
|
||||||
assert result
|
assert result
|
||||||
assert result["callsign"] == "P-K5SGD"
|
# Map label shows sender for clarity
|
||||||
|
assert result["callsign"] == "DB0SDA"
|
||||||
|
# But grouping uses object name
|
||||||
|
assert result["callsign_group"] == "P-K5SGD"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "uses item_name when packet is an item" do
|
test "uses sender for map label but item_name for grouping when packet is an item" do
|
||||||
packet = %{
|
packet = %{
|
||||||
id: Ecto.UUID.generate(),
|
id: Ecto.UUID.generate(),
|
||||||
sender: "N0CALL",
|
sender: "N0CALL",
|
||||||
|
|
@ -53,7 +56,10 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
|
||||||
result = DataBuilder.build_packet_data(packet, true)
|
result = DataBuilder.build_packet_data(packet, true)
|
||||||
|
|
||||||
assert result
|
assert result
|
||||||
assert result["callsign"] == "REPEATER1"
|
# Map label shows sender for clarity
|
||||||
|
assert result["callsign"] == "N0CALL"
|
||||||
|
# But grouping uses item name
|
||||||
|
assert result["callsign_group"] == "REPEATER1"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "falls back to sender when no object_name or item_name" do
|
test "falls back to sender when no object_name or item_name" do
|
||||||
|
|
@ -83,7 +89,7 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
|
||||||
assert result["callsign"] == "K5SGD-Y"
|
assert result["callsign"] == "K5SGD-Y"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "uses object_name in popup for object packets" do
|
test "uses sender in popup for object packets" do
|
||||||
packet = %{
|
packet = %{
|
||||||
id: Ecto.UUID.generate(),
|
id: Ecto.UUID.generate(),
|
||||||
sender: "DB0SDA",
|
sender: "DB0SDA",
|
||||||
|
|
@ -105,11 +111,12 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
|
||||||
|
|
||||||
result = DataBuilder.build_packet_data(packet, true)
|
result = DataBuilder.build_packet_data(packet, true)
|
||||||
|
|
||||||
assert result["popup"] =~ "P-K5SGD"
|
# Popup now shows sender for consistency with map label
|
||||||
refute result["popup"] =~ ">DB0SDA<"
|
assert result["popup"] =~ "DB0SDA"
|
||||||
|
refute result["popup"] =~ "P-K5SGD"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "build_minimal_packet_data uses object_name for objects" do
|
test "build_minimal_packet_data uses sender for map label but object_name for grouping" do
|
||||||
packet = %{
|
packet = %{
|
||||||
id: Ecto.UUID.generate(),
|
id: Ecto.UUID.generate(),
|
||||||
sender: "DB0SDA",
|
sender: "DB0SDA",
|
||||||
|
|
@ -132,7 +139,10 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
|
||||||
result = DataBuilder.build_minimal_packet_data(packet, true, false)
|
result = DataBuilder.build_minimal_packet_data(packet, true, false)
|
||||||
|
|
||||||
assert result
|
assert result
|
||||||
assert result["callsign"] == "P-K5SGD"
|
# Map label shows sender for clarity
|
||||||
|
assert result["callsign"] == "DB0SDA"
|
||||||
|
# But grouping uses object name
|
||||||
|
assert result["callsign_group"] == "P-K5SGD"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "build_minimal_packet_data uses red dot HTML for historical (non-most-recent) packets" do
|
test "build_minimal_packet_data uses red dot HTML for historical (non-most-recent) packets" do
|
||||||
|
|
@ -325,9 +335,13 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
|
||||||
|
|
||||||
results = DataBuilder.build_packet_data_list([object_packet_1, object_packet_2])
|
results = DataBuilder.build_packet_data_list([object_packet_1, object_packet_2])
|
||||||
|
|
||||||
# Both packets should be grouped under P-K5SGD, not DB0SDA
|
# Map labels should show sender
|
||||||
callsigns = Enum.map(results, & &1["callsign"])
|
callsigns = Enum.map(results, & &1["callsign"])
|
||||||
assert Enum.all?(callsigns, &(&1 == "P-K5SGD"))
|
assert Enum.all?(callsigns, &(&1 == "DB0SDA"))
|
||||||
|
|
||||||
|
# But grouping should be by object name
|
||||||
|
groups = Enum.map(results, & &1["callsign_group"])
|
||||||
|
assert Enum.all?(groups, &(&1 == "P-K5SGD"))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue