alula-ruby 2.6.3 → 2.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alula
4
+ # Parent class for all DCP objects
5
+ class DcpResource
6
+ attr_accessor :device_id, :index, :raw_data, :meta, :errors, :rate_limit, :dirty_attributes, :value, :staged
7
+
8
+ def self.class_name
9
+ name.split('::')[-1]
10
+ end
11
+
12
+ def initialize(device_id, index = nil, attributes = {})
13
+ @raw_data = {}
14
+ @dirty_attributes = {}
15
+ @errors = ModelErrors.new(self.class)
16
+ construct_from(device_id, index, attributes)
17
+ end
18
+
19
+ def clone
20
+ self.class.new(@device_id, nil, @raw_data)
21
+ end
22
+
23
+ #
24
+ # Construct a new resource, ready to receive attributes, with
25
+ # empty values for all attrs.
26
+ # Useful for making New resources
27
+ def self.build(device_id)
28
+ fields = get_fields.keys.map { |k| Util.camelize(k) }
29
+ empty_shell = fields.each_with_object({}) { |f, obj| obj[f] = nil }
30
+ new(device_id, nil, empty_shell)
31
+ end
32
+
33
+ def construct_from(device_id, index, json_object)
34
+ raise ArgumentError, 'device_id is required' if device_id.empty?
35
+
36
+ @raw_data = json_object.dup
37
+ assign_meta_value_staged(json_object)
38
+ self.index = index
39
+ self.device_id = device_id
40
+
41
+ @errors = ModelErrors.new(self.class)
42
+
43
+ self
44
+ end
45
+
46
+ #
47
+ # Take a hash of attributes and apply them to the model
48
+ def apply_attributes(attributes)
49
+ attributes.each do |key, value|
50
+ next unless fields.include?(key)
51
+
52
+ @dirty_attributes[key] = value
53
+ end
54
+ end
55
+
56
+ def reconstruct_from(device_id, index, json_object)
57
+ construct_from(device_id, index, json_object)
58
+ end
59
+
60
+ def errors?
61
+ @errors.any?
62
+ end
63
+
64
+ def refresh
65
+ response = Alula::Client.request(:get, resource_url, {}, {})
66
+ if response.ok?
67
+ model = construct_from(device_id, index, response.data)
68
+ model.rate_limit = response.rate_limit
69
+ model
70
+ else
71
+ error_class = AlulaError.for_response(response)
72
+ raise error_class
73
+ end
74
+ end
75
+
76
+ #
77
+ # Fetch known attributes out of the object, collected into a Hash in camelCase format
78
+ # Intended for eventually making its way back up to the API
79
+ def as_json
80
+ field_names.each_with_object({}) do |ruby_key, obj|
81
+ key = Util.camelize(ruby_key)
82
+ val = find_value(ruby_key)
83
+
84
+ next if val.nil?
85
+
86
+ obj[key] = if val.is_a? Array
87
+ val.map { |v| parse_value(v, ruby_key) }
88
+ elsif val.is_a? Alula::Dcp::ObjectField
89
+ val.as_json
90
+ else
91
+ parse_value(val, ruby_key)
92
+ end
93
+ end
94
+ end
95
+
96
+ def find_value(key)
97
+ # prioritize dirty attributes, then staged, then value
98
+ # ideally we should use only dirty attributes, done by executing .apply_attributes
99
+ return @dirty_attributes[key] if @dirty_attributes.include?(key)
100
+ return @staged.send(key) if @staged.respond_to?(key)
101
+
102
+ @value.send(key) if @value.respond_to?(key)
103
+ end
104
+
105
+ #
106
+ # Reduce as_json to a set that can be updated
107
+ def as_patchable_json
108
+ values = as_json.each_pair.each_with_object({}) do |(key, val), collector|
109
+ collector[key] = val
110
+ end
111
+
112
+ values.reject { |k, _v| %w[index version].include? k } # blacklist index and version
113
+ end
114
+
115
+ def annotate_errors(model_errors)
116
+ @errors = ModelErrors.new(self.class)
117
+ model_errors.each_pair do |field_name, error|
118
+ errors.add(field_name, error)
119
+ end
120
+ end
121
+
122
+ #
123
+ # Return an instance of QueryEngine annotated with the correct model attributes
124
+ def filter_builder
125
+ Alula::FilterBuilder.new(self.class)
126
+ end
127
+ alias fb filter_builder
128
+
129
+ def model_name
130
+ self.class
131
+ end
132
+
133
+ # Class for holding model errors
134
+ class ModelErrors
135
+ include Enumerable
136
+
137
+ def initialize(model_class)
138
+ @model_class = model_class
139
+ @details = {}
140
+ end
141
+
142
+ def each
143
+ @details.each do |field, error|
144
+ yield({ field => error })
145
+ end
146
+ end
147
+
148
+ def any?
149
+ @details.any?
150
+ end
151
+
152
+ def add(field_name, error_message)
153
+ @details[field_name] = error_message
154
+ end
155
+
156
+ def [](field_name)
157
+ @details[field_name.to_sym]
158
+ end
159
+
160
+ def full_messages
161
+ @details.map { |field, error| "#{field}: #{error}" }
162
+ end
163
+
164
+ def full_messages_for(attribute_name)
165
+ return nil unless @details[attribute_name.to_sym].present?
166
+
167
+ "#{attribute_name}: #{@details[attribute_name.to_sym]}"
168
+ end
169
+ end
170
+
171
+ private
172
+
173
+ def assign_meta_value_staged(json_object)
174
+ @meta = Alula::Dcp::Meta.new(json_object['meta']) unless empty_value?(json_object['meta'])
175
+ @value = convert_to_ruby_object(json_object['value'])
176
+ @staged = convert_to_ruby_object(json_object['staged'])
177
+ end
178
+
179
+ def convert_to_ruby_object(object)
180
+ return nil if object.nil?
181
+
182
+ fields.each_pair.each_with_object({}) do |(field_name, opts), obj|
183
+ value = object[Util.camelize(field_name.to_s)]
184
+ if value.nil?
185
+ # we still need to define the method, even if it's nil
186
+ obj[field_name] = nil
187
+ obj.define_singleton_method(field_name) { obj[field_name] }
188
+ next
189
+ end
190
+
191
+ obj[field_name] =
192
+ if opts[:type] == :date && ![nil, ''].include?(value)
193
+ begin
194
+ DateTime.parse(value)
195
+ rescue ArgumentError
196
+ value
197
+ end
198
+ elsif opts[:type] == :object && opts[:use] && !value.nil? && value.respond_to?(:each)
199
+ opts[:use].new(field_name, value)
200
+ elsif opts[:type] == :array && opts[:use] && !value.nil? && value.respond_to?(:each)
201
+ # Array of objects, each object is a new instance of the use class
202
+ value.each_with_object([]) do |item, collector|
203
+ collector << opts[:use].new(field_name, item)
204
+ end
205
+ elsif opts[:type] == :boolean
206
+ [true, 'true', 1, '1'].include? value
207
+ elsif opts[:type] == :number
208
+ value&.to_i
209
+ else
210
+ value
211
+ end
212
+ obj.define_singleton_method(field_name) { obj[field_name] }
213
+ end
214
+ end
215
+
216
+ def parse_value(value, ruby_key)
217
+ if date_fields.include?(ruby_key) && ![nil, ''].include?(value)
218
+ extract_date(value)
219
+ elsif value.is_a? Alula::Dcp::ObjectField
220
+ value.as_json
221
+ else
222
+ value
223
+ end
224
+ end
225
+
226
+ def extract_date(value)
227
+ if value.respond_to? :strftime
228
+ value.strftime('%Y-%m-%dT%H:%M:%S.%L%z')
229
+ else
230
+ value.to_s
231
+ end
232
+ end
233
+
234
+ def empty_value?(value)
235
+ [nil, ''].include?(value)
236
+ end
237
+ end
238
+ end
data/lib/alula/errors.rb CHANGED
@@ -1,6 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Alula
2
4
  class AlulaError < StandardError
3
5
  attr_reader :http_status, :raw_response, :error, :message
6
+
4
7
  def initialize(error)
5
8
  if error.class == String
6
9
  @message = error
@@ -24,9 +27,12 @@ module Alula
24
27
  elsif !response.data['errors'].nil? && !response.data['errors'].empty?
25
28
  self.errors_for_response(response)
26
29
 
30
+ elsif !response.data['message'].nil?
31
+ self.error_for_response(response)
32
+
27
33
  elsif response.data.match(/^<!DOCTYPE html>/)
28
34
  self.critical_error_for_response(response.data.scan(/<pre>(.*)<\/pre>/))
29
-
35
+
30
36
  elsif response.data.match(/502 Bad Gateway/)
31
37
  self.gateway_error_for_response(response.data.scan(/502 Bad Gateway/))
32
38
  else
@@ -34,7 +40,6 @@ module Alula
34
40
  Alula.logger.error message
35
41
  raise UnknownApiError.new(message)
36
42
  end
37
-
38
43
  rescue NoMethodError
39
44
  message = "Unable to derive error from response: #{response.inspect}"
40
45
  Alula.logger.error message
@@ -66,6 +71,10 @@ module Alula
66
71
  when 'server_error'
67
72
  Alula.logger.error response
68
73
  ServerError.new(response)
74
+ when 'Bad Request'
75
+ BadRequestError.new(response)
76
+ when 'Not Found'
77
+ NotFoundError.new(response)
69
78
  else
70
79
  #
71
80
  # RPC errors are identified by jsonrpc in the body.
@@ -77,7 +86,8 @@ module Alula
77
86
  # Here we have some errors that are actually known but at least one of them is not properly formatted by the API
78
87
  message = response.data.dig('error', 'data', 'msg') ||
79
88
  response.data.dig('error', 'data', 'message') ||
80
- response.data.dig('error', 'description')
89
+ response.data.dig('error', 'description') ||
90
+ response.data['message']
81
91
  case message
82
92
  when 'dealer does not exist'
83
93
  NotFoundError.new(message)
@@ -199,14 +209,14 @@ module Alula
199
209
  @http_status = response.http_status
200
210
  @raw_response = response
201
211
  @error = error['message']
202
- @full_messages = error.dig('data', 'message')&.split(', ') ||
203
- [error['message']]
212
+ @full_messages = error.dig('data', 'message')&.split(', ') ||
213
+ [error['message']]
204
214
  @message = error['message']
205
215
  @code = error['code']
206
216
  end
207
217
 
208
218
  #
209
- # Provides interface mirroring to success responses
219
+ # Provides interface mirroring to success responses
210
220
  def ok?
211
221
  false
212
222
  end
@@ -76,6 +76,10 @@ module Alula
76
76
  end
77
77
 
78
78
  def alder_wtp?
79
+ [38, 44].include?(program_id)
80
+ end
81
+
82
+ def alder_wtp_01?
79
83
  program_id == 38
80
84
  end
81
85
 
@@ -52,11 +52,11 @@ module Alula
52
52
  @fields[field_name] = opts
53
53
 
54
54
  self.instance_eval do
55
- jsonKey = Util.camelize(field_name)
55
+ json_key = Util.camelize(field_name)
56
56
 
57
57
  # Reader method for attribute
58
58
  define_method(field_name) do
59
- value = @values[jsonKey]
59
+ value = @values[json_key]
60
60
 
61
61
  if opts[:type] == :date && ![nil, ''].include?(value)
62
62
  begin
@@ -72,7 +72,7 @@ module Alula
72
72
  # API sends a camelCase string; provide symbol to Client
73
73
  value ? Util.underscore(value).to_sym : nil
74
74
  elsif opts[:hex_convert_required] == true
75
- Util.convert_hex_crc?(self.program_id) ? value.to_s(16) : value
75
+ Util.convert_hex_crc?(program_id) ? value.to_s(16) : value
76
76
  elsif opts[:type] == :number
77
77
  value&.to_i
78
78
  else
@@ -96,10 +96,10 @@ module Alula
96
96
  end
97
97
 
98
98
  # Mark the attribute as dirty if the new value is different
99
- mark_dirty(field_name, @values[jsonKey], new_value)
99
+ mark_dirty(field_name, @values[json_key], new_value)
100
100
  #
101
101
  # Assign the new value (always assigned even if a duplicate)
102
- @values[jsonKey] = new_value
102
+ @values[json_key] = new_value
103
103
  end
104
104
  end
105
105
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alula
4
+ module Dcp
5
+ # Base class for DCP resources
6
+ class BaseResource < Alula::DcpResource
7
+ class << self
8
+ def api_name(name = nil)
9
+ if name
10
+ @api_name = name
11
+ elsif @api_name
12
+ @api_name
13
+ else
14
+ superclass.api_name
15
+ end
16
+ end
17
+
18
+ # Infer resource name from classname if not provided
19
+ def resource_name(name = nil)
20
+ if name
21
+ @resource_name = name
22
+ elsif @resource_name
23
+ @resource_name
24
+ else
25
+ @resource_name = self.name.split('::').last.downcase.to_sym
26
+ end
27
+ end
28
+
29
+ def resource_url(device_id, index = nil)
30
+ base_url = "/#{api_name}/v2/helix/#{device_id}/#{resource_name}"
31
+ index ? "#{base_url}/#{index}" : base_url
32
+ end
33
+ end
34
+
35
+ api_name :dcp
36
+
37
+ # Instance method
38
+ def resource_url(device_id = self.device_id, index = self.index)
39
+ "/#{self.class.api_name}/v2/helix/#{device_id}/#{self.class.resource_name}/#{index}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alula
4
+ module Dcp
5
+ class Config
6
+ # Synchronize DCP configuration
7
+ class Synchronize < Alula::SingletonDcpCommandResource
8
+ extend Alula::Dcp::ResourceAttributes
9
+
10
+ resource_path 'config/synchronize'
11
+
12
+ field :alarm_types_supported, type: :boolean
13
+ field :arming_level_options_by_arming_level, type: :boolean
14
+ field :bus_modules, type: :boolean
15
+ field :comm_data, type: :boolean
16
+ field :custom_zone_profiles, type: :boolean
17
+ field :fob_data, type: :boolean
18
+ field :local_scene_actions, type: :boolean
19
+ field :local_scene_names, type: :boolean
20
+ field :local_scene_triggers, type: :boolean
21
+ field :local_scenes, type: :boolean
22
+ field :misc_nv_data, type: :boolean
23
+ field :wireless_peripherals, type: :boolean
24
+ field :mobile_device_names, type: :boolean
25
+ field :mobile_device_settings, type: :boolean
26
+ field :outputs, type: :boolean
27
+ field :panel_options, type: :boolean
28
+ field :partitions, type: :boolean
29
+ field :pin_pad_data, type: :boolean
30
+ field :pinger_fob_data, type: :boolean
31
+ field :siren_data, type: :boolean
32
+ field :time_schedules, type: :boolean
33
+ field :timers, type: :boolean
34
+ field :user_mapping, type: :boolean
35
+ field :users_data, type: :boolean
36
+ field :zone_data, type: :boolean
37
+ field :zwave_device_names, type: :boolean
38
+
39
+ def self.call(device_id:, payload:)
40
+ request(
41
+ device_id: device_id,
42
+ http_method: :post,
43
+ payload: payload
44
+ )
45
+ end
46
+
47
+ def self.payload_required?
48
+ true
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alula
4
+ module Dcp
5
+ class Config
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alula
4
+ module Dcp
5
+ class UsersData
6
+ # Panel User Resource
7
+ class InstallerPin < Alula::Dcp::BaseResource
8
+ extend Alula::Dcp::ResourceAttributes
9
+ extend Alula::DcpOperations::Request
10
+ extend Alula::DcpOperations::Save
11
+ extend Alula::DcpOperations::Delete
12
+ extend Alula::DcpOperations::DeleteStaged
13
+
14
+ resource_name 'config/usersData/installerPin'
15
+
16
+ field :primitive,
17
+ type: :string
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alula
4
+ module Dcp
5
+ class UsersData
6
+ # Panel User Resource
7
+ class User < Alula::Dcp::BaseResource
8
+ extend Alula::Dcp::ResourceAttributes
9
+ extend Alula::DcpOperations::Request
10
+ extend Alula::DcpOperations::Save
11
+ extend Alula::DcpOperations::Delete
12
+ extend Alula::DcpOperations::DeleteStaged
13
+
14
+ resource_name 'config/usersData/user'
15
+
16
+ # Partition class to be used as an array field on the User class
17
+ class Partition < Alula::Dcp::ObjectField
18
+ field :assigned,
19
+ type: :boolean
20
+
21
+ field :bypass_zones,
22
+ type: :boolean
23
+
24
+ field :manage_users,
25
+ type: :boolean
26
+
27
+ field :system_test,
28
+ type: :boolean
29
+
30
+ field :view_history,
31
+ type: :boolean
32
+
33
+ field :control_outputs,
34
+ type: :boolean
35
+
36
+ field :manage_scenes,
37
+ type: :boolean
38
+
39
+ field :manage_zwave_devices,
40
+ type: :boolean
41
+
42
+ field :silence_trouble_beeps,
43
+ type: :boolean
44
+
45
+ field :zone_sensor_reset,
46
+ type: :boolean
47
+
48
+ field :master_user,
49
+ type: :boolean
50
+
51
+ field :confirm_alarms,
52
+ type: :boolean
53
+
54
+ field :control_chime_mode,
55
+ type: :boolean
56
+
57
+ field :level1,
58
+ type: :boolean
59
+
60
+ field :level2,
61
+ type: :boolean
62
+
63
+ field :level3,
64
+ type: :boolean
65
+
66
+ field :level4,
67
+ type: :boolean
68
+
69
+ field :level5,
70
+ type: :boolean
71
+
72
+ field :level6,
73
+ type: :boolean
74
+
75
+ field :level7,
76
+ type: :boolean
77
+
78
+ field :level8,
79
+ type: :boolean
80
+ end
81
+
82
+ field :display_name,
83
+ type: :string
84
+
85
+ field :user_fob_number,
86
+ type: :number
87
+
88
+ field :user_pin,
89
+ type: :string
90
+
91
+ field :partitions,
92
+ type: :array,
93
+ use: Partition
94
+
95
+ field :version,
96
+ type: :number
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alula
4
+ module Dcp
5
+ # Main class for UsersData
6
+ # Subclasses: UsersData::User, UsersData::DuressPin, UsersData::InstallerPin, UsersData::AccessCodeUserMapping
7
+ class UsersData < Alula::Dcp::BaseResource
8
+ extend Alula::DcpOperations::List
9
+
10
+ resource_name 'config/usersData'
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alula
4
+ # Base class for DCP command resources
5
+ class SingletonDcpCommandResource
6
+ attr_accessor :device_id, :values
7
+
8
+ BASE_PATH = '/dcp/v2/helix'
9
+
10
+ def initialize(device_id, attributes = nil)
11
+ @values = attributes || {}
12
+ construct_new_resource(device_id)
13
+ end
14
+
15
+ def model_name
16
+ self.class
17
+ end
18
+
19
+ def self.request(device_id:, http_method:, path: get_resource_path, payload: {}, opts: {})
20
+ if self == Alula::SingletonDcpCommandResource
21
+ raise NotImplementedError,
22
+ 'Alula::SingletonDcpCommandResource is an abstract class and cannot be instantiated.' \
23
+ ' You should perform actions on its subclasses (Synchronize, etc.)'
24
+ end
25
+ if payload.empty? && payload_required?
26
+ raise ArgumentError, 'Payload is empty. This will result in a useless request.'
27
+ end
28
+
29
+ path = "#{BASE_PATH}/#{device_id}/#{path}"
30
+ payload = payload_to_camelcase(payload)
31
+ response = Alula::Client.request(http_method, path, payload, opts)
32
+
33
+ unless response.ok?
34
+ error = Alula::AlulaError.for_response(response)
35
+
36
+ #
37
+ # Some error classifications are critical and should be raised for visibility
38
+ # These errors do not contain meaningful data that an end-user can use to correct
39
+ # the error.
40
+ raise error if [Alula::RateLimitError, Alula::BadRequestError, Alula::ForbiddenError,
41
+ Alula::UnknownError, Alula::InsufficientScopeError].include?(error.class)
42
+
43
+ return error
44
+ end
45
+
46
+ new(device_id, response.data)
47
+ end
48
+
49
+ def construct_new_resource(device_id)
50
+ self.device_id = device_id
51
+ instance_eval do
52
+ fields.each_key do |field|
53
+ camel_key = Util.camelize(field)
54
+ define_singleton_method(field) do
55
+ if @values.key?(camel_key)
56
+ @values[camel_key]
57
+ else
58
+ @values[field]
59
+ end
60
+ end
61
+ define_singleton_method("#{field}=") do |value|
62
+ if @values.key?(camel_key)
63
+ @values[camel_key] = value
64
+ else
65
+ @values[field] = value
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ def apply_attributes(attributes)
73
+ attributes.each do |key, value|
74
+ send("#{key}=", value)
75
+ end
76
+ end
77
+
78
+ def call
79
+ self.class.call(device_id: device_id, payload: @values)
80
+ end
81
+
82
+ def self.payload_to_camelcase(payload)
83
+ # recursively convert all keys to camelcase
84
+ payload.each_with_object({}) do |(key, value), hash|
85
+ hash[Util.camelize(key)] = if value.is_a?(Array)
86
+ value.map { |v| v.is_a?(Hash) ? payload_to_camelcase(v) : v }
87
+ else
88
+ value.is_a?(Hash) ? payload_to_camelcase(value) : value
89
+ end
90
+ end
91
+ end
92
+
93
+ def self.payload_required?
94
+ # return false unless the method is overridden
95
+ return false unless method_defined?(:payload_required?)
96
+
97
+ payload_required?
98
+ end
99
+ end
100
+ end