lib/grizzly/zipgateway/log_monitor.ex

defmodule Grizzly.ZIPGateway.LogMonitor do
  @moduledoc """
  Monitors the Z/IP Gateway logs to extract and process certain data.
  """

  use GenServer

  require Logger

  @type network_key_type ::
          :s0
          | :s2_unauthenticated
          | :s2_authenticated
          | :s2_access_control
          | :s2_authenticated_long_range
          | :s2_access_control_long_range

  @doc """
  Returns the network home id as extracted from the Z/IP Gateway logs.
  """
  @spec home_id(GenServer.name()) :: binary() | nil
  def home_id(name \\ __MODULE__) do
    GenServer.call(name, :home_id)
  end

  @doc """
  Returns a keyword list of the network keys extracted from the Z/IP Gateway logs.
  """
  @spec network_keys(GenServer.name()) :: [{network_key_type(), binary()}]
  def network_keys(name \\ __MODULE__) do
    GenServer.call(name, :network_keys)
  end

  @doc """
  Returns a string containing a filename and network keys formatted for use with the
  Zniffer application (available through Simplicity Studio on Windows).
  """
  @spec zniffer_network_keys(GenServer.name()) :: binary() | nil
  def zniffer_network_keys(name \\ __MODULE__) do
    home_id = home_id(name)
    network_keys = network_keys(name)

    cond do
      is_nil(home_id) ->
        nil

      length(network_keys) != 6 ->
        nil

      true ->
        """
        #{home_id}.txt:

        9F;#{network_keys[:s2_access_control]};1
        9F;#{network_keys[:s2_access_control_long_range]};1
        9F;#{network_keys[:s2_authenticated]};1
        9F;#{network_keys[:s2_authenticated_long_range]};1
        9F;#{network_keys[:s2_unauthenticated]};1
        98;#{network_keys[:s0]};1
        """
    end
  end

  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts \\ []) do
    name = Keyword.get(opts, :name, __MODULE__)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  @impl GenServer
  def init(_opts) do
    {:ok, %{home_id: nil, network_keys: [], next_key_type: nil}}
  end

  @impl GenServer
  def handle_call(:home_id, _from, %{home_id: home_id} = state) do
    {:reply, home_id, state}
  end

  def handle_call(:network_keys, _from, %{network_keys: network_keys} = state) do
    {:reply, network_keys, state}
  end

  @impl GenServer
  # We have the next key type in state, so extract the network key and save it
  def handle_info({:message, network_key}, %{next_key_type: key_type} = state)
      when not is_nil(key_type) do
    network_key = network_key |> String.trim() |> String.upcase()

    if Regex.match?(~r/^[0-9A-F]{32}$/, network_key) do
      Logger.debug("[Grizzly] Extracted network key type=#{key_type} key=#{network_key}")
      updated_network_keys = Keyword.put(state.network_keys, key_type, network_key)
      {:noreply, %{state | network_keys: updated_network_keys, next_key_type: nil}}
    else
      Logger.error(
        "[Grizzly] #{inspect(key_type)} network key appears to be invalid: #{network_key}"
      )

      {:noreply, state}
    end
  end

  # The actual network key is printed on the next line, so save where we're at in state
  # for when we receive the next message.
  def handle_info({:message, "Key class 0x" <> _ = message}, state) do
    next_key_type =
      case Regex.named_captures(~r/Key class 0x\d+: (?<key_class>.+$)/, message) do
        %{"key_class" => "KEY_CLASS_S0"} ->
          :s0

        %{"key_class" => "KEY_CLASS_S2_UNAUTHENTICATED"} ->
          :s2_unauthenticated

        %{"key_class" => "KEY_CLASS_S2_AUTHENTICATED"} ->
          :s2_authenticated

        %{"key_class" => "KEY_CLASS_S2_ACCESS"} ->
          :s2_access_control

        %{"key_class" => "KEY_CLASS_S2_AUTHENTICATED_LR"} ->
          :s2_authenticated_long_range

        %{"key_class" => "KEY_CLASS_S2_ACCESS_LR"} ->
          :s2_access_control_long_range

        %{"key_class" => key_class} ->
          Logger.error("[Grizzly] Unknown network key class: #{key_class}")

          nil

        nil ->
          Logger.error(
            "[Grizzly] Unexpected Z/IP Gateway message while extracting network keys: #{message}"
          )

          nil
      end

    {:noreply, %{state | next_key_type: next_key_type}}
  end

  # Extract the Home ID from the ZIP_Router_Reset message
  def handle_info({:message, "ZIP_Router_Reset:" <> message}, state) do
    home_id =
      case Regex.named_captures(~r/Home ID = 0x(?<home_id>[0-9a-fA-F]{8})/, message) do
        %{"home_id" => home_id} ->
          Logger.debug("[Grizzly] Extracted Home ID from ZIP_Router_Reset message: #{home_id}")
          String.upcase(home_id)

        nil ->
          Logger.error(
            "[Grizzly] Error extracting Home ID from ZIP_Router_Reset message: #{message}"
          )

          nil
      end

    {:noreply, %{state | home_id: home_id}}
  end

  def handle_info({:message, _}, state) do
    {:noreply, state}
  end
end