easy_upnp 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []