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:
Graham McIntire 2026-02-20 18:06:20 -06:00
parent a92f63237f
commit b45993a8be
No known key found for this signature in database
8 changed files with 170 additions and 20 deletions

View file

@ -19,6 +19,21 @@ defmodule Aprsme.Encoding do
@spec sanitize_string(any()) :: binary()
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 """
Type-safe float conversion with validation
"""

View file

@ -31,6 +31,27 @@ defmodule Aprsme.EncodingUtils do
def sanitize_string(nil), do: nil
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 """
Converts various types to float with validation for safety.
@ -261,13 +282,15 @@ defmodule Aprsme.EncodingUtils do
defp sanitize_string_fields(packet) do
Enum.reduce(@string_fields, packet, fn field, acc ->
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
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.update(acc, string_key, nil, &sanitize_string/1)
Map.update(acc, string_key, nil, sanitizer)
true ->
acc

View file

@ -136,7 +136,7 @@
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">{gettext("Comment")}</dt>
<dd class="mt-1 text-sm">
<%= if is_binary(@packet.comment) do %>
{Aprsme.EncodingUtils.sanitize_string(@packet.comment)}
{Aprsme.EncodingUtils.sanitize_comment(@packet.comment)}
<% else %>
{@packet.comment}
<% end %>

View file

@ -127,16 +127,22 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
historical_dot_html(callsign)
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)}"),
"lat" => to_float(lat),
"lng" => to_float(lon),
"callsign" => callsign,
"callsign" => label,
"callsign_group" => callsign,
"symbol_table_id" => symbol_table_id,
"symbol_code" => symbol_code,
"symbol_html" => symbol_html,
"comment" => get_packet_field(packet, :comment, ""),
"comment" => clean_comment,
"timestamp" => get_packet_timestamp_unix(packet),
"historical" => !is_most_recent,
"is_most_recent_for_callsign" => is_most_recent,
@ -263,9 +269,12 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|> IO.iodata_to_binary()
else
# Build standard popup
raw_comment = get_packet_field(packet, :comment, "")
clean_comment = Aprsme.EncodingUtils.sanitize_comment(raw_comment)
%{
callsign: callsign,
comment: get_packet_field(packet, :comment, ""),
comment: clean_comment,
timestamp_dt: timestamp_dt,
cache_buster: cache_buster,
weather: false,
@ -331,12 +340,16 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
@spec extract_packet_info(map(), map()) :: map()
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_code: get_packet_field(packet, :symbol_code, ">"),
timestamp: get_timestamp(packet),
comment: get_packet_field(packet, :comment, ""),
comment: clean_comment,
safe_data_extended: convert_tuples_to_strings(data_extended),
is_weather_packet: weather_packet?(packet)
}
@ -447,7 +460,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
%{
"id" => packet_id,
"callsign" => packet_info.callsign,
"callsign_group" => packet_info.callsign,
"callsign_group" => packet_info.callsign_group,
"base_callsign" => get_packet_field(packet, :base_callsign, ""),
"ssid" => get_packet_field(packet, :ssid, nil),
"lat" => to_float(lat),
@ -492,6 +505,11 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
SharedPacketUtils.display_name(packet)
end
@spec map_label(map()) :: String.t()
defp map_label(packet) do
SharedPacketUtils.map_label(packet)
end
@spec weather_packet?(map()) :: boolean()
defp weather_packet?(packet) do
SharedPacketUtils.weather_packet?(packet)

View file

@ -232,6 +232,23 @@ defmodule AprsmeWeb.Live.Shared.PacketUtils do
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?(_), do: false

View file

@ -169,4 +169,36 @@ defmodule Aprsme.EncodingTest do
assert Encoding.to_hex(<<255>>) == "FF"
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

View file

@ -371,4 +371,35 @@ defmodule Aprsme.EncodingUtilsTest do
assert info.char_count == 4
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

View file

@ -4,7 +4,7 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
alias AprsmeWeb.MapLive.DataBuilder
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 = %{
id: Ecto.UUID.generate(),
sender: "DB0SDA",
@ -27,10 +27,13 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
result = DataBuilder.build_packet_data(packet, true)
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
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 = %{
id: Ecto.UUID.generate(),
sender: "N0CALL",
@ -53,7 +56,10 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
result = DataBuilder.build_packet_data(packet, true)
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
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"
end
test "uses object_name in popup for object packets" do
test "uses sender in popup for object packets" do
packet = %{
id: Ecto.UUID.generate(),
sender: "DB0SDA",
@ -105,11 +111,12 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
result = DataBuilder.build_packet_data(packet, true)
assert result["popup"] =~ "P-K5SGD"
refute result["popup"] =~ ">DB0SDA<"
# Popup now shows sender for consistency with map label
assert result["popup"] =~ "DB0SDA"
refute result["popup"] =~ "P-K5SGD"
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 = %{
id: Ecto.UUID.generate(),
sender: "DB0SDA",
@ -132,7 +139,10 @@ defmodule AprsmeWeb.MapLive.DataBuilderTest do
result = DataBuilder.build_minimal_packet_data(packet, true, false)
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
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])
# Both packets should be grouped under P-K5SGD, not DB0SDA
# Map labels should show sender
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