require 'uri' require 'savon' require_relative 'base' module RUPNP # Service class for device's services. # # ==Actions # This class defines ruby methods from actions defined in # service description, as provided by the device. # # By example, from this description: # # actionName # # # argumentNameIn # in # stateVariableName # # # argumentNameOut # out # stateVariableName # # # a +#action_name+ method is created. This method requires a hash with # an element named +argument_name_in+. # If no in argument is required, an empty hash ({}) # must be passed to the method. Hash keys may not be symbols. # # A Hash is returned, with a key for each out argument. # # @author Sylvain Daubert class CP::RemoteService < CP::Base # @private @@event_sub_count = 0 # Get event subscription count for all services # (unique ID for subscription) # @return [Integer] def self.event_sub_count @@event_sub_count += 1 end # @private SOAP integer types INTEGER_TYPES = %w(ui1 ui2 ui4 i1 i2 i4 int).freeze # @private SOAP float types FLOAT_TYPES = %w(r4 r8 number float).freeze # @private SOAP string types STRING_TYPES = %w(char string uuid).freeze # @private SOAP true values TRUE_TYPES = %w(1 true yes).freeze # @private SOAP false values FALSE_TYPES = %w(0 false no).freeze # Get device to which this service belongs to # @return [Device] attr_reader :device # Get service type # @return [String] attr_reader :type # Get service id # @return [String] attr_reader :id # URL for service description # @return [String] attr_reader :scpd_url # URL for control # @return [String] attr_reader :control_url # URL for eventing # @return [String] attr_reader :event_sub_url # XML namespace for device description # @return [String] attr_reader :xmlns # Define architecture on which the service is implemented # @return [String] attr_reader :spec_version # Available actions on this service # @return [Array] attr_reader :actions # State table for the service # @return [Array] attr_reader :state_table # @param [Device] device # @param [String] url_base # @param [Hash] service def initialize(device, url_base, service) super() @device = device @description = service @type = service[:service_type].to_s @id = service[:service_id].to_s @scpd_url = build_url(url_base, service[:scpdurl].to_s) @control_url = build_url(url_base, service[:control_url].to_s) @event_sub_url = build_url(url_base, service[:event_sub_url].to_s) @actions = [] initialize_savon end # Get service from its description # @return [void] def fetch scpd_getter = EM::DefaultDeferrable.new scpd_getter.errback do fail "cannot get SCPD from #@scpd_url" next end scpd_getter.callback do |scpd| if bad_description?(scpd) fail 'not a UPNP 1.0/1.1 SCPD' next end extract_service_state_table scpd extract_actions scpd succeed self end get_description @scpd_url, scpd_getter end # Subscribe to event # @param [Hash] options # @option options [Integer] timeout # @yieldparam [Event] event event received # @yieldparam [Object] msg message received # @return [Integer] subscribe id. May be used to unsubscribe on event def subscribe_to_event(options={}, &blk) cp = device.control_point cp.start_event_server port = cp.event_port num = self.class.event_sub_count @callback_url = "http://#{HOST_IP}:#{port}/event#{num}}" uri = URI(@event_sub_url) options[:timeout] ||= EVENT_SUB_DEFAULT_TIMEOUT log :info, "send SUBSCRIBE request to #{uri}" con = EM::HttpRequest.new(@event_sub_url) http = con.setup_request(:subscribe, :head => { 'HOST' => "#{uri.host}:#{uri.port}", 'USER-AGENT' => RUPNP::USER_AGENT, 'CALLBACK' => @callback_url, 'NT' => 'upnp:event', 'TIMEOUT' => "Second-#{options[:timeout]}"}) http.errback do |client| log :warn, "Cannot subscribe to event: #{client.error}" end http.callback do log :debug, 'Close connection to subscribe event URL' con.close if http.response_header.status != 200 log :warn, "Cannot subscribe to event #@event_sub_url:" + " #{http.response_header.http_reason}" else timeout = http.response_header['TIMEOUT'].match(/(\d+)/)[1] || 1800 event = Event.new(@event_sub_url, @callback_url, http.response_header['SID'], timeout.to_i) cp.add_event_url << ["/event#{num}", event] event.subscribe &blk end end end private def bad_description?(scpd) if scpd[:scpd] bd = false @xmlns = scpd[:scpd][:@xmlns] bd |= @xmlns != "urn:schemas-upnp-org:service-1-0" bd |= scpd[:scpd][:spec_version][:major].to_i != 1 @spec_version = scpd[:scpd][:spec_version][:major] + '.' @spec_version += scpd[:scpd][:spec_version][:minor] bd |= !scpd[:scpd][:service_state_table] bd | scpd[:scpd][:service_state_table].empty? else true end end def extract_service_state_table(scpd) if scpd[:scpd][:service_state_table][:state_variable] @state_table = scpd[:scpd][:service_state_table][:state_variable] if @state_table.is_a? Hash @state_table = [@state_table] end end end def extract_actions(scpd) if scpd[:scpd][:action_list] and scpd[:scpd][:action_list][:action] log :info, "extract actions for service #@type" @actions = scpd[:scpd][:action_list][:action] @actions = [@actions] unless @actions.is_a? Array @actions.each do |action| action[:arguments] = action[:argument_list][:argument] action.delete :argument_list define_method_from_action action end end end def define_method_from_action(action) action[:name] = action[:name].to_s action_name = action[:name] name = snake_case(action_name).to_sym define_singleton_method(name) do |params| if params unless params.is_a? Hash raise ArgumentError, 'only hash arguments are accepted' end end response = @soap.call(action_name) do |locals| locals.attributes 'xmlns:u' => @type locals.soap_action "#{type}##{action_name}" locals.message params end if action[:arguments].is_a? Hash log :debug, 'only one argument in argument list' if action[:arguments][:direction] == 'out' process_soap_response name, response, action[:arguments] end else log :debug, 'true argument list' hsh = {} outer = action[:arguments].select { |arg| arg[:direction] == 'out' } outer.each do |arg| hsh.merge! process_soap_response(name, response, arg) end hsh end end end def process_soap_response(action, resp, out_arg) if resp.success? and resp.to_xml.empty? log :debug, 'Successful SOAP request but empty response' return {} end state_var = @state_table.find do |h| h[:name] == out_arg[:related_state_variable] end action_response = "#{action}_response".to_sym out_arg_name = snake_case(out_arg[:name]).to_sym value = resp.hash[:envelope][:body][action_response][out_arg_name] transform_method = if INTEGER_TYPES.include? state_var[:data_type] :to_i elsif FLOAT_TYPES.include? state_var[:data_type] :to_f elsif STRING_TYPES.include? state_var[:data_type] :to_s end if transform_method { out_arg_name => value.send(transform_method) } elsif TRUE_TYPES.include? state_var[:data_type] { out_arg_name => true } elsif FALSE_TYPES.include? state_var[:data_type] { out_arg_name => false } else log :warn, "SOAP response has an unknown type: #{state_var[:data_type]}" {} end end def initialize_savon @soap = Savon.client do |globals| globals.log_level :error globals.endpoint @control_url globals.namespace @type globals.convert_request_keys_to :camel_case globals.log true globals.headers :HOST => "#{HOST_IP}" globals.env_namespace 's' globals.namespace_identifier 'u' end end end end