defmodule Aprsme.Encoding do @moduledoc """ Encoding utilities for handling APRS packet data. Provides functions for sanitizing strings, converting encodings, and validating data. """ @doc """ Sanitizes a binary to ensure it can be safely JSON encoded. Handles latin1 conversion and removes control characters. """ @spec sanitize_string(binary()) :: binary() def sanitize_string(input) when is_binary(input) do input |> try_utf8_conversion() |> clean_control_characters() end @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 """ @spec to_float_safe(binary()) :: {:ok, float()} | nil def to_float_safe(value) when is_binary(value) do sanitized = value |> String.trim() # Reasonable max length for a number |> String.slice(0, 30) case Float.parse(sanitized) do {f, _} when f > -9.0e15 and f < 9.0e15 -> {:ok, f} _ -> nil end end @spec to_float_safe(any()) :: nil def to_float_safe(_), do: nil @doc """ Convert binary to hex string """ @spec to_hex(binary()) :: binary() def to_hex(input) when is_binary(input) do Base.encode16(input) end @spec to_hex(any()) :: binary() def to_hex(_), do: "" @doc """ Check if a value looks like it has weather data """ @spec has_weather_data(any(), any(), any(), any()) :: boolean() def has_weather_data(temperature, humidity, wind_speed, pressure) do not is_nil(temperature) or not is_nil(humidity) or not is_nil(wind_speed) or not is_nil(pressure) end @doc """ Get encoding information about a binary """ @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(input) when is_binary(input) do byte_count = byte_size(input) if String.valid?(input) do %{ valid_utf8: true, byte_count: byte_count, char_count: String.length(input), invalid_at: nil } else invalid_pos = find_invalid_byte_position(input) %{ valid_utf8: false, byte_count: byte_count, char_count: nil, invalid_at: invalid_pos } end end @spec encoding_info(any()) :: %{ valid_utf8: boolean(), byte_count: non_neg_integer(), char_count: nil, invalid_at: nil } def encoding_info(_) do %{ valid_utf8: false, byte_count: 0, char_count: nil, invalid_at: nil } end # Private functions @spec try_utf8_conversion(binary()) :: binary() defp try_utf8_conversion(input) do if String.valid?(input) do input else # Try latin1 to UTF-8 conversion latin1_to_utf8(input) end end @spec latin1_to_utf8(binary()) :: binary() defp latin1_to_utf8(input) do input |> :binary.bin_to_list() |> Enum.map(&latin1_char_to_utf8/1) |> IO.iodata_to_binary() rescue _ -> "" end @spec latin1_char_to_utf8(0..255) :: binary() defp latin1_char_to_utf8(byte) when byte <= 127 do <> end defp latin1_char_to_utf8(byte) when byte >= 128 and byte <= 159 do # C1 control range (128-159) has no useful Latin1 characters. # Replace with Unicode replacement character instead of converting # to C1 control codepoints that would just get stripped later. <<0xEF, 0xBF, 0xBD>> end defp latin1_char_to_utf8(byte) do # For Latin1, values 160-255 map to Unicode U+00A0 to U+00FF # In UTF-8, these become 2-byte sequences: 110xxxxx 10xxxxxx <<0xC0 + div(byte, 64), 0x80 + rem(byte, 64)>> end @spec clean_control_characters(binary()) :: binary() defp clean_control_characters(s) do if all_bytes_clean?(s) do String.trim(s) else s |> String.graphemes() |> Enum.filter(&valid_grapheme?/1) |> Enum.join() |> String.trim() end end # Fast-path check: returns true when every byte is safe ASCII # (printable chars 32-126, plus tab/newline/CR). Any byte >= 128 # or any control character triggers the slow grapheme-based path. @spec all_bytes_clean?(binary()) :: boolean() defp all_bytes_clean?(<<>>), do: true defp all_bytes_clean?(<<9, rest::binary>>), do: all_bytes_clean?(rest) defp all_bytes_clean?(<<10, rest::binary>>), do: all_bytes_clean?(rest) defp all_bytes_clean?(<<13, rest::binary>>), do: all_bytes_clean?(rest) defp all_bytes_clean?(<>) when byte >= 32 and byte <= 126, do: all_bytes_clean?(rest) defp all_bytes_clean?(_), do: false @spec valid_grapheme?(String.grapheme()) :: boolean() defp valid_grapheme?(grapheme) do case String.to_charlist(grapheme) do [cp] -> valid_codepoint?(cp) _ -> true end end # Allow tab (0x09), newline (0x0A), carriage return (0x0D) # Remove other control characters defp valid_codepoint?(9), do: true defp valid_codepoint?(10), do: true defp valid_codepoint?(13), do: true defp valid_codepoint?(127), do: false defp valid_codepoint?(c) when c >= 0 and c <= 31, do: false defp valid_codepoint?(c) when c >= 128 and c <= 159, do: false defp valid_codepoint?(_), do: true @spec find_invalid_byte_position(binary()) :: non_neg_integer() | nil defp find_invalid_byte_position(input) do input |> :binary.bin_to_list() |> Enum.with_index() |> Enum.find_value(fn {byte, index} -> if byte > 127 and not valid_utf8_continuation?(input, index) do index end end) end @spec valid_utf8_continuation?(binary(), non_neg_integer()) :: boolean() defp valid_utf8_continuation?(binary, pos) do <<_::binary-size(pos), _char::utf8, _::binary>> = binary true rescue _ -> false end end