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 """ 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) do # For Latin1, values 128-255 map to Unicode U+0080 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 s |> String.graphemes() |> Enum.filter(&valid_grapheme?/1) |> Enum.join() |> String.trim() end @spec valid_grapheme?(String.grapheme()) :: boolean() defp valid_grapheme?(grapheme) do case String.to_charlist(grapheme) do [cp] -> # Allow tab (0x09), newline (0x0A), carriage return (0x0D) # Remove other control characters case cp do 9 -> true 10 -> true 13 -> true c when c >= 0 and c <= 31 -> false 127 -> false c when c >= 128 and c <= 159 -> false _ -> true end _ -> # Multi-codepoint grapheme, keep it true end end @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