├── .formatter.exs
├── .gitignore
├── LICENSE
├── README.md
├── config
└── config.exs
├── lib
├── sonex.ex
└── sonex
│ ├── application.ex
│ ├── event_mngr.ex
│ ├── network
│ ├── discovery.ex
│ └── state.ex
│ ├── player.ex
│ ├── request.xml.eex
│ ├── services.ex
│ ├── soap.ex
│ ├── subscriptions
│ ├── sub_handler_av.ex
│ ├── sub_handler_dev.ex
│ ├── sub_handler_render.ex
│ ├── sub_handler_zone.ex
│ ├── sub_helpers.ex
│ └── sub_mngr.ex
│ └── types
│ ├── player_state.ex
│ ├── sonoes_device.ex
│ └── subscription_event.ex
├── mix.exs
├── mix.lock
└── test
├── sonex_test.exs
└── test_helper.exs
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [],
3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"]
4 | ]
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /_build
2 | /cover
3 | /deps
4 | erl_crash.dump
5 | *.ez
6 | .elixir_ls/
7 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Sonex
2 | Elixir Sonos Controller Elixir "Application"
3 |
4 | - Allows for basic control of Sonos household from Elixir/Erlang
5 | - Discovers Sonos Devices on LAN once application is launched
6 |
7 | ## Example
8 | ```
9 | alias Sonex.Discovery
10 | alias Sonex.Player
11 | den_player = Sonex.Discovery.playerByName("Den")
12 | portable = Sonex.Discovery.playerByName("Portable")
13 | den_player |> Sonex.Discovery.zone_group_state()
14 | portable |> Player.zone_group_state()
15 | portable |> Player.group(:leave)
16 | portable |> Player.control(:pause)
17 | portable |> Player.audio(:volume)
18 | portable |> Player.audio(:volume, 50)
19 | den_player |> Player.control(:pause)
20 | den_player |> Player.position_info()
21 |
22 | ```
23 |
24 | ## Installation
25 |
26 | On Linux
27 | `sudo apt-get install erlang-xmerl`
28 |
29 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as:
30 |
31 | 1. Add sonex to your list of dependencies in `mix.exs`:
32 |
33 | def deps do
34 | [{:sonex, "~> 0.0.1"}]
35 | end
36 |
37 | 2. Ensure sonex is started before your application:
38 |
39 | def application do
40 | [applications: [:sonex]]
41 | end
42 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | use Mix.Config
4 |
5 | # This configuration is loaded before any dependency and is restricted
6 | # to this project. If another project depends on this project, this
7 | # file won't be loaded nor affect the parent project. For this reason,
8 | # if you want to provide default values for your application for
9 | # 3rd-party users, it should be done in your "mix.exs" file.
10 |
11 | config :sonex, []
12 |
13 | # You can configure for your application as:
14 | #
15 | # config :sonex, key: :value
16 | #
17 | # And access this configuration in your application as:
18 | #
19 | # Application.get_env(:sonex, :key)
20 | #
21 | # Or configure a 3rd-party app:
22 | #
23 | config :logger, level: :debug
24 | #
25 |
26 | # It is also possible to import configuration files, relative to this
27 | # directory. For example, you can emulate configuration per environment
28 | # by uncommenting the line below and defining dev.exs, test.exs and such.
29 | # Configuration from the imported file will override the ones defined
30 | # here (which is why it is important to import them last).
31 | #
32 | # import_config "#{Mix.env}.exs"
33 |
--------------------------------------------------------------------------------
/lib/sonex.ex:
--------------------------------------------------------------------------------
1 | defmodule Sonex do
2 | alias Sonex.Player
3 | alias Sonex.Network.State
4 |
5 | def get_zones() do
6 | State.zones()
7 | end
8 |
9 | def get_players() do
10 | State.players()
11 | end
12 |
13 | def get_player(uuid) do
14 | State.get_player(uuid)
15 | end
16 |
17 | def players_in_zone(zone_uuid) do
18 | State.players_in_zone(zone_uuid)
19 | end
20 |
21 | def start_player(player) do
22 | Player.control(player, :play)
23 | end
24 |
25 | def stop_player(player) do
26 | Player.control(player, :stop)
27 | end
28 |
29 | def set_volume(player, level) when is_binary(level) do
30 | Player.audio(player, :volume, String.to_integer(level))
31 | end
32 |
33 | def set_volume(player, level) do
34 | Player.audio(player, :volume, level)
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/sonex/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Sonex.Application do
2 | @moduledoc false
3 |
4 | use Application
5 | import Supervisor.Spec, warn: false
6 |
7 | def start(_type, _args) do
8 | children = [
9 | {Registry, keys: :duplicate, name: Sonex},
10 | Sonex.Network.State,
11 | Sonex.EventMngr,
12 | worker(Sonex.Discovery, []),
13 | worker(Sonex.SubMngr, [])
14 | ]
15 |
16 | opts = [strategy: :one_for_one, name: LibAstroEx.Supervisor]
17 | Supervisor.start_link(children, opts)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/sonex/event_mngr.ex:
--------------------------------------------------------------------------------
1 | defmodule Sonex.EventMngr do
2 | use GenServer
3 | require Logger
4 |
5 | def start_link(_vars) do
6 | GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
7 | end
8 |
9 | def init(args) do
10 | {:ok, _} = Registry.register(Sonex, "devices", [])
11 |
12 | {:ok, args}
13 | end
14 |
15 | def handle_event({:test, msg}, state) do
16 | Logger.info("test event received: #{msg}")
17 | {:ok, state}
18 | end
19 |
20 | def handle_event({:execute, device}, state) do
21 | Logger.info("execute event received: #{inspect(device.name)}")
22 |
23 | {:ok, state}
24 | end
25 |
26 | def handle_info({:start, _new_device}, state), do: {:noreply, state}
27 |
28 | def handle_info({:updated, _new_device}, state), do: {:noreply, state}
29 |
30 | def handle_info({:discovered, new_device}, state) do
31 | Sonex.SubMngr.subscribe(new_device, Sonex.Service.get(:renderer))
32 | Sonex.SubMngr.subscribe(new_device, Sonex.Service.get(:zone))
33 | Sonex.SubMngr.subscribe(new_device, Sonex.Service.get(:av))
34 | # Sonex.SubMngr.subscribe(new_device, Sonex.Service.get(:device))
35 |
36 | {:noreply, state}
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/sonex/network/discovery.ex:
--------------------------------------------------------------------------------
1 | defmodule DiscoverState do
2 | defstruct socket: nil, state: :starting
3 |
4 | @type t :: %__MODULE__{socket: pid}
5 | end
6 |
7 | alias Sonex.Network.State
8 |
9 | defmodule Sonex.Discovery do
10 | use GenServer
11 |
12 | require Logger
13 |
14 | @playersearch ~S"""
15 | M-SEARCH * HTTP/1.1
16 | HOST: 239.255.255.250:1900
17 | MAN: "ssdp:discover"
18 | MX: 1
19 | ST: urn:schemas-upnp-org:device:ZonePlayer:1
20 | """
21 | @multicastaddr {239, 255, 255, 250}
22 | @multicastport 1900
23 |
24 | @polling_duration 60_000
25 |
26 | def start_link() do
27 | GenServer.start_link(__MODULE__, %DiscoverState{}, name: __MODULE__)
28 | end
29 |
30 | def init(%DiscoverState{} = state) do
31 | # not really sure why i need an IP, does not seem to work on 0.0.0.0 after some timeout occurs...
32 | # needs to be passed a interface IP that is the same lan as sonos DLNA multicasts
33 |
34 | state = attempt_network_init(state)
35 |
36 | {:ok, state}
37 | end
38 |
39 | def terminate(_reason, %DiscoverState{socket: socket} = _state) when socket != nil do
40 | :ok = :gen_udp.close(socket)
41 | end
42 |
43 | @doc """
44 | Fires a UPNP discover packet onto the LAN,
45 | all Sonos devices should respond, refresing player attributes stored in state
46 | """
47 | def discover() do
48 | GenServer.cast(__MODULE__, :discover)
49 | end
50 |
51 | @doc """
52 | Returns true if devices were discovered on lan
53 | """
54 | def discovered?() do
55 | GenServer.call(__MODULE__, :discovered)
56 | end
57 |
58 | @doc """
59 | Terminates Sonex.Discovery GenServer
60 | """
61 | def kill() do
62 | GenServer.stop(__MODULE__, "Done")
63 | end
64 |
65 | def handle_info({:discovered, _new_device}, state), do: {:noreply, state}
66 | def handle_info({:start, _new_device}, state), do: {:noreply, state}
67 |
68 | def handle_info(
69 | {:updated, %SonosDevice{uuid: new_uuid} = new_device},
70 | %{players: players} = state
71 | ) do
72 | players =
73 | players
74 | |> Enum.map(fn p ->
75 | if get_uuid(p) == new_uuid,
76 | do: new_device |> IO.inspect(label: "did replace dev"),
77 | else: p
78 | end)
79 |
80 | {:noreply, %{state | players: players}}
81 | end
82 |
83 | def handle_info({:udp, _socket, ip, _fromport, packet}, state) do
84 | with this_player <- parse_upnp(ip, packet),
85 | {name, icon, config} <- attributes(this_player),
86 | {:bridge, true} <- {:bridge, name != "BRIDGE"},
87 | {_, zone_coordinator, _} = group_attributes(this_player) do
88 | player = %SonosDevice{
89 | this_player
90 | | name: name,
91 | icon: icon,
92 | config: config,
93 | coordinator_uuid: zone_coordinator
94 | }
95 |
96 | State.put_device(player)
97 | else
98 | {:bridge, true} ->
99 | Logger.debug("found bridge")
100 |
101 | e ->
102 | IO.inspect(e, label: "the errors")
103 | end
104 |
105 | {:noreply, state}
106 | end
107 |
108 | def handle_cast(:kill, state) do
109 | :ok = :gen_udp.close(state.socket)
110 | {:noreply, state}
111 | end
112 |
113 | def handle_cast(:discover, state) do
114 | :gen_udp.send(state.socket, @multicastaddr, @multicastport, @playersearch)
115 | {:noreply, state}
116 | end
117 |
118 | def handle_info(:initialize_network, state) do
119 | IO.inspect(state, label: "handing initialize_network")
120 | state = attempt_network_init(state)
121 | {:noreply, state}
122 | end
123 |
124 | defp attempt_network_init(state) do
125 | case get_ip() do
126 | {:ok, nil} ->
127 | Process.send_after(self(), :initialize_network, @polling_duration)
128 | %DiscoverState{state | state: :disconnected}
129 |
130 | {:ok, ip_addr} ->
131 | {:ok, socket} =
132 | :gen_udp.open(0, [
133 | :binary,
134 | :inet,
135 | {:ip, ip_addr},
136 | {:active, true},
137 | {:multicast_if, ip_addr},
138 | {:multicast_ttl, 4},
139 | {:add_membership, {@multicastaddr, ip_addr}}
140 | ])
141 |
142 | # fire two udp discover packets immediately
143 | :gen_udp.send(socket, @multicastaddr, @multicastport, @playersearch)
144 | :gen_udp.send(socket, @multicastaddr, @multicastport, @playersearch)
145 | %DiscoverState{state | socket: socket, state: :connected}
146 | end
147 | end
148 |
149 | defp attributes(%SonosDevice{} = player) do
150 | import SweetXml
151 | {:ok, res_body} = Sonex.SOAP.build(:device, "GetZoneAttributes") |> Sonex.SOAP.post(player)
152 |
153 | {xpath(res_body, ~x"//u:GetZoneAttributesResponse/CurrentZoneName/text()"s),
154 | xpath(res_body, ~x"//u:GetZoneAttributesResponse/CurrentIcon/text()"s),
155 | xpath(res_body, ~x"//u:GetZoneAttributesResponse/CurrentConfiguration/text()"i)}
156 | end
157 |
158 | defp group_attributes(%SonosDevice{} = player) do
159 | import SweetXml
160 | {:ok, res_body} = Sonex.SOAP.build(:zone, "GetZoneGroupAttributes") |> Sonex.SOAP.post(player)
161 |
162 | {zone_name, zone_id, player_list} =
163 | {xpath(res_body, ~x"//u:GetZoneGroupAttributesResponse/CurrentZoneGroupName/text()"s),
164 | xpath(res_body, ~x"//u:GetZoneGroupAttributesResponse/CurrentZoneGroupID/text()"s),
165 | xpath(
166 | res_body,
167 | ~x"//u:GetZoneGroupAttributesResponse/CurrentZonePlayerUUIDsInGroup/text()"ls
168 | )}
169 |
170 | clean_zone =
171 | case String.split(zone_id, ":") do
172 | [one, _] ->
173 | one
174 |
175 | [""] ->
176 | ""
177 | end
178 |
179 | case(zone_name) do
180 | "" ->
181 | {nil, clean_zone, player_list}
182 |
183 | _ ->
184 | {zone_name, clean_zone, player_list}
185 | end
186 | end
187 |
188 | def zone_group_state(%SonosDevice{} = player) do
189 | import SweetXml
190 |
191 | {:ok, res} =
192 | Sonex.SOAP.build(:zone, "GetZoneGroupState", [])
193 | |> Sonex.SOAP.post(player)
194 |
195 | xpath(res, ~x"//ZoneGroupState/text()"s)
196 | |> xpath(~x"//ZoneGroups/ZoneGroup"l,
197 | coordinator_uuid: ~x"//./@Coordinator"s,
198 | members: [
199 | ~x"//./ZoneGroup/ZoneGroupMember"el,
200 | name: ~x"//./@ZoneName"s,
201 | uuid: ~x"//./@UUID"s,
202 | addr: ~x"//./@Location"s,
203 | config: ~x"//./@Configuration"i,
204 | icon: ~x"//./@Icon"s
205 | ]
206 | )
207 | end
208 |
209 | defp parse_upnp(ip, good_resp) do
210 | split_resp = String.split(good_resp, "\r\n")
211 | vers_model = Enum.fetch!(split_resp, 4)
212 |
213 | if String.contains?(vers_model, "Sonos") do
214 | ["SERVER:", "Linux", "UPnP/1.0", version, model_raw] = String.split(vers_model)
215 | model = String.trim(model_raw)
216 | "USN: uuid:" <> usn = Enum.fetch!(split_resp, 6)
217 | uuid = String.split(usn, "::") |> Enum.at(0)
218 | "X-RINCON-HOUSEHOLD: " <> household = Enum.fetch!(split_resp, 7)
219 |
220 | %SonosDevice{
221 | ip: format_ip(ip),
222 | version: version,
223 | model: model,
224 | uuid: uuid,
225 | household: household
226 | }
227 | end
228 | end
229 |
230 | defp get_uuid(%SonosDevice{uuid: new_uuid}), do: new_uuid
231 |
232 | def get_ip do
233 | dev_name = Application.get_env(:sonex, Sonex.Discovery)[:net_device_name]
234 |
235 | en0 = to_charlist(dev_name)
236 | {:ok, test_socket} = :inet_udp.open(8989, [])
237 |
238 | ip_addr =
239 | case :inet.ifget(test_socket, en0, [:addr]) do
240 | {:ok, [addr: ip]} ->
241 | ip
242 |
243 | {:ok, []} ->
244 | case :prim_inet.ifget(test_socket, en0, [:addr]) do
245 | {:ok, [addr: ip]} ->
246 | ip
247 |
248 | {:ok, []} ->
249 | nil
250 | end
251 | end
252 |
253 | :inet_udp.close(test_socket)
254 | {:ok, ip_addr}
255 | end
256 |
257 | defp format_ip({a, b, c, d}) do
258 | "#{a}.#{b}.#{c}.#{d}"
259 | end
260 | end
261 |
--------------------------------------------------------------------------------
/lib/sonex/network/state.ex:
--------------------------------------------------------------------------------
1 | defmodule Sonex.Network.State do
2 | defmodule NetState do
3 | defstruct current_zone: "", players: %{}
4 | end
5 |
6 | use GenServer
7 | require Logger
8 |
9 | alias Sonex.Network.State.NetState
10 |
11 | def start_link(_vars) do
12 | GenServer.start_link(__MODULE__, %NetState{}, name: __MODULE__)
13 | end
14 |
15 | def get_state do
16 | GenServer.call(__MODULE__, :get_state)
17 | end
18 |
19 | def players do
20 | %{players: players} = get_state()
21 | Map.values(players)
22 | end
23 |
24 | def put_device(%SonosDevice{} = device) do
25 | GenServer.call(__MODULE__, {:put_device, device})
26 | end
27 |
28 | def players_in_zone(zone_uuid) do
29 | players()
30 | |> Enum.filter(fn p -> p.coordinator_uuid == zone_uuid end)
31 | end
32 |
33 | def zones() do
34 | players()
35 | |> Enum.filter(fn p -> p.coordinator_uuid == p.uuid end)
36 | end
37 |
38 | def get_player(uuid) do
39 | %{players: players} = get_state()
40 | Map.get(players, uuid)
41 | end
42 |
43 | def get_player_by_name(name) do
44 | players()
45 | |> Enum.find(nil, fn player ->
46 | player.name == name
47 | end)
48 | end
49 |
50 | def init(data) do
51 | {:ok, data}
52 | end
53 |
54 | def handle_call(:get_state, _from, %NetState{} = state) do
55 | {:reply, state, state}
56 | end
57 |
58 | def handle_call(
59 | {:put_device, %SonosDevice{uuid: uuid} = device},
60 | _from,
61 | %NetState{players: players} = state
62 | ) do
63 | players
64 | |> Map.get(uuid)
65 | |> case do
66 | nil ->
67 | Process.send(self(), {:broadcast, device, :discovered}, [])
68 |
69 | _dev ->
70 | Process.send(self(), {:broadcast, device, :updated}, [])
71 | end
72 |
73 | {:reply, :ok, %NetState{state | players: Map.put(players, uuid, device)}}
74 | end
75 |
76 | def terminate(reason, _state) do
77 | Logger.error("exiting Sonex.Network.State due to #{inspect(reason)}")
78 | end
79 |
80 | def handle_info({:broadcast, device, key}, state) do
81 | Registry.dispatch(Sonex, "devices", fn entries ->
82 | for {pid, _} <- entries do
83 | send(pid, {key, device})
84 | end
85 | end)
86 |
87 | {:noreply, state}
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/lib/sonex/player.ex:
--------------------------------------------------------------------------------
1 | defmodule Sonex.Player do
2 | require Logger
3 | alias Sonex.SOAP
4 | alias Sonex.Network.State
5 |
6 | def setName(%SonosDevice{} = device, new_name) do
7 | {:ok, _} =
8 | SOAP.build(:device, "SetZoneAttributes", [
9 | ["DesiredZoneName", new_name],
10 | ["DesiredIcon", device.icon],
11 | ["DesiredConfiguration", device.config]
12 | ])
13 | |> SOAP.post(device)
14 | end
15 |
16 | def control(%SonosDevice{} = device, action) do
17 | act_str =
18 | case(action) do
19 | :play -> "Play"
20 | :pause -> "Pause"
21 | :stop -> "Stop"
22 | :prev -> "Previous"
23 | :next -> "Next"
24 | end
25 |
26 | SOAP.build(:av, act_str, [["InstanceID", 0], ["Speed", 1]])
27 | |> SOAP.post(device)
28 | end
29 |
30 | def transport_info(%SonosDevice{} = device) do
31 | SOAP.build(:av, "GetTransportInfo", [["InstanceID", 0]])
32 | |> SOAP.post(device)
33 | end
34 |
35 | def position_info(%SonosDevice{} = device) do
36 | SOAP.build(:av, "GetPositionInfo", [["InstanceID", 0]])
37 | |> SOAP.post(device)
38 | end
39 |
40 | def group(%SonosDevice{} = device, :leave) do
41 | SOAP.build(:av, "BecomeCoordinatorOfStandaloneGroup", [["InstanceID", 0]])
42 | |> SOAP.post(device)
43 | end
44 |
45 | def group(%SonosDevice{} = device, :join, coordinator_name) do
46 | coordinator = State.get_player(name: coordinator_name)
47 |
48 | args = [
49 | ["InstanceID", 0],
50 | ["CurrentURI", "x-rincon:" <> coordinator.usnID],
51 | ["CurrentURIMetaData", ""]
52 | ]
53 |
54 | SOAP.build(:av, "SetAVTransportURI", args)
55 | |> SOAP.post(device)
56 | end
57 |
58 | def audio(%SonosDevice{} = device, :volume, level) when level > 0 and level < 100 do
59 | args = [["InstanceID", 0], ["Channel", "Master"], ["DesiredVolume", level]]
60 |
61 | SOAP.build(:renderer, "SetVolume", args)
62 | |> SOAP.post(device)
63 | end
64 |
65 | def audio(%SonosDevice{} = device, :volume) do
66 | args = [["InstanceID", 0], ["Channel", "Master"]]
67 |
68 | SOAP.build(:renderer, "GetVolume", args)
69 | |> SOAP.post(device)
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/lib/sonex/request.xml.eex:
--------------------------------------------------------------------------------
1 |
4 | <%= if soap_req_struct.header != nil do %>
5 | <%= soap_req_struct.header %>
6 | <% end %>
7 |
8 |
9 | xmlns:u="<%= soap_req_struct.namespace %>">
10 | <%= for [name, value] <- soap_req_struct.params do %>
11 | <<%= name %>><%= value %><%= name %>>
12 | <% end %>
13 | >
14 |
15 |
--------------------------------------------------------------------------------
/lib/sonex/services.ex:
--------------------------------------------------------------------------------
1 | defmodule Sonex.Service do
2 | @alarm_clock "AlarmClock"
3 | @audio_input "AudioIn"
4 | @device_props "DeviceProperties"
5 | @music_services "MusicServices"
6 | @system_props "SystemProperties"
7 | @redering_cont "RenderingControl"
8 | @zone_group "ZoneGroupTopology"
9 | @group_mgmt "GroupManagement"
10 | @content_dir "ContentDirectory"
11 | @conn_mngr "ConnectionManager"
12 | @av_transport "AVTransport"
13 | @q "Queue"
14 |
15 | @doc """
16 | urn:schemas-upnp-org:service:AudioIn:1
17 | /AudioIn/Control
18 | /AudioIn/Event
19 | /xml/AudioIn1.xml
20 | """
21 | def get(:audio_in) do
22 | make_service(@audio_input)
23 | end
24 |
25 | @doc """
26 | urn:schemas-upnp-org:service:DeviceProperties:1
27 | /DeviceProperties/Control
28 | /DeviceProperties/Event
29 | /xml/DeviceProperties1.xml
30 | """
31 | def get(:device) do
32 | make_service(@device_props)
33 | end
34 |
35 | @doc """
36 | urn:schemas-upnp-org:service:MusicServices:1
37 | /MusicServices/Control
38 | /MusicServices/Event
39 | /xml/MusicServices1.xml
40 | """
41 | def get(:music) do
42 | make_service(@music_services)
43 | end
44 |
45 | @doc """
46 | urn:schemas-upnp-org:service:ZoneGroupTopology:1
47 | /ZoneGroupTopology/Control
48 | /ZoneGroupTopology/Event
49 | /xml/ZoneGroupTopology1.xml
50 | """
51 | def get(:zone) do
52 | make_service(@zone_group)
53 | end
54 |
55 | @doc """
56 | urn:schemas-upnp-org:service:SystemProperties:1
57 | /SystemProperties/Control
58 | /SystemProperties/Event
59 | /xml/SystemProperties1.xml
60 | """
61 | def get(:system) do
62 | make_service(@system_props)
63 | end
64 |
65 | @doc """
66 | urn:schemas-upnp-org:service:GroupManagement:1
67 | /GroupManagement/Control
68 | /GroupManagement/Event
69 | /xml/GroupManagement1.xml
70 | """
71 | def get(:group) do
72 | make_service(@group_mgmt)
73 | end
74 |
75 | @doc """
76 | urn:schemas-upnp-org:service:ContentDirectory:1
77 | /MediaServer/ContentDirectory/Control
78 | /MediaServer/ContentDirectory/Event
79 | /xml/ContentDirectory1.xml
80 | """
81 | def get(:content) do
82 | make_service(@content_dir, :server)
83 | end
84 |
85 | @doc """
86 | urn:schemas-upnp-org:service:ConnectionManager:1
87 | /MediaServer/ConnectionManager/Control
88 | /MediaServer/ConnectionManager/Event
89 | /xml/ConnectionManager1.xml
90 | """
91 | def get(:conn) do
92 | make_service(@conn_mngr, :server)
93 | end
94 |
95 | @doc """
96 | urn:schemas-upnp-org:service:AlarmClock:1
97 | /AlarmClock/Control
98 | /AlarmClock/Event
99 | /xml/AlarmClock1.xml
100 | """
101 | def get(:alarm) do
102 | make_service(@alarm_clock, :renderer)
103 | end
104 |
105 | @doc """
106 | urn:schemas-upnp-org:service:AVTransport:1
107 | /MediaRenderer/AVTransport/Control
108 | /MediaRenderer/AVTransport/Event
109 | /xml/AVTransport1.xml
110 | """
111 | def get(:av) do
112 | make_service(@av_transport, :renderer)
113 | end
114 |
115 | @doc """
116 | urn:schemas-upnp-org:service:RenderingControl:1
117 | /MediaRenderer/RenderingControl/Control
118 | /MediaRenderer/RenderingControl/Event
119 | /xml/RenderingControl1.xml
120 | """
121 | def get(:renderer) do
122 | make_service(@redering_cont, :renderer)
123 | end
124 |
125 | @doc """
126 | urn:schemas-sonos-com:service:Queue:1
127 | /MediaRenderer/Queue/Control
128 | /MediaRenderer/Queue/Event
129 | /xml/Queue1.xml
130 | """
131 | def get(:queue) do
132 | make_service(@q, :renderer)
133 | end
134 |
135 | def actions(service) do
136 | case(Enum.count(Sonex.get_players()) > 0) do
137 | true ->
138 | serv = get(service)
139 | {:ok, players} = Sonex.get_players()
140 | player = hd(players)
141 |
142 | %HTTPoison.Response{status_code: 200, body: res_body, headers: _resp_headers} =
143 | HTTPoison.get!("http://" <> player.ip <> ":1400" <> serv.scpd_url)
144 |
145 | IO.puts(res_body)
146 |
147 | false ->
148 | {:error, "no devices to query for actions"}
149 | end
150 |
151 | #
152 | #
153 | end
154 |
155 | defp make_service(service_name) do
156 | %{
157 | type: make_type(service_name),
158 | control: make_control(service_name),
159 | event: make_event(service_name),
160 | scpd_url: make_url(service_name)
161 | }
162 | end
163 |
164 | defp make_service(service_name, :renderer) do
165 | %{
166 | type: make_type(service_name),
167 | control: make_control_renderer(service_name),
168 | event: make_event_renderer(service_name),
169 | scpd_url: make_url(service_name)
170 | }
171 | end
172 |
173 | defp make_service(service_name, :server) do
174 | %{
175 | type: make_type(service_name),
176 | control: make_control_server(service_name),
177 | event: make_event_server(service_name),
178 | scpd_url: make_url(service_name)
179 | }
180 | end
181 |
182 | defp make_type(service) do
183 | "urn:schemas-upnp-org:service:#{service}:1"
184 | end
185 |
186 | defp make_control(service) do
187 | "/#{service}/Control"
188 | end
189 |
190 | defp make_control_server(service) do
191 | "/MediaServer/#{service}/Control"
192 | end
193 |
194 | defp make_event_server(service) do
195 | "/MediaServer/#{service}/Event"
196 | end
197 |
198 | defp make_control_renderer(service) do
199 | "/MediaRenderer/#{service}/Control"
200 | end
201 |
202 | defp make_event_renderer(service) do
203 | "/MediaRenderer/#{service}/Event"
204 | end
205 |
206 | defp make_event(service) do
207 | "/#{service}/Event"
208 | end
209 |
210 | defp make_url(service) do
211 | "/xml/#{service}1.xml"
212 | end
213 | end
214 |
--------------------------------------------------------------------------------
/lib/sonex/soap.ex:
--------------------------------------------------------------------------------
1 | defmodule Sonex.SOAP do
2 | require EEx
3 | import SweetXml
4 |
5 | @moduledoc """
6 | Functions for generating and sending Sonos SOAP requests via HTTP
7 | """
8 |
9 | defmodule SOAPReq do
10 | @moduledoc """
11 | Struct that represents the contents of a Sonos XML SOAP request
12 | Typically only requires method and params
13 | Build using build() function, it will automatically fill in namespace based on specific service
14 | """
15 |
16 | defstruct path: nil, method: nil, namespace: nil, header: nil, params: []
17 |
18 | @type t :: %__MODULE__{
19 | path: String.t(),
20 | method: String.t(),
21 | namespace: String.t(),
22 | header: String.t(),
23 | params: list(list)
24 | }
25 | end
26 |
27 | @doc """
28 | Generates a SOAP XML for the Sonos API based on SOAPReq Struct
29 | """
30 | EEx.function_from_file(:def, :gen, Path.expand("./lib/sonex/request.xml.eex"), [
31 | :soap_req_struct
32 | ])
33 |
34 | @doc """
35 | Build a SOAPReq Struct, to be passed to post function
36 | """
37 | def build(service_atom, method, params \\ [], event \\ false) do
38 | serv = Sonex.Service.get(service_atom)
39 |
40 | req_path =
41 | case(event) do
42 | false -> serv.control
43 | true -> serv.event
44 | end
45 |
46 | %SOAPReq{method: method, namespace: serv.type, path: req_path, params: params}
47 | end
48 |
49 | @doc """
50 | Generates XML request body and sends via HTTP post to specified %SonosDevice{}
51 | Returns response body as XML, or error based on codes
52 | """
53 | def post(%SOAPReq{} = req, %SonosDevice{} = player) do
54 | req_headers = gen_headers(req)
55 | req_body = gen(req)
56 | uri = "http://#{player.ip}:1400#{req.path}"
57 | res = HTTPoison.post!(uri, req_body, req_headers)
58 |
59 | case(res) do
60 | %HTTPoison.Response{status_code: 200, body: res_body} ->
61 | {:ok, res_body}
62 |
63 | %HTTPoison.Response{status_code: 500, body: res_err} ->
64 | case(req.namespace) do
65 | "urn:schemas-upnp-org:service:ContentDirectory:1" ->
66 | {:error, parse_soap_error(res_err, true)}
67 |
68 | _ ->
69 | {:error, parse_soap_error(res_err)}
70 | end
71 | end
72 | end
73 |
74 | defp gen_headers(soap_req) do
75 | %{
76 | "Content-Type" => "text/xml; charset=\"utf-8\"",
77 | "SOAPACTION" => "\"#{soap_req.namespace}##{soap_req.method}\""
78 | }
79 | end
80 |
81 | def parse_soap_error(err_body, content_dir_req \\ false) do
82 | # https://github.com/SoCo/SoCo/blob/master/soco/services.py
83 | # For error codes, see table 2.7.16 in
84 | # http://upnp.org/specs/av/UPnP-av-ContentDirectory-v1-Service.pdf
85 | # http://upnp.org/specs/av/UPnP-av-AVTransport-v1-Service.pdf
86 | case(xpath(err_body, ~x"//UPnPError/errorCode/text()"i)) do
87 | 400 -> "Bad Request"
88 | 401 -> "Invalid Action"
89 | 402 -> "Invalid Args"
90 | 404 -> "Invalid Var"
91 | 412 -> "Precondition Failed"
92 | 501 -> "Action Failed"
93 | 600 -> "Argument Value Invalid"
94 | 601 -> "Argument Value Out of Range"
95 | 602 -> "Optional Action Not Implemented"
96 | 603 -> "Out Of Memory"
97 | 604 -> "Human Intervention Required"
98 | 605 -> "String Argument Too Long"
99 | 606 -> "Action Not Authorized"
100 | 607 -> "Signature Failure"
101 | 608 -> "Signature Missing"
102 | 609 -> "Not Encrypted"
103 | 610 -> "Invalid Sequence"
104 | 611 -> "Invalid Control URL"
105 | 612 -> "No Such Session"
106 | 701 when content_dir_req == true -> "No such object"
107 | 701 -> "Transition not available"
108 | 702 when content_dir_req == true -> "Invalid CurrentTagValue"
109 | 702 -> "No contents"
110 | 703 when content_dir_req == true -> "Invalid NewTagValue"
111 | 703 -> "Read error"
112 | 704 when content_dir_req == true -> "Required tag"
113 | 704 -> "Format not supported for playback"
114 | 705 when content_dir_req == true -> "Read only tag"
115 | 705 -> "Transport is locked"
116 | 706 when content_dir_req == true -> "Parameter Mismatch"
117 | 706 -> "Write error"
118 | 707 -> "Media is protected or not writeable"
119 | 708 when content_dir_req == true -> "Unsupported or invalid search criteria"
120 | 708 -> "Format not supported for recording"
121 | 709 when content_dir_req == true -> "Unsupported or invalid sort criteria"
122 | 709 -> "Media is full"
123 | 710 when content_dir_req == true -> "No such container"
124 | 710 -> "Seek mode not supported"
125 | 711 when content_dir_req == true -> "Restricted object"
126 | 711 -> "Illegal seek target"
127 | 712 when content_dir_req == true -> "Bad metadata"
128 | 712 -> "Play mode not supported"
129 | 713 when content_dir_req == true -> "Restricted parent object"
130 | 713 -> "Record quality not supported"
131 | 714 when content_dir_req == true -> "No such source resource"
132 | 714 -> "Illegal MIME-Type"
133 | 715 when content_dir_req == true -> "Resource access denied"
134 | 715 -> "Content BUSY"
135 | 716 when content_dir_req == true -> "Transfer busy"
136 | 716 -> "Resource Not found"
137 | 717 when content_dir_req == true -> "No such file transfer"
138 | 717 -> "Play speed not supported"
139 | 718 when content_dir_req == true -> "No such destination resource"
140 | 718 -> "Invalid InstanceID"
141 | 719 -> "Destination resource access denied"
142 | 720 -> "Cannot process the request"
143 | 737 -> "No DNS Server"
144 | 738 -> "Bad Domain Name"
145 | 739 -> "Server Error"
146 | _ -> "Unknown Error"
147 | end
148 | end
149 | end
150 |
--------------------------------------------------------------------------------
/lib/sonex/subscriptions/sub_handler_av.ex:
--------------------------------------------------------------------------------
1 | defmodule Sonex.SubHandlerAV do
2 | import SweetXml
3 | alias Sonex.SubHelpers
4 | alias Sonex.Network.State
5 |
6 | def init(req, _opts) do
7 | handle(req, %{})
8 | {:ok, req, :no_state}
9 | end
10 |
11 | def handle(request, state) do
12 | {:ok, data, _} = :cowboy_req.read_body(request, %{})
13 | sub_info_base = SubHelpers.create_sub_data(request, :av)
14 |
15 | clean_xml = SubHelpers.clean_xml_str(data)
16 | event_xml = xpath(clean_xml, ~x"//e:propertyset/e:property/LastChange/*[1]"e)
17 |
18 | transport_state = xpath(event_xml, ~x"//Event/InstanceID/TransportState/@val"s)
19 | {title, artist, album} = track_details(event_xml)
20 |
21 | player = %{player_state: player_state} = State.get_player(sub_info_base.from)
22 |
23 | new_state = %{
24 | player_state
25 | | current_state: transport_state,
26 | current_mode: xpath(event_xml, ~x"//Event/InstanceID/CurrentPlayMode/@val"s),
27 | current_track: xpath(event_xml, ~x"//Event/InstanceID/CurrentTrack/@val"i),
28 | total_tracks: xpath(event_xml, ~x"//Event/InstanceID/NumberOfTracks/@val"i),
29 | track_info: %{
30 | title: title,
31 | artist: artist,
32 | album: album,
33 | duration: xpath(event_xml, ~x"//Event/InstanceID/CurrentTrackDuration/@val"s)
34 | }
35 | }
36 |
37 | player = %{player | player_state: new_state}
38 | State.put_device(player)
39 |
40 | reply = :cowboy_req.reply(200, request)
41 |
42 | # handle/2 returns a tuple starting containing :ok, the reply, and the
43 | # current state of the handler.
44 | {:ok, reply, state}
45 | end
46 |
47 | defp track_details(event_xml_element) do
48 | xml_str = xpath(event_xml_element, ~x"//Event/InstanceID/CurrentTrackMetaData/@val"s)
49 |
50 | case(xml_str) do
51 | track when track == "" ->
52 | {nil, nil, nil}
53 |
54 | track ->
55 | cleaned_track = SubHelpers.clean_xml_str(track)
56 |
57 | {xpath(cleaned_track, ~x"//DIDL-Lite/item/dc:title/text()"s),
58 | xpath(cleaned_track, ~x"//DIDL-Lite/item/dc:creator/text()"s),
59 | xpath(cleaned_track, ~x"//DIDL-Lite/item/upnp:album/text()"s)}
60 | end
61 | end
62 |
63 | # Termination handler. Usually you don't do much with this. If things are breaking,
64 | # try uncommenting the output lines here to get some more info on what's happening.
65 | def terminate(_reason, _request, _state) do
66 | # IO.puts("Terminating for reason: #{inspect(reason)}")
67 | # IO.puts("Terminating after request: #{inspect(request)}")
68 | # IO.puts("Terminating with state: #{inspect(state)}")
69 | :ok
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/lib/sonex/subscriptions/sub_handler_dev.ex:
--------------------------------------------------------------------------------
1 | # not using this, getting enough from zone sub
2 | defmodule Sonex.SubHandlerDevice do
3 | # import SweetXml
4 | alias Sonex.SubHelpers
5 |
6 | def init(req, _opts) do
7 | req |> IO.inspect(label: "device req")
8 | handle(req, %{})
9 |
10 | {:ok, req, :no_state}
11 | end
12 |
13 | def handle(request, state) do
14 | {:ok, data, _} = :cowboy_req.read_body(request, %{})
15 |
16 | sub_info_base = SubHelpers.create_sub_data(request, :device)
17 |
18 | clean_xml = SubHelpers.clean_xml_str(data)
19 |
20 | # event_xml = xpath(clean_xml, ~x"//e:propertyset/e:property/LastChange/*[1]"e)
21 | # volume_state = xpath(event_xml, ~x"//Event/InstanceID/Volume/@val"il)
22 | # mute_info = xpath(event_xml, ~x"//Event/InstanceID/Mute/@val"il)
23 | # sub_info = %SubscriptionEvent{sub_info_base | content: sub_content_map}
24 |
25 | IO.inspect(sub_info_base)
26 |
27 | {:ok, reply} = :cowboy_req.reply(200, request)
28 |
29 | # handle/2 returns a tuple starting containing :ok, the reply, and the
30 | # current state of the handler.
31 | {:ok, reply, state}
32 | end
33 |
34 | # Termination handler. Usually you don't do much with this. If things are breaking,
35 | # try uncommenting the output lines here to get some more info on what's happening.
36 | def terminate(_reason, _request, _state) do
37 | # IO.puts("Terminating for reason: #{inspect(reason)}")
38 | # IO.puts("Terminating after request: #{inspect(request)}")
39 | # IO.puts("Terminating with state: #{inspect(state)}")
40 | :ok
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/sonex/subscriptions/sub_handler_render.ex:
--------------------------------------------------------------------------------
1 | defmodule Sonex.SubHandlerRender do
2 | import SweetXml
3 | alias Sonex.SubHelpers
4 | alias Sonex.Network.State
5 |
6 | def init(req, _opts) do
7 | handle(req, %{})
8 | {:ok, req, :no_state}
9 | end
10 |
11 | def handle(request, state) do
12 | {:ok, data, _} = :cowboy_req.read_body(request, %{})
13 |
14 | sub_info_base = SubHelpers.create_sub_data(request, :renderer)
15 |
16 | clean_xml = SubHelpers.clean_xml_str(data)
17 | event_xml = xpath(clean_xml, ~x"//e:propertyset/e:property/LastChange/*[1]"e)
18 |
19 | player = %{player_state: player_state} = State.get_player(sub_info_base.from)
20 |
21 | new_state =
22 | player_state
23 | |> get_volume(event_xml)
24 | |> get_mute(event_xml)
25 | |> get_bass(event_xml)
26 | |> get_treble(event_xml)
27 | |> get_loudness(event_xml)
28 |
29 | player = %{player | player_state: new_state}
30 | State.put_device(player)
31 |
32 | reply = :cowboy_req.reply(200, request)
33 | {:ok, reply, state}
34 | end
35 |
36 | defp get_volume(%PlayerState{} = p_state, xml) do
37 | case(xpath(xml, ~x"//Event/InstanceID/Volume"e)) do
38 | nil ->
39 | p_state
40 |
41 | _ ->
42 | [master_vol, left_vol, right_vol] = xpath(xml, ~x"//Event/InstanceID/Volume/@val"sl)
43 | %{p_state | volume: %{m: master_vol, l: left_vol, r: right_vol}}
44 | end
45 | end
46 |
47 | defp get_mute(%PlayerState{} = p_state, xml) do
48 | case(xpath(xml, ~x"//Event/InstanceID/Mute"e)) do
49 | nil ->
50 | p_state
51 |
52 | _ ->
53 | [master_m, _, _] = xpath(xml, ~x"//Event/InstanceID/Mute/@val"sl)
54 |
55 | %{p_state | mute: master_m == "1"}
56 | end
57 | end
58 |
59 | defp get_treble(%PlayerState{} = p_state, xml) do
60 | case(xpath(xml, ~x"//Event/InstanceID/Treble"e)) do
61 | nil ->
62 | p_state
63 |
64 | _ ->
65 | %{p_state | treble: xpath(xml, ~x"//Event/InstanceID/Treble/@val"i)}
66 | end
67 | end
68 |
69 | defp get_bass(%PlayerState{} = p_state, xml) do
70 | case(xpath(xml, ~x"//Event/InstanceID/Bass"e)) do
71 | nil ->
72 | p_state
73 |
74 | _ ->
75 | %{p_state | bass: xpath(xml, ~x"//Event/InstanceID/Bass/@val"i)}
76 | end
77 | end
78 |
79 | defp get_loudness(%PlayerState{} = p_state, xml) do
80 | case(xpath(xml, ~x"//Event/InstanceID/Loudness"e)) do
81 | nil ->
82 | p_state
83 |
84 | _ ->
85 | loudness = xpath(xml, ~x"//Event/InstanceID/Loudness/@val"s)
86 | %{p_state | loudness: loudness == "1"}
87 | end
88 | end
89 |
90 | # Termination handler. Usually you don't do much with this. If things are breaking,
91 | # try uncommenting the output lines here to get some more info on what's happening.
92 | def terminate(_reason, _request, _state) do
93 | # IO.puts("Render Terminating for reason: #{inspect(reason)}")
94 | # IO.puts("Terminating after request: #{inspect(request)}")
95 | # IO.puts("Terminating with state: #{inspect(state)}")
96 | :ok
97 | end
98 | end
99 |
--------------------------------------------------------------------------------
/lib/sonex/subscriptions/sub_handler_zone.ex:
--------------------------------------------------------------------------------
1 | defmodule Sonex.SubHandlerZone do
2 | import SweetXml
3 |
4 | alias Sonex.SubHelpers
5 | alias Sonex.Network.State
6 |
7 | def init(req, _opts) do
8 | handle(req, %{})
9 |
10 | {:ok, req, :no_state}
11 | end
12 |
13 | def handle(request, state) do
14 | {:ok, data, _} = :cowboy_req.read_body(request, %{})
15 |
16 | clean_xml = SubHelpers.clean_xml_str(data)
17 |
18 | zone_info =
19 | xpath(clean_xml, ~x"//ZoneGroups/ZoneGroup"l,
20 | coordinator_uuid: ~x"//./@Coordinator"s,
21 | members: [
22 | ~x"//./ZoneGroup/ZoneGroupMember"el,
23 | name: ~x"//./@ZoneName"s,
24 | uuid: ~x"//./@UUID"s,
25 | addr: ~x"//./@Location"s,
26 | config: ~x"//./@Configuration"i,
27 | icon: ~x"//./@Icon"s
28 | ]
29 | )
30 |
31 | Enum.each(zone_info, fn zone_group ->
32 | Enum.each(zone_group.members, fn member ->
33 | player = State.get_player(member.uuid)
34 | player = %{player | coordinator_uuid: zone_group.coordinator_uuid, name: member.name}
35 | State.put_device(player)
36 | end)
37 | end)
38 |
39 | reply = :cowboy_req.reply(200, request)
40 |
41 | # handle/2 returns a tuple starting containing :ok, the reply, and the
42 | # current state of the handler.
43 | {:ok, reply, state}
44 | end
45 |
46 | # Termination handler. Usually you don't do much with this. If things are breaking,
47 | # try uncommenting the output lines here to get some more info on what's happening.
48 | def terminate(_reason, _request, _state) do
49 | # IO.puts("Terminating for reason: #{inspect(reason)}")
50 | # IO.puts("Terminating after request: #{inspect(request)}")
51 | # IO.puts("Terminating with state: #{inspect(state)}")
52 | :ok
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/lib/sonex/subscriptions/sub_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule Sonex.SubHelpers do
2 | def create_sub_data(%{headers: %{"sid" => sid, "seq" => seq}} = _request, type) do
3 | "uuid:" <> uuid_raw = sid
4 | [header, main, _sub_id] = String.split(uuid_raw, "_")
5 | from_id = header <> "_" <> main
6 | %SubscriptionEvent{from: from_id, seq_num: seq, sub_id: uuid_raw, type: type}
7 | end
8 |
9 | def create_sub_data(request, _type) do
10 | IO.inspect(request, label: "requestrequestrequestrequestrequest")
11 | end
12 |
13 | def clean_xml_str(xml) do
14 | _cleaned_xml =
15 | xml
16 | |> clean_resp("<", "<")
17 | |> clean_resp(">", ">")
18 | |> clean_resp(""", "\"")
19 | end
20 |
21 | def clean_resp(resp, to_clean, replace_with) do
22 | String.replace(resp, to_clean, replace_with)
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/sonex/subscriptions/sub_mngr.ex:
--------------------------------------------------------------------------------
1 | defmodule Sonex.SubMngr do
2 | use GenServer
3 |
4 | @timeout 3_600_000
5 | @resub @timeout - 60000
6 |
7 | def start_link() do
8 | GenServer.start_link(__MODULE__, %{handler: nil, subs: [], port: 8700}, name: __MODULE__)
9 | end
10 |
11 | def init(state) do
12 | dispatch =
13 | :cowboy_router.compile([
14 | {:_,
15 | [
16 | {"/ZoneGroupTopology", Sonex.SubHandlerZone, []},
17 | {"/RenderingControl", Sonex.SubHandlerRender, []},
18 | {"/AVTransport", Sonex.SubHandlerAV, []},
19 | {"/DeviceProperties", Sonex.SubHandlerDevice, []}
20 | ]}
21 | ])
22 |
23 | {:ok, handler} =
24 | :cowboy.start_clear(
25 | :http,
26 | [{:port, state.port}],
27 | %{env: %{dispatch: dispatch}}
28 | )
29 |
30 | # IO.inspect Sonex.Discovery.players
31 | # {:ok, sub_interval } = :timer.apply_interval(5000, IO, :inspect, ["HI from interval"] )
32 | # IO.inspect handler
33 | {:ok, %{state | handler: handler}}
34 | end
35 |
36 | def subscribe(device, service) do
37 | GenServer.call(__MODULE__, {:subscribe, device, service})
38 | end
39 |
40 | def protocol_opts() do
41 | GenServer.call(__MODULE__, :protocol_opts)
42 | end
43 |
44 | def handle_call(:protocol_opts, _from, state) do
45 | {:reply, :ok, state}
46 | end
47 |
48 | def handle_call({:subscribe, device, service}, _from, state) do
49 | {:ok, sub_id} = subscribe_req(device, service, state.port)
50 |
51 | # resubscribe 1 minute before sub timesout
52 | {:ok, timer} = :timer.send_interval(@resub, {:sub_interval, device, service})
53 |
54 | {:reply, sub_id,
55 | %{
56 | state
57 | | subs: [
58 | %{timer: timer, name: device.name, sub_id: sub_id, type: service.event} | state.subs
59 | ]
60 | }}
61 | end
62 |
63 | def handle_info({:sub_interval, device, service}, state) do
64 | subscribe_req(device, service, state.port)
65 | {:noreply, state}
66 | end
67 |
68 | def handle_info(data, state) do
69 | IO.inspect(data, label: "unknown handle_info")
70 | {:noreply, state}
71 | end
72 |
73 | defp subscribe_req(%SonosDevice{} = device, service, port) do
74 | uri = "http://#{device.ip}:1400#{service.event}"
75 | req_headers = sub_headers(port, service)
76 |
77 | HTTPoison.request!(:subscribe, uri, "", req_headers)
78 | |> handle_sub_response()
79 | end
80 |
81 | defp subscribe_req(device, service, port) do
82 | uri = "http://#{device.info.ip}:1400#{service.event}"
83 | req_headers = sub_headers(port, service)
84 |
85 | HTTPoison.request!(:subscribe, uri, "", req_headers)
86 | |> handle_sub_response()
87 | end
88 |
89 | defp sub_headers(port, serv) do
90 | {:ok, {a, b, c, d}} = Sonex.Discovery.get_ip()
91 |
92 | cb_uri =
93 | case(serv) do
94 | %{type: "urn:schemas-upnp-org:service:ZoneGroupTopology:1"} ->
95 | ""
96 |
97 | %{type: "urn:schemas-upnp-org:service:RenderingControl:1"} ->
98 | ""
99 |
100 | %{type: "urn:schemas-upnp-org:service:AVTransport:1"} ->
101 | ""
102 |
103 | %{type: "urn:schemas-upnp-org:service:DeviceProperties:1"} ->
104 | ""
105 | end
106 |
107 | %{
108 | "CALLBACK" => cb_uri,
109 | "NT" => "upnp:event",
110 | "TIMEOUT" => "Second-#{div(@timeout, 1000)}",
111 | "Content-Type" => "text/xml; charset=\"utf-8\""
112 | }
113 | end
114 |
115 | defp handle_sub_response(resp) do
116 | case(resp) do
117 | %HTTPoison.Response{status_code: 200, body: _res_body, headers: headers} ->
118 | header_map = Map.new(headers)
119 | "uuid:" <> sub_id = header_map["SID"]
120 | {:ok, sub_id}
121 |
122 | %HTTPoison.Response{status_code: 500, body: err_body} ->
123 | {:error, Sonex.SOAP.parse_soap_error(err_body)}
124 | end
125 | end
126 | end
127 |
--------------------------------------------------------------------------------
/lib/sonex/types/player_state.ex:
--------------------------------------------------------------------------------
1 | defmodule PlayerState do
2 | @moduledoc """
3 |
4 | """
5 |
6 | defstruct volume: nil,
7 | mute: nil,
8 | bass: nil,
9 | treble: nil,
10 | loudness: nil,
11 | track_info: nil,
12 | track_number: nil,
13 | total_tracks: nil,
14 | current_state: nil,
15 | current_track: nil,
16 | current_mode: nil
17 |
18 | @type t :: %__MODULE__{
19 | volume: map,
20 | mute: boolean,
21 | bass: integer,
22 | treble: integer,
23 | loudness: boolean,
24 | track_info: map,
25 | track_number: integer,
26 | total_tracks: integer,
27 | current_state: String.t(),
28 | current_track: String.t(),
29 | current_mode: String.t()
30 | }
31 | end
32 |
--------------------------------------------------------------------------------
/lib/sonex/types/sonoes_device.ex:
--------------------------------------------------------------------------------
1 | defmodule SonosDevice do
2 | defstruct ip: nil,
3 | model: nil,
4 | uuid: nil,
5 | household: nil,
6 | name: nil,
7 | config: nil,
8 | icon: nil,
9 | version: nil,
10 | coordinator_uuid: nil,
11 | coordinator_pid: nil,
12 | info: nil,
13 | player_state: %PlayerState{}
14 |
15 | @type t :: %__MODULE__{
16 | ip: String.t(),
17 | model: String.t(),
18 | uuid: String.t(),
19 | household: String.t(),
20 | name: String.t(),
21 | config: integer,
22 | icon: String.t(),
23 | version: String.t(),
24 | coordinator_uuid: String.t(),
25 | coordinator_pid: reference,
26 | info: String.t(),
27 | player_state: PlayerState.t()
28 | }
29 | end
30 |
--------------------------------------------------------------------------------
/lib/sonex/types/subscription_event.ex:
--------------------------------------------------------------------------------
1 | defmodule SubscriptionEvent do
2 | defstruct type: nil, sub_id: nil, from: nil, seq_num: nil, content: nil
3 |
4 | @type t :: %__MODULE__{
5 | type: String.t(),
6 | sub_id: String.t(),
7 | from: String.t(),
8 | seq_num: integer,
9 | content: map
10 | }
11 | end
12 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Sonex.Mixfile do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :sonex,
7 | version: "0.0.1",
8 | elixir: "~> 1.2",
9 | build_embedded: Mix.env() == :prod,
10 | start_permanent: Mix.env() == :prod,
11 | deps: deps()
12 | ]
13 | end
14 |
15 | # Configuration for the OTP application
16 | #
17 | # Type "mix help compile.app" for more information
18 | def application do
19 | [applications: [:logger, :httpoison, :cowboy, :ranch], mod: {Sonex.Application, []}]
20 | end
21 |
22 | # Dependencies can be Hex packages:
23 | #
24 | # {:mydep, "~> 0.3.0"}
25 | #
26 | # Or git/path repositories:
27 | #
28 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"}
29 | #
30 | # Type "mix help deps" for more examples and options
31 | defp deps do
32 | [{:httpoison, "~> 1.6"}, {:sweet_xml, "~> 0.6"}, {:cowboy, "~> 2.6"}]
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "cauldron": {:hex, :cauldron, "0.1.5"},
3 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
4 | "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
5 | "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm"},
6 | "exts": {:hex, :exts, "0.2.2"},
7 | "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
8 | "httpoison": {:hex, :httpoison, "1.6.1", "2ce5bf6e535cd0ab02e905ba8c276580bab80052c5c549f53ddea52d72e81f33", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
9 | "httprot": {:hex, :httprot, "0.1.6"},
10 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
11 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []},
12 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
13 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
14 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
15 | "reagent": {:hex, :reagent, "0.1.5"},
16 | "socket": {:hex, :socket, "0.2.8"},
17 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"},
18 | "sweet_xml": {:hex, :sweet_xml, "0.6.1", "a56f235171f35a32807ce44798dedc748ce249fca574674fecd29c1321cab0de", [:mix], []},
19 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"},
20 | }
21 |
--------------------------------------------------------------------------------
/test/sonex_test.exs:
--------------------------------------------------------------------------------
1 | defmodule SonexTest do
2 | use ExUnit.Case
3 | doctest Sonex
4 |
5 | alias Sonex.Network.State
6 |
7 | setup do
8 | # wait for discovery before running tests
9 | :timer.sleep(175)
10 | end
11 |
12 | test "discovery" do
13 | players = State.players()
14 | assert Enum.count(players) > 0
15 | end
16 |
17 | test "error messages - Invalid Action" do
18 | players = State.players()
19 | a_player = List.first(players)
20 | {:error, err_msg} = Sonex.SOAP.build(:device, "badReq") |> Sonex.SOAP.post(a_player)
21 | assert err_msg == "Invalid Action"
22 | end
23 |
24 | test "error messages - Invalid Arg" do
25 | players = State.players() |> IO.inspect(label: "players")
26 | a_player = List.first(players)
27 |
28 | {:error, err_msg} =
29 | Sonex.SOAP.build(:device, "SetLEDState", [["badArg", "Off"]]) |> Sonex.SOAP.post(a_player)
30 |
31 | assert err_msg == "Invalid Args"
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------