├── .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 %>> 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 | --------------------------------------------------------------------------------