playful 0.1.0.alpha.1

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