defmodule DaProductApp.Switch.MessageFraming do @moduledoc """ Message framing utilities for upstream network communication. Handles different message framing formats required by various upstream processors: - Length prefix (BCD, binary, ASCII) - STX/ETX delimited messages - No framing (raw messages) - Custom framing patterns ## Configuration Format: ```elixir message_framing: %{ type: :length_prefix, encoding: :bcd, # :bcd, :binary, :ascii length_bytes: 2, # Number of bytes for length include_header: false # Whether length includes the length prefix itself } ``` ## Example Usage: ```elixir # Frame message with 2-byte BCD length prefix framing_config = %{type: :length_prefix, encoding: :bcd, length_bytes: 2} {:ok, framed_bytes} = MessageFraming.frame_message(message_bytes, framing_config) # Unframe response {:ok, {response_bytes, remaining}} = MessageFraming.unframe_message(framed_bytes, framing_config) ``` """ require Logger import Bitwise @type framing_config :: %{ type: :length_prefix | :stx_etx | :none | :custom, encoding: :bcd | :binary | :ascii, length_bytes: pos_integer(), include_header: boolean(), stx: binary(), etx: binary(), custom_framer: {module(), atom()}, custom_unframer: {module(), atom()} } @type frame_result :: {:ok, binary()} | {:error, term()} @type unframe_result :: {:ok, {binary(), binary()}} | {:error, term()} @doc """ Applies message framing to outbound message bytes. ## Parameters - `message_bytes`: The raw message to frame - `framing_config`: Configuration map specifying framing type and options ## Returns - `{:ok, framed_bytes}`: Successfully framed message - `{:error, reason}`: Framing error """ @spec frame_message(binary(), framing_config()) :: frame_result() def frame_message(message_bytes, framing_config) when is_binary(message_bytes) do Logger.debug("Framing message: #{byte_size(message_bytes)} bytes with config: #{inspect(framing_config)}") try do case Map.get(framing_config, :type, :none) do :length_prefix -> frame_with_length_prefix(message_bytes, framing_config) :stx_etx -> frame_with_stx_etx(message_bytes, framing_config) :none -> {:ok, message_bytes} :custom -> frame_with_custom(message_bytes, framing_config) unknown_type -> {:error, {:unsupported_framing_type, unknown_type}} end rescue exception -> Logger.error("Message framing failed: #{inspect(exception)}") {:error, {:framing_exception, exception}} end end @doc """ Removes message framing from inbound response bytes. ## Parameters - `framed_bytes`: The framed response bytes - `framing_config`: Configuration map specifying framing type and options ## Returns - `{:ok, {message_bytes, remaining_bytes}}`: Successfully unframed message and any remaining data - `{:error, reason}`: Unframing error """ @spec unframe_message(binary(), framing_config()) :: unframe_result() def unframe_message(framed_bytes, framing_config) when is_binary(framed_bytes) do Logger.debug("Unframing response: #{byte_size(framed_bytes)} bytes with config: #{inspect(framing_config)}") try do case Map.get(framing_config, :type, :none) do :length_prefix -> unframe_with_length_prefix(framed_bytes, framing_config) :stx_etx -> unframe_with_stx_etx(framed_bytes, framing_config) :none -> {:ok, {framed_bytes, <<>>}} :custom -> unframe_with_custom(framed_bytes, framing_config) unknown_type -> {:error, {:unsupported_framing_type, unknown_type}} end rescue exception -> Logger.error("Message unframing failed: #{inspect(exception)}") {:error, {:unframing_exception, exception}} end end @doc """ Validates message framing configuration. ## Returns - `:ok`: Configuration is valid - `{:error, reason}`: Configuration error """ @spec validate_framing_config(framing_config()) :: :ok | {:error, term()} def validate_framing_config(framing_config) when is_map(framing_config) do with :ok <- validate_framing_type(Map.get(framing_config, :type)), :ok <- validate_encoding_config(framing_config), :ok <- validate_length_config(framing_config), :ok <- validate_custom_config(framing_config) do :ok else {:error, reason} -> {:error, reason} end end def validate_framing_config(_), do: {:error, :invalid_config_format} # Private Functions - Length Prefix Framing defp frame_with_length_prefix(message_bytes, config) do encoding = Map.get(config, :encoding, :binary) length_bytes = Map.get(config, :length_bytes, 2) include_header = Map.get(config, :include_header, false) message_length = byte_size(message_bytes) total_length = if include_header, do: message_length + length_bytes, else: message_length Logger.debug("Encoding length #{total_length} as #{encoding} in #{length_bytes} bytes") case encode_length(total_length, encoding, length_bytes) do {:ok, length_prefix} -> framed_message = length_prefix <> message_bytes Logger.debug("Framed message: #{byte_size(framed_message)} bytes, hex: #{Base.encode16(framed_message, case: :upper)}") {:ok, framed_message} {:error, reason} -> {:error, reason} end end defp unframe_with_length_prefix(framed_bytes, config) do encoding = Map.get(config, :encoding, :binary) length_bytes = Map.get(config, :length_bytes, 2) include_header = Map.get(config, :include_header, false) if byte_size(framed_bytes) >= length_bytes do length_prefix = binary_part(framed_bytes, 0, length_bytes) case decode_length(length_prefix, encoding) do {:ok, decoded_length} -> message_length = if include_header, do: decoded_length - length_bytes, else: decoded_length if byte_size(framed_bytes) >= length_bytes + message_length do message_bytes = binary_part(framed_bytes, length_bytes, message_length) remaining_bytes = binary_part(framed_bytes, length_bytes + message_length, byte_size(framed_bytes) - length_bytes - message_length) Logger.debug("Unframed message: #{byte_size(message_bytes)} bytes") {:ok, {message_bytes, remaining_bytes}} else {:error, :insufficient_data_for_message} end {:error, reason} -> {:error, reason} end else {:error, :insufficient_data_for_length} end end # Private Functions - STX/ETX Framing defp frame_with_stx_etx(message_bytes, config) do stx = Map.get(config, :stx, <<0x02>>) # Default STX etx = Map.get(config, :etx, <<0x03>>) # Default ETX framed_message = stx <> message_bytes <> etx Logger.debug("STX/ETX framed message: #{byte_size(framed_message)} bytes") {:ok, framed_message} end defp unframe_with_stx_etx(framed_bytes, config) do stx = Map.get(config, :stx, <<0x02>>) etx = Map.get(config, :etx, <<0x03>>) case :binary.match(framed_bytes, stx) do {start_pos, _} -> remaining = binary_part(framed_bytes, start_pos + byte_size(stx), byte_size(framed_bytes) - start_pos - byte_size(stx)) case :binary.match(remaining, etx) do {end_pos, _} -> message_bytes = binary_part(remaining, 0, end_pos) leftover = binary_part(remaining, end_pos + byte_size(etx), byte_size(remaining) - end_pos - byte_size(etx)) Logger.debug("STX/ETX unframed message: #{byte_size(message_bytes)} bytes") {:ok, {message_bytes, leftover}} :nomatch -> {:error, :etx_not_found} end :nomatch -> {:error, :stx_not_found} end end # Private Functions - Custom Framing defp frame_with_custom(message_bytes, config) do case Map.get(config, :custom_framer) do {module, function} when is_atom(module) and is_atom(function) -> apply(module, function, [message_bytes, config]) _ -> {:error, :invalid_custom_framer} end end defp unframe_with_custom(framed_bytes, config) do case Map.get(config, :custom_unframer) do {module, function} when is_atom(module) and is_atom(function) -> apply(module, function, [framed_bytes, config]) _ -> {:error, :invalid_custom_unframer} end end # Private Functions - Length Encoding/Decoding defp encode_length(length, :bcd, num_bytes) when length >= 0 do # Convert length to BCD representation # Example: length 224 -> "224" -> [0x02, 0x24] (2 bytes BCD) length_str = Integer.to_string(length) max_digits = num_bytes * 2 if String.length(length_str) <= max_digits do # Pad with leading zeros if needed padded_str = String.pad_leading(length_str, max_digits, "0") try do bcd_bytes = padded_str |> String.to_charlist() |> Enum.chunk_every(2) |> Enum.map(fn [hi, lo] -> ((hi - ?0) <<< 4) ||| (lo - ?0) end) |> :binary.list_to_bin() Logger.debug("BCD encoded length #{length} -> #{Base.encode16(bcd_bytes, case: :upper)}") {:ok, bcd_bytes} rescue _ -> {:error, :bcd_encoding_failed} end else {:error, {:length_too_large_for_bcd, length, max_digits}} end end defp encode_length(length, :binary, num_bytes) when length >= 0 do # Convert length to binary big-endian representation max_value = trunc(:math.pow(256, num_bytes)) - 1 if length <= max_value do binary_bytes = <> Logger.debug("Binary encoded length #{length} -> #{Base.encode16(binary_bytes, case: :upper)}") {:ok, binary_bytes} else {:error, {:length_too_large_for_binary, length, max_value}} end end defp encode_length(length, :ascii, num_bytes) when length >= 0 do # Convert length to ASCII representation length_str = Integer.to_string(length) if String.length(length_str) <= num_bytes do padded_str = String.pad_leading(length_str, num_bytes, "0") Logger.debug("ASCII encoded length #{length} -> #{padded_str}") {:ok, padded_str} else {:error, {:length_too_large_for_ascii, length, num_bytes}} end end defp encode_length(length, :hex, num_bytes) when length >= 0 do # Convert length to hexadecimal binary representation # Example: length 224 -> 0x00E0 -> <<0x00, 0xE0>> for 2 bytes max_value = trunc(:math.pow(256, num_bytes)) - 1 if length <= max_value do # Convert to binary, same as binary encoding but documented as hex for clarity hex_bytes = <> Logger.debug("Hex encoded length #{length} -> #{Base.encode16(hex_bytes, case: :upper)} (binary: #{inspect(hex_bytes)})") {:ok, hex_bytes} else {:error, {:length_too_large_for_hex, length, max_value}} end end defp encode_length(length, encoding, num_bytes) do {:error, {:invalid_encoding_params, length, encoding, num_bytes}} end defp decode_length(length_bytes, :bcd) do try do decoded_length = length_bytes |> :binary.bin_to_list() |> Enum.map(fn byte -> hi = (byte >>> 4) &&& 0x0F lo = byte &&& 0x0F [Integer.to_string(hi), Integer.to_string(lo)] end) |> List.flatten() |> Enum.join() |> String.to_integer() Logger.debug("BCD decoded length: #{Base.encode16(length_bytes, case: :upper)} -> #{decoded_length}") {:ok, decoded_length} rescue _ -> {:error, :bcd_decoding_failed} end end defp decode_length(length_bytes, :binary) do try do num_bytes = byte_size(length_bytes) <> = length_bytes Logger.debug("Binary decoded length: #{Base.encode16(length_bytes, case: :upper)} -> #{decoded_length}") {:ok, decoded_length} rescue _ -> {:error, :binary_decoding_failed} end end defp decode_length(length_bytes, :ascii) do try do decoded_length = length_bytes |> String.to_integer() Logger.debug("ASCII decoded length: #{length_bytes} -> #{decoded_length}") {:ok, decoded_length} rescue _ -> {:error, :ascii_decoding_failed} end end defp decode_length(length_bytes, :hex) do try do # Hex encoding produces binary bytes, same as binary decoding num_bytes = byte_size(length_bytes) <> = length_bytes Logger.debug("Hex decoded length: #{Base.encode16(length_bytes, case: :upper)} -> #{decoded_length}") {:ok, decoded_length} rescue _ -> {:error, :hex_decoding_failed} end end # Private Functions - Configuration Validation defp validate_framing_type(type) when type in [:length_prefix, :stx_etx, :none, :custom], do: :ok defp validate_framing_type(nil), do: :ok # Default to :none defp validate_framing_type(type), do: {:error, {:invalid_framing_type, type}} defp validate_encoding_config(%{type: :length_prefix, encoding: encoding}) when encoding in [:bcd, :binary, :ascii, :hex], do: :ok defp validate_encoding_config(%{type: :length_prefix}), do: {:error, :missing_encoding} defp validate_encoding_config(_), do: :ok defp validate_length_config(%{type: :length_prefix, length_bytes: bytes}) when is_integer(bytes) and bytes > 0 and bytes <= 8, do: :ok defp validate_length_config(%{type: :length_prefix}), do: {:error, :missing_length_bytes} defp validate_length_config(_), do: :ok defp validate_custom_config(%{type: :custom, custom_framer: {mod, fun}, custom_unframer: {mod2, fun2}}) when is_atom(mod) and is_atom(fun) and is_atom(mod2) and is_atom(fun2), do: :ok defp validate_custom_config(%{type: :custom}), do: {:error, :missing_custom_functions} defp validate_custom_config(_), do: :ok end