easy_upnp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ !binary "U0hBMQ==":
3
+ metadata.gz: 58750344e60c3c1ccba0ec054ea0a8e1707b70b1
4
+ data.tar.gz: d73392dec5230c617f2f1cca4596e9950fd67a79
5
+ !binary "U0hBNTEy":
6
+ metadata.gz: c9f9def01cb5147d365b7edebe1cb1f27efc55c3f039f2e17111417f6ecfbab1e6104e05cbebf4f63dfa4f0ade3785874669a1751f9e29ce0c581daba6011648
7
+ data.tar.gz: e2ae7f65a0616476d9e8c593eb753d5b766372e62a03e18309f4f30de87b7d361940b1ab62c09c0e86ac65a61edad6c689ce9f3bb7fbafedc6a2fe4d019fc234
data/.gitignore ADDED
@@ -0,0 +1,35 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /test/tmp/
9
+ /test/version_tmp/
10
+ /tmp/
11
+
12
+ ## Specific to RubyMotion:
13
+ .dat*
14
+ .repl_history
15
+ build/
16
+
17
+ ## Documentation cache and generated files:
18
+ /.yardoc/
19
+ /_yardoc/
20
+ /doc/
21
+ /rdoc/
22
+
23
+ ## Environment normalisation:
24
+ /.bundle/
25
+ /vendor/bundle
26
+ /lib/bundler/man/
27
+
28
+ # for a library or gem, you might want to ignore these files since the code is
29
+ # intended to run in multiple environments; otherwise, check them in:
30
+ Gemfile.lock
31
+ .ruby-version
32
+ .ruby-gemset
33
+
34
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
35
+ .rvmrc
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ gem 'savon', '~> 2.11.1'
5
+ gem 'nori', '~> 2.6.0'
6
+ gem 'nokogiri', '~> 1.6.6.2'
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Chris Mullins
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
+
data/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # easy-upnp
2
+ A super simple UPnP control point client for Ruby
data/easy_upnp.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ $:.push File.expand_path('../lib', __FILE__)
2
+
3
+ require "easy_upnp/version"
4
+
5
+ Gem::Specification.new do |gem|
6
+ gem.name = 'easy_upnp'
7
+ gem.version = EasyUpnp::VERSION
8
+
9
+ gem.summary = "A super easy to use UPnP control point client"
10
+
11
+ gem.authors = ['Christopher Mullins']
12
+ gem.email = 'chris@sidoh.org'
13
+ gem.homepage = 'http://github.com/sidoh/easy-upnp'
14
+
15
+ gem.add_dependency 'rake'
16
+ gem.add_dependency 'savon', '~> 2.11.1'
17
+ gem.add_dependency 'nori', '~> 2.6.0'
18
+ gem.add_dependency 'nokogiri', '~> 1.6.6.2'
19
+
20
+ gem.add_development_dependency('rspec', [">= 2.0.0"])
21
+
22
+ ignores = File.readlines(".gitignore").grep(/\S+/).map(&:chomp)
23
+ dotfiles = %w[.gitignore]
24
+
25
+ all_files_without_ignores = Dir["**/*"].reject { |f|
26
+ File.directory?(f) || ignores.any? { |i| File.fnmatch(i, f) }
27
+ }
28
+
29
+ gem.files = (all_files_without_ignores + dotfiles).sort
30
+
31
+ gem.require_path = "lib"
32
+ end
@@ -0,0 +1,78 @@
1
+ require 'nokogiri'
2
+ require 'open-uri'
3
+ require 'nori'
4
+
5
+ module EasyUpnp
6
+ class DeviceControlPoint
7
+ def initialize client, service_type, definition_url
8
+ @client = client
9
+ @service_type = service_type
10
+
11
+ definition = Nokogiri::XML(open(definition_url))
12
+ definition.remove_namespaces!
13
+
14
+ definition.xpath('//actionList/action').map do |action|
15
+ define_action action
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def define_action action
22
+ action = Nori.new.parse(action.to_xml)['action']
23
+ action_name = action['name']
24
+ args = action['argumentList']['argument']
25
+ args = [args] unless args.is_a? Array
26
+
27
+ input_args = args.
28
+ reject { |x| x['direction'] != 'in' }.
29
+ map { |x| x['name'].to_sym }
30
+ output_args = args.
31
+ reject { |x| x['direction'] != 'out' }.
32
+ map { |x| x['name'].to_sym }
33
+
34
+ define_singleton_method(action['name']) do |args_hash|
35
+ if (args_hash.keys - input_args).any?
36
+ raise RuntimeError.new "Unsupported arguments: #{(args_hash.keys - input_args)}." <<
37
+ " Supported args: #{input_args}"
38
+ end
39
+
40
+ attrs = {
41
+ soap_action: "#{@service_type}##{action_name}",
42
+ attributes: {
43
+ :'xmlns:u' => @service_type
44
+ }
45
+ }
46
+
47
+ response = @client.call action['name'], attrs do
48
+ message(args_hash)
49
+ end
50
+
51
+ # Response is usually wrapped in <#{ActionName}Response></>. For example:
52
+ # <BrowseResponse>...</BrowseResponse>. Extract the body since consumers
53
+ # won't care about wrapper stuff.
54
+ if response.body.keys.count > 1
55
+ raise RuntimeError.new "Unexpected keys in response body: #{response.body.keys}"
56
+ end
57
+ result = response.body.first[1]
58
+ output = {}
59
+
60
+ # Keys returned by savon are underscore style. Convert them to camelcase.
61
+ output_args.map do |arg|
62
+ output[arg] = result[underscore(arg.to_s).to_sym]
63
+ end
64
+
65
+ output
66
+ end
67
+ end
68
+
69
+ # This is included in ActiveSupport, but don't want to pull that in for just this method...
70
+ def underscore s
71
+ s.gsub(/::/, '/').
72
+ gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').
73
+ gsub(/([a-z\d])([A-Z])/, '\1_\2').
74
+ tr("-", "_").
75
+ downcase
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,127 @@
1
+ require 'socket'
2
+ require 'ipaddr'
3
+ require 'timeout'
4
+ require_relative 'upnp_device'
5
+
6
+ module EasyUpnp
7
+ class SsdpSearcher
8
+ # These are dictated by the SSDP protocol and cannot be changed
9
+ MULTICAST_ADDR = '239.255.255.250'
10
+ MULTICAST_PORT = 1900
11
+
12
+ DEFAULT_OPTIONS = {
13
+ :bind_addr => '0.0.0.0',
14
+
15
+ # Number of seconds to wait for responses
16
+ :timeout => 2,
17
+
18
+ # Part of the SSDP protocol. Servers should delay a random amount of time between 0 and N
19
+ # seconds before sending the response.
20
+ :mx => 1,
21
+
22
+ # Sometimes recommended to send repeat M-SEARCH queries. Control that here.
23
+ :repeat_queries => 1
24
+ }
25
+
26
+ def initialize(options = {})
27
+ unsupported_args = options.keys.reject { |x| DEFAULT_OPTIONS[x] }
28
+ raise RuntimeError.new "Unsupported arguments: #{unsupported_args}" if unsupported_args.any?
29
+
30
+ @options = DEFAULT_OPTIONS.merge options
31
+ end
32
+
33
+ def option key
34
+ @options[key]
35
+ end
36
+
37
+ def search urn
38
+ listen_socket = build_listen_socket
39
+ send_socket = build_send_socket
40
+ packet = construct_msearch_packet(urn)
41
+
42
+ # Send M-SEARCH packet over UDP socket
43
+ option(:repeat_queries).times do
44
+ send_socket.send packet, 0, MULTICAST_ADDR, MULTICAST_PORT
45
+ end
46
+
47
+ raw_messages = []
48
+
49
+ # Wait for responses. Timeout after a specified number of seconds
50
+ begin
51
+ Timeout::timeout(option :timeout) do
52
+ loop do
53
+ raw_messages.push send_socket.recv(4196)
54
+ end
55
+ end
56
+ rescue Timeout::Error
57
+ # This is expected
58
+ ensure
59
+ send_socket.close
60
+ listen_socket.close
61
+ end
62
+
63
+ # Parse messages (extract HTTP headers)
64
+ parsed_messages = raw_messages.map { |x| parse_message x }
65
+
66
+ # Group messages by device they come from (identified by a UUID in the 'USN' header),
67
+ # and create UpnpDevices for them. This wrap the services advertized by the SSDP
68
+ # results.
69
+ parsed_messages.reject { |x| !x[:usn] }.group_by { |x| x[:usn].split('::').first }.map do |k, v|
70
+ UpnpDevice.new k, v
71
+ end
72
+ end
73
+
74
+ def construct_msearch_packet(urn)
75
+ <<-MSEARCH
76
+ M-SEARCH * HTTP/1.1\r
77
+ HOST: #{MULTICAST_ADDR}:#{MULTICAST_PORT}\r
78
+ MAN: "ssdp:discover"\r
79
+ MX: #{option :mx}\r
80
+ ST: #{urn}\r
81
+ \r
82
+ MSEARCH
83
+ end
84
+
85
+ def parse_message message
86
+ lines = message.split "\r\n"
87
+ headers = lines[1...-1].map do |line|
88
+ header, value = line.match(/([^:]+):\s?(.*)/i).captures
89
+
90
+ key = header.
91
+ downcase.
92
+ gsub('-', '_').
93
+ to_sym
94
+
95
+ [key, value]
96
+ end
97
+ Hash[headers]
98
+ end
99
+
100
+ private
101
+
102
+ def build_listen_socket
103
+ socket = UDPSocket.new
104
+ socket.do_not_reverse_lookup = true
105
+
106
+ membership = IPAddr.new(MULTICAST_ADDR).hton + IPAddr.new(option :bind_addr).hton
107
+
108
+ socket.setsockopt(:IPPROTO_IP, :IP_ADD_MEMBERSHIP, membership)
109
+ socket.setsockopt(:SOL_SOCKET, :SO_REUSEADDR, true)
110
+ socket.setsockopt(:IPPROTO_IP, :IP_TTL, 1)
111
+
112
+ socket.bind(option(:bind_addr), MULTICAST_PORT)
113
+
114
+ socket
115
+ end
116
+
117
+ def build_send_socket
118
+ socket = UDPSocket.open
119
+ socket.do_not_reverse_lookup = true
120
+
121
+ socket.setsockopt(:IPPROTO_IP, :IP_MULTICAST_TTL, true)
122
+ socket.setsockopt(:SOL_SOCKET, :SO_REUSEADDR, true)
123
+
124
+ socket
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,77 @@
1
+ require 'uri'
2
+ require 'nokogiri'
3
+ require 'open-uri'
4
+ require 'savon'
5
+
6
+ require_relative 'device_control_point'
7
+
8
+ module EasyUpnp
9
+ class UpnpDevice
10
+ attr_reader :uuid
11
+
12
+ def initialize uuid, messages
13
+ @uuid = uuid
14
+
15
+ @service_definitions = messages.
16
+ # Filter out messages that aren't service definitions. These include
17
+ # the root device and the root UUID
18
+ reject { |message| not message[:st].include? ':service:' }.
19
+ # Distinct by ST header -- might have repeats if we sent multiple
20
+ # M-SEARCH packets
21
+ group_by { |message| message[:st] }.
22
+ map { |_, matching_messages| matching_messages.first }.
23
+ map do |message|
24
+ {
25
+ :location => message[:location],
26
+ :st => message[:st]
27
+ }
28
+ end
29
+ end
30
+
31
+ def has_service?(urn)
32
+ !service_definition(urn).nil?
33
+ end
34
+
35
+ def service(urn)
36
+ definition = service_definition(urn)
37
+
38
+ if !definition.nil?
39
+ root_uri = definition[:location]
40
+ xml = Nokogiri::XML(open(root_uri))
41
+ xml.remove_namespaces!
42
+
43
+ service = xml.xpath("//device/serviceList/service[serviceType=\"#{urn}\"]").first
44
+
45
+ if service.nil?
46
+ raise RuntimeError.new "Couldn't find service with urn: #{urn}"
47
+ else
48
+ service = Nokogiri::XML(service.to_xml)
49
+ wsdl = URI.join(root_uri, service.xpath('service/SCPDURL').text).to_s
50
+
51
+ client = Savon.client do |c|
52
+ c.endpoint URI.join(root_uri, service.xpath('service/controlURL').text).to_s
53
+
54
+ c.namespace urn
55
+
56
+ # I found this was necessary on some of my UPnP devices (namely, a Sony TV).
57
+ c.namespaces({:'s:encodingStyle' => "http://schemas.xmlsoap.org/soap/encoding/"})
58
+
59
+ # This makes XML tags be like <ObjectID> instead of <objectID>.
60
+ c.convert_request_keys_to :camelcase
61
+
62
+ c.namespace_identifier :u
63
+ c.env_namespace :s
64
+ end
65
+
66
+ DeviceControlPoint.new client, urn, wsdl
67
+ end
68
+ end
69
+ end
70
+
71
+ def service_definition(urn)
72
+ @service_definitions.
73
+ reject { |s| s[:st] == urn }.
74
+ first
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,3 @@
1
+ module EasyUpnp
2
+ VERSION = '0.1.0'
3
+ end
data/lib/easy_upnp.rb ADDED
@@ -0,0 +1,3 @@
1
+ module EasyUpnp
2
+ require 'easy_upnp'
3
+ end
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: easy_upnp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Christopher Mullins
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-06-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: savon
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 2.11.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 2.11.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: nori
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 2.6.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 2.6.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: nokogiri
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 1.6.6.2
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.6.6.2
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 2.0.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 2.0.0
83
+ description:
84
+ email: chris@sidoh.org
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - ".gitignore"
90
+ - Gemfile
91
+ - LICENSE
92
+ - README.md
93
+ - easy_upnp.gemspec
94
+ - lib/easy_upnp.rb
95
+ - lib/easy_upnp/device_control_point.rb
96
+ - lib/easy_upnp/ssdp_searcher.rb
97
+ - lib/easy_upnp/upnp_device.rb
98
+ - lib/easy_upnp/version.rb
99
+ homepage: http://github.com/sidoh/easy-upnp
100
+ licenses: []
101
+ metadata: {}
102
+ post_install_message:
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubyforge_project:
118
+ rubygems_version: 2.0.0.preview3.1
119
+ signing_key:
120
+ specification_version: 4
121
+ summary: A super easy to use UPnP control point client
122
+ test_files: []