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 +7 -0
- data/.gitignore +35 -0
- data/Gemfile +6 -0
- data/LICENSE +22 -0
- data/README.md +2 -0
- data/easy_upnp.gemspec +32 -0
- data/lib/easy_upnp/device_control_point.rb +78 -0
- data/lib/easy_upnp/ssdp_searcher.rb +127 -0
- data/lib/easy_upnp/upnp_device.rb +77 -0
- data/lib/easy_upnp/version.rb +3 -0
- data/lib/easy_upnp.rb +3 -0
- metadata +122 -0
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
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
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
|
data/lib/easy_upnp.rb
ADDED
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: []
|