defmodule Aprsme.EncodingUtils do @moduledoc """ Encoding utilities for handling APRS packet data. Provides functions for sanitizing strings, converting encodings, and validating data. """ alias Aprs.Types.MicE alias Aprsme.Encoding @doc """ Sanitizes a binary to ensure it can be safely JSON encoded. If the binary is valid UTF-8, returns it as-is. If it contains invalid UTF-8 sequences, replaces them with the Unicode replacement character (�) or removes them entirely. ## Examples iex> Aprsme.EncodingUtils.sanitize_string("Hello World") "Hello World" iex> Aprsme.EncodingUtils.sanitize_string(<<72, 101, 108, 108, 111, 211, 87, 111, 114, 108, 100>>) "HelloÓWorld" """ @spec sanitize_string(binary() | nil | any()) :: binary() | nil | any() def sanitize_string(binary) when is_binary(binary) do Encoding.sanitize_string(binary) end 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. ## Examples iex> Aprsme.EncodingUtils.to_float(1) 1.0 iex> Aprsme.EncodingUtils.to_float(1.5) 1.5 iex> Aprsme.EncodingUtils.to_float("2.3") 2.3 iex> Aprsme.EncodingUtils.to_float("bad") nil iex> Aprsme.EncodingUtils.to_float(nil) nil """ @spec to_float(any()) :: float() | nil def to_float(value) when is_float(value) do # In Elixir, floats are always finite (no infinity or NaN) value end def to_float(value) when is_integer(value) do if value >= -9.0e15 and value <= 9.0e15 do value * 1.0 end end def to_float(value) when is_binary(value) do # Sanitize and use Gleam's safe conversion sanitized = value |> sanitize_string() |> to_string() case Encoding.to_float_safe(sanitized) do {:ok, f} -> f nil -> nil end end def to_float(%Decimal{} = value) do Decimal.to_float(value) end def to_float(_), do: nil @doc """ Converts various types to Decimal for database storage. ## Examples iex> is_struct(Aprsme.EncodingUtils.to_decimal(1), Decimal) true iex> is_struct(Aprsme.EncodingUtils.to_decimal(1.5), Decimal) true iex> is_struct(Aprsme.EncodingUtils.to_decimal("2.3"), Decimal) true iex> Aprsme.EncodingUtils.to_decimal("bad") nil iex> Aprsme.EncodingUtils.to_decimal(nil) nil """ @spec to_decimal(any()) :: Decimal.t() | nil def to_decimal(%Decimal{} = d), do: d def to_decimal(f) when is_float(f), do: Decimal.from_float(f) def to_decimal(i) when is_integer(i), do: Decimal.new(i) def to_decimal(s) when is_binary(s) do case Decimal.parse(s) do {d, _} -> d :error -> case Float.parse(s) do {f, _} -> Decimal.from_float(f) :error -> nil end end end def to_decimal(_), do: nil @doc """ Sanitizes all string fields in packet data before database storage. ## Examples iex> Aprsme.EncodingUtils.sanitize_packet_strings(["abc", <<255>>]) ["abc", "ÿ"] iex> Aprsme.EncodingUtils.sanitize_packet_strings(%{"foo" => <<0, 65, 66, 67>>}) %{"foo" => "ABC"} iex> Aprsme.EncodingUtils.sanitize_packet_strings(nil) nil """ @spec sanitize_packet_strings(any()) :: any() def sanitize_packet_strings(%DateTime{} = dt), do: dt def sanitize_packet_strings(%NaiveDateTime{} = ndt), do: ndt def sanitize_packet_strings(%_struct{} = struct), do: struct |> Map.from_struct() |> sanitize_packet_strings() def sanitize_packet_strings(list) when is_list(list), do: Enum.map(list, &sanitize_packet_strings/1) def sanitize_packet_strings(map) when is_map(map) do Enum.reduce(map, %{}, fn {key, value}, acc -> Map.put(acc, key, sanitize_packet_strings(value)) end) end def sanitize_packet_strings(binary) when is_binary(binary) do s = sanitize_string(binary) if is_binary(s), do: s, else: "" end def sanitize_packet_strings(other), do: other @doc """ Normalizes data_type field to ensure it's always a string. ## Examples iex> Aprsme.EncodingUtils.normalize_data_type(%{data_type: :weather}) %{data_type: "weather"} iex> Aprsme.EncodingUtils.normalize_data_type(%{"data_type" => :foo}) %{"data_type" => "foo"} iex> Aprsme.EncodingUtils.normalize_data_type(%{data_type: "bar"}) %{data_type: "bar"} iex> Aprsme.EncodingUtils.normalize_data_type(%{"data_type" => "baz"}) %{"data_type" => "baz"} iex> Aprsme.EncodingUtils.normalize_data_type(%{foo: 1}) %{foo: 1} """ @spec normalize_data_type(map()) :: map() def normalize_data_type(%{data_type: data_type} = attrs) when is_atom(data_type) do %{attrs | data_type: to_string(data_type)} end def normalize_data_type(%{"data_type" => data_type} = attrs) when is_atom(data_type) do %{attrs | "data_type" => to_string(data_type)} end def normalize_data_type(attrs) when is_map(attrs) do case {Map.has_key?(attrs, :data_type), Map.get(attrs, :data_type)} do {true, data_type} when is_atom(data_type) -> %{attrs | data_type: to_string(data_type)} _ -> attrs end end def normalize_data_type(attrs), do: attrs @doc """ List of weather-related fields used for packet classification. ## Examples iex> Aprsme.EncodingUtils.weather_fields() |> Enum.member?(:temperature) true iex> :foo in Aprsme.EncodingUtils.weather_fields() false """ @spec weather_fields() :: [ :temperature | :humidity | :wind_speed | :wind_direction | :wind_gust | :pressure | :rain_1h | :rain_24h | :rain_since_midnight | :snow, ... ] def weather_fields do [ :temperature, :humidity, :wind_speed, :wind_direction, :wind_gust, :pressure, :rain_1h, :rain_24h, :rain_since_midnight, :snow ] end @doc """ Sanitizes all string fields in an APRS packet to ensure safe JSON encoding. ## Examples iex> result = Aprsme.EncodingUtils.sanitize_packet(%{"comment" => <<0, 65, 66, 67>>, "data_extended" => %{"comment" => <<0, 68, 69, 70>>}}) iex> result["comment"] == "ABC" true iex> result["data_extended"]["comment"] == "DEF" true """ @spec sanitize_packet(struct() | map()) :: struct() | map() def sanitize_packet(%Aprsme.Packet{} = packet) do sanitized_data = case packet.data do nil -> nil data when is_map(data) -> sanitize_packet_strings(data) other -> other end %{ packet | data: sanitized_data, data_extended: sanitize_data_extended(packet.data_extended) } end def sanitize_packet(packet) when is_map(packet) do packet |> sanitize_string_fields() |> sanitize_nested_map(:data, &sanitize_packet_strings/1) |> sanitize_nested_map(:data_extended, &sanitize_data_extended/1) end @string_fields [ :comment, :path, :raw_packet, :destination, :sender, :base_callsign, :ssid, :manufacturer, :equipment_type, :message_text, :addressee, :symbol_code, :symbol_table_id, :dao, :timestamp, :device_identifier ] defp sanitize_string_fields(packet) do Enum.reduce(@string_fields, packet, fn field, acc -> # sanitize_comment strips Mic-E telemetry; everything else uses the # generic sanitizer. sanitizer = field_sanitizer(field) update_existing_key(acc, field, sanitizer) end) end defp field_sanitizer(:comment), do: &sanitize_comment/1 defp field_sanitizer(_other), do: &sanitize_string/1 defp sanitize_nested_map(packet, key, sanitizer) do update_existing_key(packet, key, nil_aware(sanitizer)) end # Apply `sanitizer` to whichever key (atom or string) actually exists in the # map. If neither is present, return the map unchanged. defp update_existing_key(map, atom_key, sanitizer) do string_key = to_string(atom_key) case first_present_key(map, atom_key, string_key) do nil -> map key -> Map.update(map, key, nil, sanitizer) end end defp first_present_key(map, atom_key, string_key) do cond do Map.has_key?(map, atom_key) -> atom_key Map.has_key?(map, string_key) -> string_key true -> nil end end # Wraps a sanitizer so it passes `nil` values through unchanged. defp nil_aware(sanitizer) do fn nil -> nil value -> sanitizer.(value) end end @doc """ Sanitizes string fields in the data_extended structure. ## Examples iex> Aprsme.EncodingUtils.sanitize_data_extended(%{"comment" => <<0, 65, 66, 67>>}) %{"comment" => "ABC"} iex> Aprsme.EncodingUtils.sanitize_data_extended(nil) nil """ @spec sanitize_data_extended(nil | map() | MicE.t() | any()) :: nil | map() | MicE.t() | any() def sanitize_data_extended(nil), do: nil def sanitize_data_extended(%{comment: comment} = data_extended) when is_map(data_extended) do %{data_extended | comment: sanitize_string(comment)} end def sanitize_data_extended(%MicE{message: message} = mic_e) do %{mic_e | message: sanitize_string(message)} end def sanitize_data_extended(data_extended) when is_map(data_extended) and not is_struct(data_extended) do # Handle generic maps by sanitizing all string values Enum.reduce(data_extended, %{}, fn {key, value}, acc -> sanitized_value = sanitize_map_value(value) Map.put(acc, key, sanitized_value) end) end def sanitize_data_extended(data_extended), do: data_extended @spec sanitize_map_value(any()) :: any() defp sanitize_map_value(val) when is_binary(val), do: sanitize_string(val) defp sanitize_map_value(val), do: val @doc """ Converts a binary to a hex string representation for debugging. ## Examples iex> Aprsme.EncodingUtils.to_hex(<<72, 101, 108, 108, 111>>) "48656C6C6F" """ @spec to_hex(binary()) :: String.t() def to_hex(binary) when is_binary(binary) do Encoding.to_hex(binary) end @doc """ Returns information about a binary's encoding validity. ## Examples iex> Aprsme.EncodingUtils.encoding_info("Hello") %{valid_utf8: true, byte_count: 5, char_count: 5, invalid_at: nil} iex> Aprsme.EncodingUtils.encoding_info(<<72, 101, 211, 108, 111>>) %{valid_utf8: false, byte_count: 5, char_count: nil, invalid_at: 2} """ @spec encoding_info(binary()) :: %{ valid_utf8: boolean(), byte_count: non_neg_integer(), char_count: non_neg_integer() | nil, invalid_at: non_neg_integer() | nil } def encoding_info(binary) when is_binary(binary) do Encoding.encoding_info(binary) end end