sony_camera_remote_api 0.1.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.
@@ -0,0 +1,76 @@
1
+ require 'logger'
2
+
3
+ # Logging function module
4
+ module Logging
5
+ # Generic delegator class
6
+ class MultiDelegator
7
+ def initialize(*targets)
8
+ @targets = targets
9
+ end
10
+
11
+ def self.delegate(*methods)
12
+ methods.each do |m|
13
+ define_method(m) do |*args|
14
+ @targets.map { |t| t.send(m, *args) }
15
+ end
16
+ end
17
+ self
18
+ end
19
+
20
+ class <<self
21
+ alias to new
22
+ end
23
+ end
24
+
25
+ #--------------------PUBLIC METHODS BEGIN--------------------
26
+
27
+ # Get Logger class instance for the user class.
28
+ # @return [Logger] Logging class instance
29
+ def log
30
+ @logger ||= Logging.logger_for(self.class.name)
31
+ end
32
+
33
+
34
+ # Set file name or stream to output log.
35
+ # Log file is common in each user class.
36
+ # @param [String, IO, Array<String, IO>] log_file file name or stream to output log.
37
+ # @return [void]
38
+ def output_to(*log_file)
39
+ Logging.log_file self.class.name, log_file
40
+ end
41
+
42
+ #--------------------PUBLIC METHODS END----------------------
43
+
44
+
45
+ # Log files and streams shared in user classes.
46
+ @log_file = []
47
+ # Use a hash class-ivar to cache a unique Logger per class.
48
+ @loggers = {}
49
+
50
+ # These module class methods are private to user class.
51
+ class << self
52
+ # Set log files and streams. Logger class instance is re-created.
53
+ def log_file(classname, log_file)
54
+ @log_file = Array(log_file)
55
+ @loggers[classname] = nil
56
+ end
57
+
58
+ # Get Logger class instance or create it for the user class
59
+ def logger_for(classname)
60
+ @loggers[classname] ||= configure_logger_for(classname)
61
+ end
62
+
63
+ # Configure Logger instance for the user class.
64
+ # 1. create Logger instance with given log file and sterams
65
+ # 2. set progname to user class name
66
+ def configure_logger_for(classname)
67
+ @log_file.compact!
68
+ fios = @log_file.map do |f|
69
+ f.is_a?(String) ? File.open(f, 'a') : f
70
+ end
71
+ logger = Logger.new MultiDelegator.delegate(:write, :close).to(*fios)
72
+ logger.progname = classname.split('::')[-1]
73
+ logger
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,109 @@
1
+ require 'bindata'
2
+
3
+ module SonyCameraRemoteAPI
4
+
5
+ # The payload of Liveview image packet
6
+ # @private
7
+ class LiveviewImagePayload < BinData::Record
8
+ endian :big
9
+ # Payload header part
10
+ uint32 :start_code, assert: 0x24356879
11
+ uint24 :payload_data_size_wo_padding
12
+ uint8 :padding_size
13
+ uint32
14
+ uint8 :flag
15
+ uint920
16
+ # Payload data part
17
+ string :jpeg_data, :length => lambda { payload_data_size_wo_padding }
18
+ string :padding_data, :length => lambda { padding_size }
19
+ end
20
+
21
+ # Liveview frame information data version
22
+ # @private
23
+ class DataVersion < BinData::Record
24
+ endian :big
25
+ uint8 :major
26
+ uint8 :minor
27
+ end
28
+
29
+ # Point of liveview frame data
30
+ class Point < BinData::Record
31
+ endian :big
32
+ uint16 :x
33
+ uint16 :y
34
+ end
35
+
36
+ # The payload of Liveview frame information packet
37
+ # @private
38
+ class LiveviewFrameInformationPayload < BinData::Record
39
+ endian :big
40
+ # Payload header part
41
+ uint32 :start_code, assert: 0x24356879
42
+ uint24 :payload_data_size_wo_padding
43
+ uint8 :padding_size
44
+ data_version :frame_information_data_version
45
+ uint16 :frame_count
46
+ uint16 :single_frame_data_size
47
+ uint912
48
+ # Payload data part
49
+ array :frame_data, :initial_length => lambda { frame_count } do
50
+ point :top_left
51
+ point :bottom_right
52
+ uint8 :category
53
+ uint8 :status
54
+ uint8 :additional_status
55
+ uint40
56
+ string :padding_data, :length => lambda { padding_size }
57
+ end
58
+ end
59
+
60
+ # Liveview packet definition
61
+ # @private
62
+ class LiveviewPacket < BinData::Record
63
+ endian :big
64
+ uint8 :start_byte, assert: 0xFF
65
+ uint8 :payload_type, assert: -> { value == 0x01 || value == 0x02 }
66
+ uint16 :sequence_number
67
+ uint32 :time_stamp
68
+ choice :payload, selection: :payload_type do
69
+ liveview_image_payload 0x01
70
+ liveview_frame_information_payload 0x02
71
+ end
72
+ end
73
+
74
+
75
+ # Liveview image class
76
+ class LiveviewImage
77
+ attr_reader :sequence_number, :time_stamp, :jpeg_data
78
+ def initialize(packet)
79
+ @sequence_number = packet.sequence_number
80
+ @time_stamp = packet.time_stamp
81
+ @jpeg_data = packet.payload.jpeg_data
82
+ end
83
+ end
84
+
85
+ # Liveview frame information class
86
+ class LiveviewFrameInformation
87
+ # Frame class
88
+ class Frame
89
+ attr_reader :top_left, :bottom_right, :category, :status, :additional_status
90
+
91
+ def initialize(frame)
92
+ @top_left = frame.top_left
93
+ @bottom_right = frame.bottom_right
94
+ @category = frame.category
95
+ @status = frame.status
96
+ @additional_status = frame.additional_status
97
+ end
98
+ end
99
+
100
+ attr_reader :sequence_number, :time_stamp, :data_version, :frames
101
+ def initialize(packet)
102
+ @sequence_number = packet.sequence_number
103
+ @time_stamp = packet.time_stamp
104
+ @data_version = "#{packet.payload.frame_information_data_version.major}.#{packet.payload.frame_information_data_version.minor}"
105
+ @frames = packet.payload.frame_data.map { |f| Frame.new(f) }
106
+ end
107
+ end
108
+ end
109
+
@@ -0,0 +1,196 @@
1
+ require 'sony_camera_remote_api/error'
2
+ require 'httpclient'
3
+
4
+ module SonyCameraRemoteAPI
5
+
6
+ # Raw API layer class, which call APIs by HTTP POST to endpoint URLs and recieve the response.
7
+ class RawAPIManager
8
+ include Logging
9
+
10
+ # API Information class
11
+ class APIInfo
12
+ attr_accessor :name, :versions, :service_types
13
+
14
+ def initialize(name, versions, service_types)
15
+ @name = name
16
+ @versions = [versions]
17
+ @service_types = [service_types]
18
+ end
19
+
20
+ def multi_versions?
21
+ @versions.length > 1
22
+ end
23
+
24
+ def multi_service_types?
25
+ @service_types.length > 1
26
+ end
27
+ end
28
+
29
+ attr_reader :apis
30
+
31
+
32
+ # @param [Hash] endpoints Endpoint URIs
33
+ def initialize(endpoints)
34
+ @endpoints = endpoints
35
+ @cli = HTTPClient.new
36
+ @cli.connect_timeout = @cli.send_timeout = @cli.receive_timeout = 30
37
+
38
+ unless call_api('camera', 'getApplicationInfo', [], 1, '1.0').result[1] >= '2.0.0'
39
+ raise ServerNotCompatible, 'API version of the server < 2.0.0.'
40
+ end
41
+ @apis = make_api_list
42
+ end
43
+
44
+
45
+ # Make supported API list
46
+ # @return [Hash<Symbol, APIInfo>] API list
47
+ def make_api_list
48
+ apis = {}
49
+ @endpoints.keys.each do |s|
50
+ versions = call_api(s, 'getVersions', [], 1, '1.0')['result'][0].sort.reverse
51
+ versions.each do |v|
52
+ results = call_api(s, 'getMethodTypes', [v], 1, '1.0')['results']
53
+ next unless results
54
+ results.each do |r|
55
+ name = r[0].to_sym
56
+ if apis.key?(name)
57
+ apis[name].versions << v unless apis[name].versions.index(v)
58
+ apis[name].service_types << s unless apis[name].service_types.index(s)
59
+ else
60
+ apis[name] = APIInfo.new(r[0], v, s)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ apis
66
+ end
67
+
68
+
69
+ # Call API by HTTP POST.
70
+ # @param [String] service_type
71
+ # @param [String] method
72
+ # @param [Array, String] params
73
+ # @param [Fixnum] id
74
+ # @param [String] version
75
+ # @return [Hash] Response of API
76
+ def call_api(service_type, method, params, id, version)
77
+ params = [params] unless params.is_a? Array
78
+ request = {
79
+ 'method' => method,
80
+ 'params' => params,
81
+ 'id' => id,
82
+ 'version' => version
83
+ }
84
+ # log.debug request
85
+ begin
86
+ raw_response = @cli.post_content(@endpoints[service_type], request.to_json)
87
+ rescue HTTPClient::BadResponseError => e
88
+ if e.res.status_code == 403
89
+ raise APIForbidden.new, "Method '#{method}' returned 403 Forbidden error. Maybe this method is not allowed to general users."
90
+ else
91
+ raise e
92
+ end
93
+ end
94
+ response = JSON.parse(raw_response)
95
+ if response.key? 'error'
96
+ raise APIExecutionError.new(method, request, response), "Request:#{request}, Response:#{response}"
97
+ end
98
+ # log.debug response
99
+ response
100
+ end
101
+
102
+
103
+ # Asynchronous call API by HTTP POST.
104
+ # Currently only used by 'getEvent' API with long polling.
105
+ # @param [String] service_type
106
+ # @param [String] method
107
+ # @param [Array, String] params
108
+ # @param [Fixnum] id
109
+ # @param [String] version
110
+ # @param [Fixnum] timeout Timeout in seconds for waiting response
111
+ # @return [Hash] Response of API
112
+ def call_api_async(service_type, method, params, id, version, timeout = nil)
113
+ request = {
114
+ 'method' => method,
115
+ 'params' => params,
116
+ 'id' => id,
117
+ 'version' => version
118
+ }
119
+ conn = @cli.post_async(@endpoints[service_type], request.to_json)
120
+ start_time = Time.now if timeout
121
+ loop do
122
+ break if conn.finished?
123
+ if timeout
124
+ raise EventTimeoutError, "Timeout expired: #{timeout} sec." if Time.now - start_time > timeout
125
+ end
126
+ sleep 0.1
127
+ end
128
+ raw_response = conn.pop.content.read
129
+ response = JSON.parse(raw_response)
130
+ if response.key? 'error'
131
+ raise APIExecutionError.new(method, request, response), "Request:#{request}, Response:#{response}"
132
+ end
133
+ response
134
+ end
135
+
136
+
137
+ # Search given API from API list.
138
+ # @param [Symbol] method The method name
139
+ # @param [String] service
140
+ # @param [Fixnum] id
141
+ # @param [String] version
142
+ def search_method(method, **opts)
143
+ if @apis && @apis.key?(method)
144
+ api_info = @apis[method]
145
+ # use ID=1 if not given
146
+ id = opts.key?(:id) ? opts[:id] : 1
147
+ if opts.key? :version
148
+ if api_info.versions.include? opts[:version]
149
+ version = opts[:version]
150
+ else
151
+ raise APIVersionInvalid, "The version '#{opts[:version]}' is invalid for method '#{method}'."
152
+ end
153
+ else
154
+ # use newest version if not given
155
+ if api_info.multi_versions?
156
+ # log.debug "Using newest version '#{api_info.versions[0]}' for method '#{method}'."
157
+ end
158
+ version = api_info.versions[0]
159
+ end
160
+ if opts.key? :service
161
+ service = opts[:service]
162
+ if api_info.service_types.include? opts[:service]
163
+ service = opts[:service]
164
+ else
165
+ raise ServiceTypeInvalid, "The service type '#{opts[:service]}' is invalid for method '#{method}'."
166
+ end
167
+ else
168
+ # raise error if service type is not given for method having multi service types
169
+ if api_info.multi_service_types?
170
+ strs = api_info.service_types.map { |item| "'#{item}'" }
171
+ raise ServiceTypeNotGiven, "The method '#{method}' must be specified service type from #{strs.join(' or ')}."
172
+ end
173
+ service = api_info.service_types[0]
174
+ end
175
+ return api_info.name, service, id, version
176
+ else
177
+ raise APINotSupported.new, "The method '#{method}' is not supported by this camera."
178
+ end
179
+ end
180
+
181
+
182
+ # @param [Symbol] method The method name
183
+ # @return [Boolean] +true+ if the API is supported by this camera. +false+ otherwise.
184
+ def support?(method)
185
+ @apis.key? method
186
+ end
187
+
188
+
189
+ # Reset HTTPClient.
190
+ # @return [void]
191
+ def reset_connection
192
+ @cli.reset_all
193
+ end
194
+
195
+ end
196
+ end
@@ -0,0 +1,64 @@
1
+ require 'open3'
2
+
3
+ module SonyCameraRemoteAPI
4
+ # Helper module for connecting to camera by wi-fi.
5
+ module Scripts
6
+
7
+ module_function
8
+
9
+ # Connects to camera by Wi-Fi.
10
+ # This method does nothing if already connected, which is judged by ifconfig command.
11
+ # @param [String] interface Interface name, e.g. wlan0
12
+ # @param [String] ssid SSID of the camera to connect
13
+ # @param [String] pass Password of the camera to connect
14
+ # @return [Boolean] +true+ if succeeded, +false+ otherwise.
15
+ def connect(interface, ssid, pass)
16
+ run_external_command "sudo bash #{connection_script} #{interface} #{ssid} #{pass}"
17
+ end
18
+
19
+ # Restart the interface and connect to camera by Wi-Fi.
20
+ # @param [String] interface Interface name, e.g. wlan0
21
+ # @param [String] ssid SSID of the camera to connect
22
+ # @param [String] pass Password of the camera to connect
23
+ # @return [Boolean] +true+ if succeeded, +false+ otherwise.
24
+ def restart_and_connect(interface, ssid, pass)
25
+ run_external_command "sudo bash #{connection_script} -r #{interface} #{ssid} #{pass}"
26
+ end
27
+
28
+
29
+ # Run shell command.
30
+ # Command output are written to stdout witout buffering.
31
+ # @param [String] command Command to execute
32
+ # @return [Boolean] +true+ if succeeded, +false+ otherwise.
33
+ def run_external_command(command)
34
+ puts command
35
+ Open3.popen2e(command) do |_i, oe, w|
36
+ oe.each do |line|
37
+ puts line
38
+ end
39
+ # Return code
40
+ if w.value != 0
41
+ return false
42
+ else
43
+ return true
44
+ end
45
+ end
46
+ end
47
+
48
+ # Get gem root path (not smart)
49
+ def root
50
+ File.expand_path '../../..', __FILE__
51
+ end
52
+
53
+ # Path where scripts are located
54
+ def path
55
+ File.join root, 'scripts'
56
+ end
57
+
58
+ # Full path of connection script
59
+ def connection_script
60
+ File.join path, 'connect.sh'
61
+ end
62
+
63
+ end
64
+ end
@@ -0,0 +1,72 @@
1
+ require 'frisky/ssdp'
2
+ require 'httpclient'
3
+ require 'nokogiri'
4
+ require 'json'
5
+
6
+ module SonyCameraRemoteAPI
7
+ # Module providing SSDP function to discover camera (Device discovery)
8
+ module SSDP
9
+
10
+ # Cannot find camera
11
+ class DeviceNotFound < StandardError;
12
+ end
13
+ # Failed to Parse device description
14
+ class DeviceDescriptionInvalid < StandardError;
15
+ end
16
+
17
+ # The search target for Sony camera (fixed)
18
+ SSDP_SEARCH_TARGET = 'urn:schemas-sony-com:service:ScalarWebAPI:1'.freeze
19
+ # Retrying limit for SSDP search
20
+ SSDP_SEARCH_RETRY = 4
21
+ # Retrying interval for SSDP search
22
+ SSDP_RETRY_INTERVAL = 5
23
+
24
+ # Perform SSDP discover to find camera on network.
25
+ # @return [Hash] Endpoint URLs
26
+ def ssdp_search
27
+ log.info 'Trying SSDP discover...'
28
+ try = 1
29
+ while true do
30
+ response = Frisky::SSDP.search SSDP_SEARCH_TARGET
31
+ if response.present?
32
+ break
33
+ elsif try < SSDP_SEARCH_RETRY
34
+ try += 1
35
+ log.warn "SSDP discover failed, retrying... (#{try}/#{SSDP_SEARCH_RETRY})"
36
+ sleep(SSDP_RETRY_INTERVAL)
37
+ else
38
+ raise DeviceNotFound, 'Cannot find camera API server. Please confirm network connection is correct.'
39
+ end
40
+ end
41
+ log.info 'SSDP discover succeeded.'
42
+
43
+ # get device description
44
+ dd = HTTPClient.new.get_content(response[0][:location])
45
+ # puts dd
46
+ parse_device_description(dd)
47
+ end
48
+
49
+
50
+ # Parse device description and get endpoint URLs
51
+ def parse_device_description(dd)
52
+ dd_xml = Nokogiri::XML(dd)
53
+ raise DeviceDescriptionInvalid if dd_xml.nil?
54
+ dd_xml.remove_namespaces!
55
+ camera_name = dd_xml.css('device friendlyName').inner_text
56
+ services = dd_xml.css('device X_ScalarWebAPI_Service')
57
+ endpoints = {}
58
+ services.each do |sv|
59
+ service_type = sv.css('X_ScalarWebAPI_ServiceType').inner_text
60
+ endpoints[service_type] = File.join(sv.css('X_ScalarWebAPI_ActionList_URL').inner_text, service_type)
61
+ end
62
+ # endpoints['liveview'] = dd_xml.css('device X_ScalarWebAPI_LiveView_URL').inner_text
63
+ # endpoints.delete_if { |k, v| v.blank? }
64
+ log.info "model-name: #{camera_name}"
65
+ log.debug 'endpoints:'
66
+ endpoints.each do |e|
67
+ log.debug " #{e}"
68
+ end
69
+ endpoints
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,98 @@
1
+ module SonyCameraRemoteAPI
2
+ # Module providing utility methods
3
+ module Utils
4
+
5
+ module_function
6
+
7
+ # Get file name in the format of <prefix>_<num>.<ext>
8
+ # If there are files that matches this format, get the next number of it.
9
+ # @param [String] prefix
10
+ # @param [String] ext
11
+ # @param [Fixnum] start
12
+ # @param [Fixnum] num
13
+ # @param [String] dir
14
+ def generate_sequencial_filenames(prefix, ext, start: nil, num: nil, dir: nil)
15
+ if start
16
+ count = start
17
+ else
18
+ count = get_next_file_number prefix, ext, dir: dir
19
+ end
20
+ gen = Enumerator.new do |y|
21
+ loop do
22
+ y << "#{prefix}_#{count}.#{ext}"
23
+ count += 1
24
+ end
25
+ end
26
+ if num
27
+ return (0..(num-1)).map { gen.next }
28
+ else
29
+ return gen
30
+ end
31
+ end
32
+
33
+
34
+ # Get the next file number by searching files with format '<prefix>_\d+.<ext>' in <dir>.
35
+ # @param [String] prefix
36
+ # @param [String] ext
37
+ # @param [String] dir
38
+ def get_next_file_number(prefix, ext, dir: nil)
39
+ numbers = []
40
+ Dir["#{dir}/*"].map do |f|
41
+ begin
42
+ num = f[/#{dir}\/#{prefix}_(\d+).#{ext}/, 1]
43
+ numbers << num.to_i if num.present?
44
+ rescue
45
+ nil
46
+ end
47
+ end
48
+ if numbers.empty?
49
+ 0
50
+ else
51
+ numbers.sort[-1] + 1
52
+ end
53
+ end
54
+
55
+
56
+ # Search pattern in candidates.
57
+ # @param [String] pattern Pattern
58
+ # @param [Array<String>] candidates Candidates
59
+ # @return [Array<String, Fixnum>] matched candidate and the number of matched candidates.
60
+ def partial_and_unique_match(pattern, candidates)
61
+ result = candidates.find { |c| c == pattern }
62
+ return result, 1 if result
63
+ result = candidates.select { |c| c =~ /#{pattern}/i }
64
+ return result[0], 1 if result.size == 1
65
+ result = candidates.select { |c| c =~ /#{pattern}/ }
66
+ return result[0], 1 if result.size == 1
67
+ return nil, result.size
68
+ end
69
+
70
+ # Print array.
71
+ # @param [Array] array
72
+ # @param [Fixnum] horizon
73
+ # @param [Fixnum] space
74
+ # @param [Fixnum] threshold
75
+ def print_array_in_columns(array, horizon, space, threshold)
76
+ if array.size >= threshold
77
+ longest = array.map { |s| s.size }.max
78
+ num_columns = (horizon + space ) / (longest + space)
79
+ num_rows = array.size / num_columns + 1
80
+ array += [''] * ((num_columns * num_rows) - array.size)
81
+ array_2d = array.each_slice(num_rows).to_a
82
+ longests = array_2d.map { |column| column.map { |e| e.size }.max }
83
+ array_2d = array_2d.transpose
84
+ array_2d.each do |row|
85
+ row.zip(longests).each do |e, len|
86
+ print e + ' ' * (len - e.size) + ' ' * space
87
+ end
88
+ puts ''
89
+ end
90
+ else
91
+ array.each do |e|
92
+ puts e
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+
@@ -0,0 +1,3 @@
1
+ module SonyCameraRemoteAPI
2
+ VERSION = '0.1.1'.freeze
3
+ end