lib/discover.ex

defmodule Yeelight.Discover do
  @moduledoc """
  start the discovery server and get the device list
  ```
  Yeelight.Discover.start()
  :timer.sleep(1000)

  devices = Yeelight.Discover.devices()
  ```
  """
  use GenServer

  defmodule State do
    @moduledoc false
    defstruct udp: nil, devices: [], handlers: [], port: nil
  end

  @port 1982
  @multicast_group {239, 255, 255, 250}
  @discover_message """
  M-SEARCH * HTTP/1.1\r\n
  HOST: 239.255.255.250:1982\r\n
  MAN: "ssdp:discover"\r\n
  ST: wifi_bulb\r\n
  """

  def start_link do
    GenServer.start_link(__MODULE__, @port, name: __MODULE__)
  end

  def start do
    start_link()
    GenServer.call(__MODULE__, :start)
  end

  def discover, do: GenServer.call(__MODULE__, :discover)

  def devices, do: GenServer.call(__MODULE__, :devices)

  @impl true
  def init(port) do
    {:ok, %State{:port => port}}
  end

  @impl true
  def handle_call(:start, _from, state) do
    {:reply, :ok,
     case state.udp do
       nil ->
         udp_options = [
           :binary,
           add_membership: {@multicast_group, {0, 0, 0, 0}},
           multicast_if: {0, 0, 0, 0},
           multicast_loop: false,
           multicast_ttl: 2,
           reuseaddr: true
         ]

         {:ok, udp} = :gen_udp.open(state.port, udp_options)

         Process.send_after(self(), :discover, 0)
         Map.update(state, :udp, udp, fn _ -> udp end)

       _exists ->
         state
     end}
  end

  def handle_call(:devices, _from, state) do
    {:reply, state.devices, state}
  end

  def handle_call(:send, _from, state) do
    :gen_udp.send(state.udp, @multicast_group, @port, discover())
    {:noreply, state}
  end

  @impl true
  def handle_info(:discover, state) do
    Process.send_after(self(), {:send, @discover_message}, (:rand.uniform() * 1000) |> round)
    Process.send_after(self(), :discover, 61_000)
    {:noreply, state}
  end

  def handle_info({:send, discover}, state) do
    :gen_udp.send(state.udp, @multicast_group, @port, discover)
    {:noreply, state}
  end

  def handle_info(<<@discover_message, _::binary>>, state) do
    {:noreply, state}
  end

  def handle_info({:udp, _s, _ip, _port, <<"HTTP/1.1 200 OK\r\n", device::binary>>}, state) do
    {:noreply, device |> parse_device |> update_devices(state)}
  end

  def handle_info({:udp, _s, _ip, _port, <<"NOTIFY * HTTP/1.1\r\n", device::binary>>}, state) do
    {:noreply, device |> parse_device |> update_devices(state)}
  end

  def parse_device(body) do
    map =
      body
      |> String.split(["\r\n", "\n"], trim: true)
      |> Enum.reduce(%{}, fn x, acc ->
        case String.split(x, ": ", parts: 2) do
          [k, v] when k in ~w(bright color_mode ct rgb hue sat) ->
            Map.put(acc, String.to_atom(k), String.to_integer(v))

          [k, v] when k in ~w(id model fw_ver power name) ->
            Map.put(acc, String.to_atom(k), v)

          ["support", v] ->
            Map.put(acc, :support, String.split(v, " ", trim: true))

          ["Location", location] ->
            uri =
              location
              |> String.replace("yeelight", "http")
              |> URI.parse()

            acc
            |> Map.put(:host, uri.host)
            |> Map.put(:port, uri.port)

          [_k, _v] ->
            acc
        end
      end)

    struct!(Yeelight.Device, map)
  end

  def update_devices(device, state) when is_map(device) do
    case state.devices |> Enum.any?(&(&1.id == device.id)) do
      false -> Map.update(state, :devices, state.devices, &[device | &1])
      true -> state
    end
  end
end