playful 0.1.0.alpha.1

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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/.gemtest +0 -0
  3. data/.gitignore +19 -0
  4. data/.rspec +1 -0
  5. data/.travis.yml +5 -0
  6. data/Gemfile +6 -0
  7. data/History.rdoc +3 -0
  8. data/LICENSE.rdoc +22 -0
  9. data/README.rdoc +194 -0
  10. data/Rakefile +20 -0
  11. data/features/control_point.feature +13 -0
  12. data/features/device.feature +22 -0
  13. data/features/device_discovery.feature +9 -0
  14. data/features/step_definitions/control_point_steps.rb +19 -0
  15. data/features/step_definitions/device_discovery_steps.rb +40 -0
  16. data/features/step_definitions/device_steps.rb +28 -0
  17. data/features/support/common.rb +9 -0
  18. data/features/support/env.rb +17 -0
  19. data/features/support/fake_upnp_device_collection.rb +108 -0
  20. data/features/support/world_extensions.rb +15 -0
  21. data/lib/core_ext/hash_patch.rb +5 -0
  22. data/lib/core_ext/socket_patch.rb +16 -0
  23. data/lib/core_ext/to_upnp_s.rb +65 -0
  24. data/lib/playful.rb +5 -0
  25. data/lib/playful/control_point.rb +175 -0
  26. data/lib/playful/control_point/base.rb +74 -0
  27. data/lib/playful/control_point/device.rb +511 -0
  28. data/lib/playful/control_point/error.rb +13 -0
  29. data/lib/playful/control_point/service.rb +404 -0
  30. data/lib/playful/device.rb +28 -0
  31. data/lib/playful/logger.rb +8 -0
  32. data/lib/playful/ssdp.rb +195 -0
  33. data/lib/playful/ssdp/broadcast_searcher.rb +114 -0
  34. data/lib/playful/ssdp/error.rb +6 -0
  35. data/lib/playful/ssdp/listener.rb +38 -0
  36. data/lib/playful/ssdp/multicast_connection.rb +112 -0
  37. data/lib/playful/ssdp/network_constants.rb +17 -0
  38. data/lib/playful/ssdp/notifier.rb +41 -0
  39. data/lib/playful/ssdp/searcher.rb +87 -0
  40. data/lib/playful/version.rb +3 -0
  41. data/lib/rack/upnp_control_point.rb +70 -0
  42. data/playful.gemspec +38 -0
  43. data/spec/spec_helper.rb +16 -0
  44. data/spec/support/search_responses.rb +134 -0
  45. data/spec/unit/core_ext/to_upnp_s_spec.rb +105 -0
  46. data/spec/unit/playful/control_point/device_spec.rb +7 -0
  47. data/spec/unit/playful/control_point_spec.rb +45 -0
  48. data/spec/unit/playful/ssdp/listener_spec.rb +29 -0
  49. data/spec/unit/playful/ssdp/multicast_connection_spec.rb +157 -0
  50. data/spec/unit/playful/ssdp/notifier_spec.rb +76 -0
  51. data/spec/unit/playful/ssdp/searcher_spec.rb +110 -0
  52. data/spec/unit/playful/ssdp_spec.rb +214 -0
  53. data/tasks/control_point.html +30 -0
  54. data/tasks/control_point.thor +43 -0
  55. data/tasks/search.thor +128 -0
  56. data/tasks/test_js/FABridge.js +1425 -0
  57. data/tasks/test_js/WebSocketMain.swf +807 -0
  58. data/tasks/test_js/swfobject.js +825 -0
  59. data/tasks/test_js/web_socket.js +1133 -0
  60. data/test/test_ssdp.rb +298 -0
  61. data/test/test_ssdp_notification.rb +74 -0
  62. data/test/test_ssdp_response.rb +31 -0
  63. data/test/test_ssdp_search.rb +23 -0
  64. metadata +339 -0
@@ -0,0 +1,17 @@
1
+ require 'socket'
2
+ require 'log_buddy'
3
+ require_relative '../../lib/playful/control_point'
4
+
5
+ def local_ip_and_port
6
+ orig, Socket.do_not_reverse_lookup = Socket.do_not_reverse_lookup, true
7
+
8
+ UDPSocket.open do |s|
9
+ s.connect '64.233.187.99', 1
10
+ s.addr.last
11
+ [s.addr.last, s.addr[1]]
12
+ end
13
+ ensure
14
+ Socket.do_not_reverse_lookup = orig
15
+ end
16
+
17
+ ENV['RUBY_UPNP_ENV'] = 'testing'
@@ -0,0 +1,108 @@
1
+ require 'singleton'
2
+ require 'socket'
3
+ require 'ipaddr'
4
+
5
+ require_relative '../../lib/playful/ssdp/network_constants'
6
+
7
+ class FakeUPnPDeviceCollection
8
+ include Singleton
9
+ include UPnP::SSDP::NetworkConstants
10
+
11
+ attr_accessor :respond_with
12
+
13
+ def initialize
14
+ @response = ''
15
+ @ssdp_listen_thread = nil
16
+ @serve_description = false
17
+ @local_ip, @local_port = local_ip_and_port
18
+ end
19
+
20
+ def expect_discovery(type)
21
+ case type
22
+ when :m_search
23
+
24
+ end
25
+
26
+ end
27
+
28
+ def stop_ssdp_listening
29
+ puts "<#{self.class}> Stopping..."
30
+ @ssdp_listen_thread.kill if @ssdp_listen_thread && @ssdp_listen_thread.alive?
31
+ puts "<#{self.class}> Stopped."
32
+ end
33
+
34
+ # @return [Thread] The thread that's doing the listening.
35
+ def start_ssdp_listening
36
+ multicast_socket = setup_multicast_socket
37
+ multicast_socket.bind(MULTICAST_IP, MULTICAST_PORT)
38
+
39
+ ttl = [4].pack 'i'
40
+ unicast_socket = UDPSocket.open
41
+ unicast_socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_TTL, ttl)
42
+
43
+ @ssdp_listen_thread = Thread.new do
44
+ loop do
45
+ text, sender = multicast_socket.recvfrom(1024)
46
+ #puts "<#{self.class}> received text:\n#{text} from #{sender}"
47
+
48
+ #if text =~ /ST: upnp:rootdevice/
49
+ #if text =~ /#{@response}/m
50
+ if text =~ /M-SEARCH.*#{@local_ip}/m
51
+ puts "<#{self.class}> received text:\n#{text} from #{sender}"
52
+ return_port, return_ip = sender[1], sender[2]
53
+
54
+ puts "<#{self.class}> sending response\n#{@response}\n back to: #{return_ip}:#{return_port}"
55
+ unicast_socket.send(@response, 0, return_ip, return_port)
56
+ #multicast_socket.close
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ def start_serving_description
63
+ =begin
64
+ tcp_server = TCPServer.new('0.0.0.0', 4567)
65
+ @serve_description = true
66
+
67
+ while @serve_description
68
+ @description_serve_thread = Thread.start(tcp_server.accept) do |s|
69
+ print(s, " is accepted\n")
70
+ s.write(Time.now)
71
+ print(s, " is gone\n")
72
+ s.close
73
+ end
74
+ end
75
+ =end
76
+ require 'webrick'
77
+ @description_server = WEBrick::HTTPServer.new(Port: 4567)
78
+ trap('INT') { @description_server.shutdown }
79
+ @description_server.mount_proc '/' do |req, res|
80
+ res.body = "<start>\n</start>"
81
+ end
82
+ @description_server.start
83
+ end
84
+
85
+ def stop_serving_description
86
+ =begin
87
+ @serve_description = false
88
+
89
+ if @description_serve_thread && @description_serve_thread.alive?
90
+ @description_serve_thread.join
91
+ end
92
+ =end
93
+ @description_server.stop
94
+ end
95
+
96
+ def setup_multicast_socket
97
+ membership = IPAddr.new(MULTICAST_IP).hton + IPAddr.new('0.0.0.0').hton
98
+ ttl = [4].pack 'i'
99
+
100
+ socket = UDPSocket.new
101
+ socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_ADD_MEMBERSHIP, membership)
102
+ socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_LOOP, "\000")
103
+ socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_TTL, ttl)
104
+ socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_TTL, ttl)
105
+
106
+ socket
107
+ end
108
+ end
@@ -0,0 +1,15 @@
1
+ module HelperStuff
2
+ def control_point
3
+ @control_point ||= UPnP::ControlPoint.new
4
+ end
5
+
6
+ def fake_device_collection
7
+ @fake_device_collection ||= FakeUPnPDeviceCollection.instance
8
+ end
9
+
10
+ def local_ip
11
+ @local_ip ||= local_ip_and_port.first
12
+ end
13
+ end
14
+
15
+ World(HelperStuff)
@@ -0,0 +1,5 @@
1
+ class Hash
2
+ def symbolize_keys!
3
+ self.inject({}) { |result, (k, v)| result[k.to_sym] = v; result }
4
+ end
5
+ end
@@ -0,0 +1,16 @@
1
+ require 'socket'
2
+
3
+ # Workaround for missing constants on Windows
4
+ module Socket::Constants
5
+ IP_ADD_MEMBERSHIP = 12 unless defined? IP_ADD_MEMBERSHIP
6
+ IP_MULTICAST_LOOP = 11 unless defined? IP_MULTICAST_LOOP
7
+ IP_MULTICAST_TTL = 10 unless defined? IP_MULTICAST_TTL
8
+ IP_TTL = 4 unless defined? IP_TTL
9
+ end
10
+
11
+ class Socket
12
+ IP_ADD_MEMBERSHIP = 12 unless defined? IP_ADD_MEMBERSHIP
13
+ IP_MULTICAST_LOOP = 11 unless defined? IP_MULTICAST_LOOP
14
+ IP_MULTICAST_TTL = 10 unless defined? IP_MULTICAST_TTL
15
+ IP_TTL = 4 unless defined? IP_TTL
16
+ end
@@ -0,0 +1,65 @@
1
+ class Hash
2
+
3
+ # Converts Hash search targets to SSDP search target String. Conversions are
4
+ # as follows:
5
+ # uuid: "someUUID" # => "uuid:someUUID"
6
+ # device_type: "someDeviceType:1" # => "urn:schemas-upnp-org:device:someDeviceType:1"
7
+ # service_type: "someServiceType:2" # => "urn:schemas-upnp-org:service:someServiceType:2"
8
+ #
9
+ # You can use custom UPnP domain names too:
10
+ # { device_type: "someDeviceType:3",
11
+ # domain_name: "mydomain-com" } # => "urn:my-domain:device:someDeviceType:3"
12
+ # { service_type: "someServiceType:4",
13
+ # domain_name: "mydomain-com" } # => "urn:my-domain:service:someDeviceType:4"
14
+ #
15
+ # @return [String] The converted String, according to the UPnP spec.
16
+ def to_upnp_s
17
+ if self.has_key? :uuid
18
+ return "uuid:#{self[:uuid]}"
19
+ elsif self.has_key? :device_type
20
+ if self.has_key? :domain_name
21
+ return "urn:#{self[:domain_name]}:device:#{self[:device_type]}"
22
+ else
23
+ return "urn:schemas-upnp-org:device:#{self[:device_type]}"
24
+ end
25
+ elsif self.has_key? :service_type
26
+ if self.has_key? :domain_name
27
+ return "urn:#{self[:domain_name]}:service:#{self[:service_type]}"
28
+ else
29
+ return "urn:schemas-upnp-org:service:#{self[:service_type]}"
30
+ end
31
+ else
32
+ self.to_s
33
+ end
34
+ end
35
+ end
36
+
37
+
38
+ class Symbol
39
+
40
+ # Converts Symbol search targets to SSDP search target String. Conversions are
41
+ # as follows:
42
+ # :all # => "ssdp:all"
43
+ # :root # => "upnp:rootdevice"
44
+ # "root" # => "upnp:rootdevice"
45
+ #
46
+ # @return [String] The converted String, according to the UPnP spec.
47
+ def to_upnp_s
48
+ if self == :all
49
+ 'ssdp:all'
50
+ elsif self == :root
51
+ 'upnp:rootdevice'
52
+ else
53
+ self
54
+ end
55
+ end
56
+ end
57
+
58
+
59
+ class String
60
+ # This doesn't do anything to the string; just allows users to call the
61
+ # method without having to check type first.
62
+ def to_upnp_s
63
+ self
64
+ end
65
+ end
data/lib/playful.rb ADDED
@@ -0,0 +1,5 @@
1
+ require_relative 'playful/version'
2
+
3
+ module Playful
4
+
5
+ end
@@ -0,0 +1,175 @@
1
+ require 'open-uri'
2
+ require 'nori'
3
+ require 'em-synchrony'
4
+ require_relative 'logger'
5
+ require_relative 'ssdp'
6
+ require_relative 'control_point/service'
7
+ require_relative 'control_point/device'
8
+ require_relative 'control_point/error'
9
+
10
+
11
+ module Playful
12
+
13
+ # Allows for controlling a UPnP device as defined in the UPnP spec for control
14
+ # points.
15
+ #
16
+ # It uses +Nori+ for parsing the description XML files, which will use +Nokogiri+
17
+ # if you have it installed.
18
+ class ControlPoint
19
+ include LogSwitch::Mixin
20
+
21
+ def self.config
22
+ yield self
23
+ end
24
+
25
+ class << self
26
+ attr_accessor :raise_on_remote_error
27
+ end
28
+
29
+ @@raise_on_remote_error ||= true
30
+
31
+ attr_reader :devices
32
+
33
+ # @param [String] search_target The device(s) to control.
34
+ # @param [Hash] search_options Options to pass on to SSDP search and listen calls.
35
+ # @option options [Fixnum] response_wait_time
36
+ # @option options [Fixnum] m_search_count
37
+ # @option options [Fixnum] ttl
38
+ def initialize(search_target, search_options = {})
39
+ @search_target = search_target
40
+ @search_options = search_options
41
+ @search_options[:ttl] ||= 4
42
+ @devices = []
43
+ @new_device_channel = EventMachine::Channel.new
44
+ @old_device_channel = EventMachine::Channel.new
45
+ end
46
+
47
+ # Starts the ControlPoint. If an EventMachine reactor is running already,
48
+ # it'll join that reactor, otherwise it'll start the reactor.
49
+ #
50
+ # @yieldparam [EventMachine::Channel] new_device_channel The means through
51
+ # which clients can get notified when a new device has been discovered
52
+ # either through SSDP searching or from an +ssdp:alive+ notification.
53
+ # @yieldparam [EventMachine::Channel] old_device_channel The means through
54
+ # which clients can get notified when a device has gone offline (have sent
55
+ # out a +ssdp:byebye+ notification).
56
+ def start(&blk)
57
+ @stopping = false
58
+
59
+ starter = -> do
60
+ ssdp_search_and_listen(@search_target, @search_options)
61
+ blk.call(@new_device_channel, @old_device_channel)
62
+ @running = true
63
+ end
64
+
65
+ if EM.reactor_running?
66
+ log 'Joining reactor...'
67
+ starter.call
68
+ else
69
+ log 'Starting reactor...'
70
+ EM.synchrony(&starter)
71
+ end
72
+ end
73
+
74
+ def listen(ttl)
75
+ EM.defer do
76
+ listener = SSDP.listen(ttl)
77
+
78
+ listener.alive_notifications.subscribe do |advertisement|
79
+ log "Got alive #{advertisement}"
80
+
81
+ if @devices.any? { |d| d.usn == advertisement[:usn] }
82
+ log "Device with USN #{advertisement[:usn]} already exists."
83
+ else
84
+ log "Device with USN #{advertisement[:usn]} not found. Creating..."
85
+ create_device(advertisement)
86
+ end
87
+ end
88
+
89
+ listener.byebye_notifications.subscribe do |advertisement|
90
+ log "Got bye-bye from #{advertisement}"
91
+
92
+ @devices.reject! do |device|
93
+ device.usn == advertisement[:usn]
94
+ end
95
+
96
+ @old_device_channel << advertisement
97
+ end
98
+ end
99
+ end
100
+
101
+ def ssdp_search_and_listen(search_for, options = {})
102
+ searcher = SSDP.search(search_for, options)
103
+
104
+ searcher.discovery_responses.subscribe do |notification|
105
+ create_device(notification)
106
+ end
107
+
108
+ # Do I need to do this?
109
+ EM.add_timer(options[:response_wait_time]) do
110
+ searcher.close_connection
111
+ listen(options[:ttl])
112
+ end
113
+
114
+ EM.add_periodic_timer(5) do
115
+ log "Time since last timer: #{Time.now - @timer_time}" if @timer_time
116
+ log "Connections: #{EM.connection_count}"
117
+ @timer_time = Time.now
118
+ puts "<#{self.class}> Device count: #{@devices.size}"
119
+ puts "<#{self.class}> Device unique: #{@devices.uniq.size}"
120
+ end
121
+
122
+ trap_signals
123
+ end
124
+
125
+ def create_device(notification)
126
+ deferred_device = Device.new(ssdp_notification: notification)
127
+
128
+ deferred_device.errback do |partially_built_device, message|
129
+ log message
130
+ #add_device(partially_built_device)
131
+
132
+ if self.class.raise_on_remote_error
133
+ raise ControlPoint::Error, message
134
+ end
135
+ end
136
+
137
+ deferred_device.callback do |built_device|
138
+ log "Device created from #{notification}"
139
+ add_device(built_device)
140
+ end
141
+
142
+ deferred_device.fetch
143
+ end
144
+
145
+ def add_device(built_device)
146
+ if (@devices.any? { |d| d.usn == built_device.usn }) ||
147
+ (@devices.any? { |d| d.udn == built_device.udn })
148
+ log 'Newly created device already exists in internal list. Not adding.'
149
+ #if @devices.any? { |d| d.usn == built_device.usn }
150
+ # log "Newly created device (#{built_device.usn}) already exists in internal list. Not adding."
151
+ else
152
+ log 'Adding newly created device to internal list..'
153
+ @new_device_channel << built_device
154
+ @devices << built_device
155
+ end
156
+ end
157
+
158
+ def stop
159
+ @running = false
160
+ @stopping = false
161
+
162
+ EM.stop if EM.reactor_running?
163
+ end
164
+
165
+ def running?
166
+ @running
167
+ end
168
+
169
+ def trap_signals
170
+ trap('INT') { stop }
171
+ trap('TERM') { stop }
172
+ trap('HUP') { stop } if RUBY_PLATFORM !~ /mswin|mingw/
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,74 @@
1
+ require 'nori'
2
+ require 'em-http-request'
3
+ require_relative 'error'
4
+ require_relative '../logger'
5
+ require_relative '../../playful'
6
+
7
+
8
+ module Playful
9
+ class ControlPoint
10
+ class Base
11
+ include LogSwitch::Mixin
12
+
13
+ protected
14
+
15
+ def get_description(location, description_getter)
16
+ log "Getting description with getter ID #{description_getter.object_id} for: #{location}"
17
+ http = EM::HttpRequest.new(location).aget
18
+
19
+ t = EM::Timer.new(30) do
20
+ http.fail(:timeout)
21
+ end
22
+
23
+ http.errback do |error|
24
+ if error == :timeout
25
+ log 'Timed out getting description. Retrying...'
26
+ http = EM::HttpRequest.new(location).get
27
+ else
28
+ log "Unable to retrieve DDF from #{location}", :error
29
+ log "Request error: #{http.error}"
30
+ log "Response status: #{http.response_header.status}"
31
+
32
+ description_getter.set_deferred_status(:failed)
33
+
34
+ if ControlPoint.raise_on_remote_error
35
+ raise ControlPoint::Error, "Unable to retrieve DDF from #{location}"
36
+ end
37
+ end
38
+ end
39
+
40
+ http.callback {
41
+ log "HTTP callback called for #{description_getter.object_id}"
42
+ response = xml_parser.parse(http.response)
43
+ description_getter.set_deferred_status(:succeeded, response)
44
+ }
45
+ end
46
+
47
+ def build_url(url_base, rest_of_url)
48
+ if url_base.end_with?('/') && rest_of_url.start_with?('/')
49
+ rest_of_url.sub!('/', '')
50
+ end
51
+
52
+ url_base + rest_of_url
53
+ end
54
+
55
+ # @return [Nori::Parser]
56
+ def xml_parser
57
+ @xml_parser if @xml_parser
58
+
59
+ options = {
60
+ convert_tags_to: lambda { |tag| tag.to_sym }
61
+ }
62
+
63
+ begin
64
+ require 'nokogiri'
65
+ options.merge! parser: :nokogiri
66
+ rescue LoadError
67
+ warn "Tried loading nokogiri for XML couldn't. This is OK, just letting you know."
68
+ end
69
+
70
+ @xml_parser = Nori.new(options)
71
+ end
72
+ end
73
+ end
74
+ end