alula-ruby 2.6.3 → 2.8.0

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,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
 
@@ -98,6 +102,11 @@ module Alula
98
102
  def xip_family?
99
103
  self.class::XIP_FAMILY_PROGRAM_IDS.include?(program_id)
100
104
  end
105
+
106
+ def supports_onvif?
107
+ # C+Pro-SC, BAT-Mini, BAT-Mini AV
108
+ [42, 39, 43].include?(program_id)
109
+ end
101
110
  end
102
111
  end
103
112
  end
@@ -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
@@ -24,7 +24,7 @@ module Alula
24
24
  field :video_verification, type: :boolean
25
25
  field :sms_notification, type: :boolean
26
26
  field :panel_downloading, type: :boolean
27
- # field :onvif_video_hub, type: :boolean # Set by the M2M portal, we don't want it for now
27
+ field :onvif_video_hub, type: :boolean
28
28
  end
29
29
 
30
30
  resource_path 'devices'