defmodule Grizzly.UnsolicitedServer.ResponseHandler do @moduledoc false # module helper for handling various different responses from the Z-Wave PAN # network alias Grizzly.Associations alias Grizzly.VersionReports alias Grizzly.ZWave alias Grizzly.ZWave.Command alias Grizzly.ZWave.Commands alias Grizzly.ZWave.Commands.ZIPPacket require Logger @type opt() :: {:association_server, GenServer.name()} @type action() :: {:notify, Command.t()} | {:send, Command.t()} | {:forward_to_controller, Command.t()} defguardp is_supervision_status_action(a) when is_tuple(a) and elem(a, 0) == :supervision_status @doc """ When a transport receives a response from the Z-Wave network handle it and send any other commands back over the Z-Wave PAN if needed """ @spec handle_response(Grizzly.node_id(), Command.t(), [opt()]) :: [action()] | {:error, reason :: any()} def handle_response(node_id, command, opts \\ []) do cond do command.name == :keep_alive -> handle_keep_alive(command) # Everything else should be Z/IP encapsulated command.name != :zip_packet -> Logger.warning( "[Grizzly] Unsolicited server expected a Z/IP Packet, got: #{inspect(command)}" ) [] # Nothing to do for ACK responses ZIPPacket.ack_response?(command) -> [] # Warn about nack responses ZIPPacket.nack_response?(command) -> Logger.warning( "[Grizzly] Unsolicited server received a nack response: #{inspect(command)}" ) [] # There's not much we can do about the nack+waiting condition when replying # from the unsolicited server. ZIPPacket.nack_waiting?(command) -> [] # Pretty much anything else should be a Z/IP Packet carrying a command. true -> with internal_cmd when not is_nil(internal_cmd) <- Command.param!(command, :command), actions when is_list(actions) <- handle_command(node_id, internal_cmd, opts) do actions = Enum.reject(actions, &is_supervision_status_action/1) [:ack | actions] else {:error, _any} = error -> error nil -> Logger.warning( "[Grizzly] Unsolicited server received an unexpectedly empty Z/IP Packet: #{inspect(command)}" ) [] end end end defp handle_keep_alive(cmd) do case Command.param!(cmd, :ack_flag) do :ack_request -> {:ok, ack_response} = Commands.create(:keep_alive, ack_flag: :ack_response) [{:send_raw, ack_response}] _ -> [] end end defp handle_command(node_id, %Command{name: :supervision_get} = command, opts) do encapsulated_command = Command.param!(command, :encapsulated_command) case ZWave.from_binary(encapsulated_command) do {:ok, report} -> # We need to process the internal command and get the actions that need # to be preformed. The supervision report must come last in the chain of # actions. See SDS13783 section 3.7.2.2 for more information. actions = handle_command(node_id, report, opts) {_, supervision_status} = Enum.find(actions, {:supervision_status, :success}, &is_supervision_status_action/1) actions = Enum.reject(actions, &is_supervision_status_action/1) {:ok, supervision_report} = make_supervision_report(command, supervision_status) actions ++ [{:send, supervision_report}] {:error, reason} -> Logger.warning( "Failed to parse: #{inspect(encapsulated_command)} notification for reason: #{inspect(reason)}" ) {:ok, supervision_report} = make_supervision_report(command, :no_support) [{:send, supervision_report}] end end defp handle_command(_node_id, %Command{name: :basic_set}, _opts) do [{:supervision_status, :no_support}] end # When an inclusion controller adds a node, we'll receive network management # commands via the unsolicited server. defp handle_command(node_id, %Command{name: inclusion_cmd} = cmd, _opts) when inclusion_cmd in [ :node_add_status, :node_add_keys_report, :node_add_dsk_report, :extended_node_add_status, :node_remove_status ] do Logger.debug( "[UnsolicitedServer] Received unsolicited inclusion command: #{inspect(cmd, pretty: true)}" ) Grizzly.Inclusions.continue_inclusion(node_id, cmd) # do nothing. the configured inclusion handler will take care of it. [] end defp handle_command(_node_id, %Command{name: :smart_start_join_started} = cmd, _opts) do [{:notify, cmd}] end defp handle_command(_node_id, %Command{name: :association_specific_group_get}, _) do case Commands.create(:association_specific_group_report, group: 0) do {:ok, command} -> [{:send, command}] end end defp handle_command(_node_id, %Command{name: :association_get}, opts) do # According the the Z-Wave specification if a get request contains an # unsupported grouping identifier then we should report back the grouping # information for group number 1. Since right now we only support that # group we don't need to check because we will always just send back the # grouping information for group id 1. associations_server = Keyword.get(opts, :associations_server, Associations) {:ok, respond_with_command} = case Associations.get(associations_server, 1) do nil -> Commands.create(:association_report, grouping_identifier: 1, max_nodes_supported: 1, nodes: [] ) association -> Commands.create(:association_report, grouping_identifier: association.grouping_id, max_nodes_supported: 1, nodes: association.node_ids ) end [{:send, respond_with_command}] end defp handle_command(_node_id, %Command{name: :association_set} = command, opts) do associations_server = Keyword.get(opts, :associations_server, Associations) grouping_identifier = Command.param!(command, :grouping_identifier) # According the Z-Wave specification we should just ignore grouping ids that # we don't support. As of right now we are only supporting grouping id 1, so # if the command contains something other than one we will just move along. if grouping_identifier == 1 do nodes = Command.param!(command, :nodes) case Associations.save(associations_server, grouping_identifier, nodes) do :ok -> [supervision_status: :success] _ -> [supervision_status: :fail] end else [supervision_status: :fail] end end defp handle_command(_node_id, %Command{name: :association_groupings_get}, _opts) do case Commands.create(:association_groupings_report, supported_groupings: 1) do {:ok, command} -> [{:send, command}] end end defp handle_command(_node_id, %Command{name: :association_remove} = command, opts) do associations_server = Keyword.get(opts, :associations_server, Associations) grouping_id = Command.param!(command, :grouping_identifier) nodes = Command.param!(command, :nodes) # This case matching is based off a table from the Z-Wave specification # Right now we only have one association grouping, so the first and third # match are the same right now. I want to keep the matching explicit right # now as I hope it will enable quicker understand to any future work that # will be as it maps nicely to the documentation found in the Z-Wave # specification. case {grouping_id, nodes} do {1, []} -> :ok = Associations.delete_all_nodes_from_grouping(associations_server, grouping_id) [] {1, nodes} -> case Associations.delete_nodes_from_grouping(associations_server, grouping_id, nodes) do :ok -> [] error -> error end {0, []} -> :ok = Associations.delete_all(associations_server) [] {0, nodes} -> :ok = Associations.delete_nodes_from_all_groupings(associations_server, nodes) [] _ -> [] end end defp handle_command(_node_id, %Command{name: :association_group_name_get}, _opts) do # Always just return the lifeline group (group_id == 1) as of right now # because that is all that Grizzly supports right now. case Commands.create(:association_group_name_report, group_id: 1, name: "Lifeline") do {:ok, command} -> [{:send, command}] end end defp handle_command(_node_id, %Command{name: :association_group_info_get} = command, _opts) do {:ok, report} = Commands.create(:association_group_info_report, dynamic: false, groups_info: [[group_id: 1, profile: :general_lifeline]], list_mode: Command.param(command, :all, false) ) [{:send, report}] end defp handle_command(_node_id, %Command{name: :association_group_command_list_get}, _opts) do {:ok, report} = Commands.create(:association_group_command_list_report, group_id: 0x01, commands: [:device_reset_locally_notification] ) [{:send, report}] end defp handle_command(_node_id, %Command{name: :multi_channel_association_groupings_get}, _opts) do {:ok, report} = Commands.create(:multi_channel_association_groupings_report, supported_groupings: 1) [{:send, report}] end defp handle_command(_node_id, %Command{name: :multi_channel_association_get}, opts) do # According the the Z-Wave specification if a get request contains an # unsupported grouping identifier then we should report back the grouping # information for group number 1. Since right now we only support that # group we don't need to check because we will always just send back the # grouping information for group id 1. associations_server = Keyword.get(opts, :associations_server, Associations) {:ok, respond_with_command} = case Associations.get(associations_server, 1) do nil -> Commands.create(:multi_channel_association_report, grouping_identifier: 1, max_nodes_supported: 1, nodes: [], reports_to_follow: 0, node_endpoints: [] ) association -> {endpoints, nodes} = Enum.split_with(association.node_ids, fn v -> is_tuple(v) end) Commands.create(:multi_channel_association_report, grouping_identifier: association.grouping_id, max_nodes_supported: 1, nodes: nodes, reports_to_follow: 0, node_endpoints: Enum.map(endpoints, fn {node, endpoint} -> [node: node, endpoint: endpoint, bit_address: 0] end) ) end [{:send, respond_with_command}] end defp handle_command(_node_id, %Command{name: :multi_channel_association_set} = command, opts) do associations_server = Keyword.get(opts, :associations_server, Associations) grouping_identifier = Command.param!(command, :grouping_identifier) # According the Z-Wave specification we should just ignore grouping ids that # we don't support. As of right now we are only supporting grouping id 1, so # if the command contains something other than one we will just move along. if grouping_identifier == 1 do nodes = Command.param!(command, :nodes) node_endpoints = Command.param!(command, :node_endpoints) node_endpoints = Enum.map(node_endpoints, fn ep -> {ep[:node], ep[:endpoint]} end) case Associations.save(associations_server, grouping_identifier, node_endpoints ++ nodes) do :ok -> [supervision_status: :success] _ -> [supervision_status: :fail] end else [supervision_status: :fail] end end defp handle_command(_node_id, %Command{name: :multi_channel_association_remove} = command, opts) do associations_server = Keyword.get(opts, :associations_server, Associations) grouping_id = Command.param!(command, :grouping_identifier) nodes = Command.param!(command, :nodes) node_endpoints = Command.param!(command, :node_endpoints) node_endpoints = Enum.map(node_endpoints, fn ep -> {ep[:node], ep[:endpoint]} end) # This case matching is based off a table from the Z-Wave specification # Right now we only have one association grouping, so the first and third # match are the same right now. I want to keep the matching explicit right # now as I hope it will enable quicker understand to any future work that # will be as it maps nicely to the documentation found in the Z-Wave # specification. case {grouping_id, nodes} do {1, []} -> :ok = Associations.delete_all_nodes_from_grouping(associations_server, grouping_id) [] {1, nodes} -> case Associations.delete_nodes_from_grouping( associations_server, grouping_id, node_endpoints ++ nodes ) do :ok -> [] error -> error end {0, []} -> :ok = Associations.delete_all(associations_server) [] {0, nodes} -> :ok = Associations.delete_nodes_from_all_groupings( associations_server, node_endpoints ++ nodes ) [] _ -> [] end end defp handle_command(node_id, %Command{name: :multi_command_encapsulated} = command, opts) do commands = Command.param!(command, :commands) extra_commands = [ :supervision_get, :association_group_command_list_get, :association_group_name_get, :association_group_info_get, :association_get, :association_set, :association_remove, :association_groupings_get, :association_specific_group_get, :multi_channel_association_groupings_get, :multi_channel_association_set, :multi_channel_association_get, :multi_channel_association_remove ] Enum.reduce(commands, [], fn cmd, actions -> if Enum.member?(extra_commands, cmd) do new_actions = handle_command(node_id, cmd, opts) actions ++ new_actions else if cmd.name == :alarm_report do actions ++ [{:notify, cmd}] else actions ++ [{:forward_to_controller, cmd}] end end end) end defp handle_command(_node_id, %Command{name: :version_command_class_get} = command, _opts) do command_class = Command.param!(command, :command_class) case VersionReports.version_report_for(command_class) do {:ok, report} -> [{:send, report}] _ -> [] end end defp handle_command(_node_id, %Command{name: :s2_resynchronization_event} = command, _opts) do node_id = Command.param!(command, :node_id) reason = Command.param!(command, :reason) :telemetry.execute( [:grizzly, :zwave, :s2_resynchronization], %{}, %{node_id: node_id, reason: reason} ) [{:notify, command}] end defp handle_command(node_id, %Command{name: :wake_up_notification} = command, _opts) do Grizzly.Storage.put_node_last_awake(node_id, DateTime.utc_now()) [{:notify, command}] end defp handle_command(_node_id, command, _opts), do: [{:notify, command}] defp make_supervision_report(%Command{name: :supervision_get} = command, status) do session_id = Command.param!(command, :session_id) Commands.create(:supervision_report, session_id: session_id, status: status, more_status_updates: :last_report, duration: 0 ) end end