lib/wiegand.ex

defmodule Wiegand do
  @moduledoc """
  Encodes and decodes various formats of Wiegand card data.

  Most formats contain a facility code (though this is sometimes omitted) and a
  card/cardholder ID. Many formats provide provide error checking via parity bits.
  Even parity is calculated over the n-most-significant bits, and odd parity is
  calculated over the n-least-significant bits.

  ### Assumptions

  This package does not currently support every type of Wiegand card format.
  For simplicity's sake, it currently makes the following assumptions:

  * The even parity bit, if present, is the most-significant bit
  * The facility code, if present, immediately follows the even parity bit
  * The card/cardholder ID immediately follows the facility code
  * The odd parity bit, if present, is the least-significant bit
  * Parity checking is the only type of error checking supported
  """

  alias Wiegand.{CardData, CardFormat}
  require Integer

  @type decode_error :: :bit_count | :even_parity_mismatch | :odd_parity_mismatch

  @typep bit :: <<_::1>>
  @typep bitlist :: [0 | 1]

  @doc """
  Encodes the card data according to the given format and returns the encoded value
  as an integer.
  """
  @spec encode(CardFormat.t(), CardData.t()) :: integer()
  def encode(%CardFormat{} = format, %CardData{card_id: card_id, facility_code: facility_code}) do
    fc_data_bits = int_to_bitlist(facility_code, format.fc_len)
    card_id_data_bits = int_to_bitlist(card_id, format.card_id_len)

    card_data = fc_data_bits ++ card_id_data_bits

    [
      format |> even_parity_bit(card_data) |> List.wrap(),
      card_data,
      format |> odd_parity_bit(card_data) |> List.wrap()
    ]
    |> Enum.concat()
    |> Integer.undigits(2)
  end

  @spec decode(CardFormat.t(), bitstring() | integer()) ::
          {:ok, CardData.t()} | {:error, decode_error()}
  def decode(%CardFormat{} = format, int) when is_integer(int) do
    size = CardFormat.total_bits(format)
    bits = <<int::size(size)>>
    decode(format, bits)
  end

  def decode(
        %CardFormat{
          even_parity_bits: even_parity_bits,
          odd_parity_bits: odd_parity_bits,
          fc_len: fc_len,
          card_id_len: card_id_len
        } = format,
        bits
      ) do
    even_parity_width = min(even_parity_bits, 1)
    odd_parity_width = min(even_parity_bits, 1)
    card_data_width = fc_len + card_id_len

    if even_parity_width + odd_parity_width + card_data_width != bit_size(bits) do
      {:error, :bit_count}
    else
      <<even_parity_value::size(even_parity_width), card_data::bits-size(card_data_width),
        odd_parity_value::size(odd_parity_width)>> = bits

      <<facility_code::size(fc_len), card_id::size(card_id_len)>> = card_data

      data_bits = for <<bit::1 <- card_data>>, into: [], do: bit

      cond do
        even_parity_bits > 0 and even_parity_bit(format, data_bits) != even_parity_value ->
          {:error, :even_parity_mismatch}

        odd_parity_bits > 0 and odd_parity_bit(format, data_bits) != odd_parity_value ->
          {:error, :odd_parity_mismatch}

        true ->
          {:ok,
           %CardData{
             card_id: card_id,
             facility_code: if(fc_len == 0, do: nil, else: facility_code)
           }}
      end
    end
  end

  @spec zeropad(bitlist(), non_neg_integer()) :: bitlist()
  defp zeropad(_list, 0), do: []
  defp zeropad(list, len) when length(list) < len, do: [<<0::1>>] ++ list
  defp zeropad(list, _len), do: list

  @spec explode_bits(bitstring()) :: bitlist()
  defp explode_bits(integer), do: for(<<bit::1 <- integer>>, into: [], do: bit)

  @spec int_to_bitlist(non_neg_integer(), non_neg_integer()) :: bitlist()
  defp int_to_bitlist(nil, bit_count), do: int_to_bitlist(0, bit_count)

  defp int_to_bitlist(integer, bit_count) do
    <<integer::size(bit_count)>>
    |> explode_bits()
    |> zeropad(bit_count)
  end

  @spec even_parity_bit(CardFormat.t(), bitlist()) :: 0..1 | nil
  defp even_parity_bit(%CardFormat{even_parity_bits: 0}, _data_bits), do: nil

  defp even_parity_bit(%CardFormat{even_parity_bits: even_parity_bit_count}, data_bits) do
    data_bits
    |> Enum.take(even_parity_bit_count - 1)
    |> parity_bit(&Integer.is_even/1)
  end

  @spec odd_parity_bit(CardFormat.t(), bitlist()) :: 0..1 | nil
  defp odd_parity_bit(%CardFormat{odd_parity_bits: 0}, _data_bits), do: nil

  defp odd_parity_bit(%CardFormat{odd_parity_bits: odd_parity_bit_count}, data_bits) do
    data_bits
    |> Enum.take(-(odd_parity_bit_count - 1))
    |> parity_bit(&Integer.is_odd/1)
  end

  @spec parity_bit([bit()], (non_neg_integer() -> boolean())) :: 0..1
  defp parity_bit(bitlist, comparator) do
    bitlist
    |> Enum.sum()
    |> comparator.()
    |> case do
      true -> 0
      false -> 1
    end
  end
end