tempoiq 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 46035694511c1eab2ca20938cfc129a20e84e0b2
4
+ data.tar.gz: 4b6a6595df82676875deaefbc406bed40bad8278
5
+ SHA512:
6
+ metadata.gz: 453cba05af4add9361f01c4449478a3436e8ca4f988441ae305d14128c5858eb2df6936531c1aa8ac2d048c3a0af9f2d5f539b6fc86b8e081cdee443db646226
7
+ data.tar.gz: 8423b80cf5a336f99757435a4f22745dfbb3d1a767a087281510fbbe4d5333a7fc9c9647bbb8f97f62aeb1354e7d91d8a81ca7eee60f49d720ec9b8724c6367a
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem 'rdoc', '>= 2.4.2'
4
+
5
+ gemspec
6
+
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # TempoIQ HTTP Ruby Client
2
+
3
+ ## Installation
4
+
5
+ ```
6
+ gem build tempoiq.gemspec
7
+ gem install tempoiq-<version>.gem
8
+ ```
9
+
10
+ ## Quickstart
11
+
12
+ ```ruby
13
+ require 'tempoiq'
14
+
15
+ client = TempoIQ::Client.new('key', 'secret', 'myco.backend.tempoiq.com')
16
+ client.create_device('device1')
17
+ client.list_devices.to_a
18
+ ```
19
+
20
+ For more example usage, see the Client ruby docs.
21
+
22
+ ## Test Suite
23
+
24
+ To run the test suite against local stubs:
25
+
26
+ ```
27
+ rake
28
+ ```
29
+
30
+ If you'd like to run the test suite against an actual live backend,
31
+ edit `test/integration/integration-credentials.yml`, and run:
32
+
33
+ ```
34
+ rake test:integration
35
+ ```
data/Rakefile ADDED
@@ -0,0 +1,28 @@
1
+ gem 'rdoc', '>= 2.4.2'
2
+ require 'rdoc/task'
3
+ require 'rake/testtask'
4
+
5
+ task :default => "test:unit"
6
+
7
+ Rake::TestTask.new do |task|
8
+ task.name = "test:unit"
9
+ task.libs << "tests"
10
+ task.test_files = FileList["test/unit/test*.rb"]
11
+ task.verbose = true
12
+ end
13
+
14
+ Rake::TestTask.new do |task|
15
+ task.name = "test:integration"
16
+ task.libs << "tests"
17
+ task.test_files = FileList["test/integration/test*.rb"]
18
+ task.verbose = true
19
+ end
20
+
21
+ desc 'Generate API documentation'
22
+ RDoc::Task.new do |rd|
23
+ rd.rdoc_files.include("README.md", "lib/**/*.rb")
24
+ rd.options << '--inline-source'
25
+ rd.options << '--line-numbers'
26
+ rd.options << '--main=README.md'
27
+ end
28
+
data/lib/tempoiq.rb ADDED
@@ -0,0 +1,7 @@
1
+ require 'rubygems'
2
+
3
+ require 'tempoiq/client'
4
+ require 'tempoiq/constants'
5
+
6
+ module TempoIQ
7
+ end
@@ -0,0 +1,450 @@
1
+ require 'rubygems'
2
+ require 'json'
3
+ require 'uri'
4
+
5
+ require 'tempoiq/models/bulk_write'
6
+ require 'tempoiq/models/cursor'
7
+ require 'tempoiq/models/datapoint'
8
+ require 'tempoiq/models/delete_summary'
9
+ require 'tempoiq/models/device'
10
+ require 'tempoiq/models/find'
11
+ require 'tempoiq/models/multi_status'
12
+ require 'tempoiq/models/pipeline'
13
+ require 'tempoiq/models/query'
14
+ require 'tempoiq/models/read'
15
+ require 'tempoiq/models/row'
16
+ require 'tempoiq/models/search'
17
+ require 'tempoiq/models/selection'
18
+ require 'tempoiq/models/single'
19
+ require 'tempoiq/remoter/live_remoter'
20
+
21
+ module TempoIQ
22
+ class ClientError < StandardError
23
+ end
24
+
25
+ MEDIA_PREFIX = "application/prs.tempoiq"
26
+
27
+ # TempoIQ::Client is the main interface to your TempoIQ backend.
28
+ #
29
+ # The client is broken down into two main sections:
30
+ #
31
+ # [Device Provisioning]
32
+ # - #create_device
33
+ # - #update_device
34
+ # - #delete_device
35
+ # - #delete_devices
36
+ # - #get_device
37
+ # - #list_devices
38
+ #
39
+ # [DataPoint Reading / Writing]
40
+ # - #write_bulk
41
+ # - #write_device
42
+ # - #read
43
+ #
44
+ # == Key Concepts:
45
+ #
46
+ # === Selection - A way to describe a grouping of related objects. Used primarily in Device / Sensor queries.
47
+ class Client
48
+ # Your TempoIQ backend key (String)
49
+ attr_reader :key
50
+
51
+ # TempoIQ backend secret (String)
52
+ attr_reader :secret
53
+
54
+ # TempoIQ backend host, found on your TempoIQ backend dashboard (String)
55
+ attr_reader :host
56
+
57
+ # Whether to use SSL or not. Defaults to true (Boolean, default: true)
58
+ attr_reader :secure
59
+
60
+ # Makes the backend calls (Remoter, default: LiveRemoter)
61
+ attr_reader :remoter
62
+
63
+ # Create a TempoIQ API Client
64
+ #
65
+ # * +key+ [String] - Your TempoIQ backend key
66
+ # * +secret+ [String] - TempoIQ backend secret
67
+ # * +host+ [String] - TempoIQ backend host, found on your TempoIQ backend dashboard
68
+ # * +port+ (optional) [Integer] - TempoIQ backend port
69
+ # * +opts+ (optional) [Hash] - Optional client parameters
70
+ #
71
+ # ==== Options
72
+ # * +:secure+ [Boolean] - Whether to use SSL or not. Defaults to true
73
+ # * +:remoter+ [Remoter] - Which backend to issue calls with. Defaults to LiveRemoter
74
+ def initialize(key, secret, host, port = 443, opts = {})
75
+ @key = key
76
+ @secret = secret
77
+ @host = host
78
+ @port = port
79
+ @secure = opts.has_key?(:secure) ? opts[:secure] : true
80
+ @remoter = opts[:remoter] || LiveRemoter.new(key, secret, host, port, secure)
81
+ end
82
+
83
+ # Create a Device in your TempoIQ backend
84
+ #
85
+ # * +key+ [String] - Device key
86
+ # * +name+ (optional) [String] - Human readable device name
87
+ # * +attributes+ (optional) [Hash] - A hash of device attributes. Keys / values are strings.
88
+ # * +sensors+ (optional) [Array] - An array of Sensor objects to attach to the device
89
+ #
90
+ # On success:
91
+ # - Returns the Device created
92
+ # On failure:
93
+ # - Raises HttpException
94
+ #
95
+ # ==== Example
96
+ #
97
+ # # Create a device keyed 'heatpump4789' with 2 attached sensors
98
+ # device = client.create_device('heatpump4789', 'Basement Heat Pump',
99
+ # 'building' => '445 W Erie', 'model' => '75ZX',
100
+ # TempoIQ::Sensor.new('temp-1'), TempoIQ::Sensor.new('pressure-1'))
101
+ def create_device(key, name = "", attributes = {}, *sensors)
102
+ device = Device.new(key, name, attributes, *sensors)
103
+ remoter.post("/v2/devices", JSON.dump(device.to_hash)).on_success do |result|
104
+ json = JSON.parse(result.body)
105
+ Device.from_hash(json)
106
+ end
107
+ end
108
+
109
+ # Fetch a device by key
110
+ #
111
+ # * +device_key+ [String] - The device key to fetch by
112
+ #
113
+ # On success:
114
+ # - Returns the Device found, nil when not found
115
+ # On failure:
116
+ # - Raises HttpException
117
+ #
118
+ # ==== Example
119
+ # # Lookup the device keyed 'heatpump4789'
120
+ # device = client.get_device('heatpump4789')
121
+ # device.sensors.each { |sensor| puts sensor.key }
122
+ def get_device(device_key)
123
+ result = remoter.get("/v2/devices/#{URI.escape(device_key)}")
124
+ case result.code
125
+ when HttpResult::OK
126
+ json = JSON.parse(result.body)
127
+ Device.from_hash(json)
128
+ when HttpResult::NOT_FOUND
129
+ nil
130
+ else
131
+ raise HttpException.new(result)
132
+ end
133
+ end
134
+
135
+ # Search for a set of devices based on Selection criteria
136
+ #
137
+ # * +selection+ - Device search criteria. See Selection.
138
+ #
139
+ # On success:
140
+ # - Return Cursor of Devices.
141
+ # On failure:
142
+ # - Raises HttpException after first Cursor iteration (lazy iteration)
143
+ #
144
+ # ==== Example
145
+ # # Select devices in building in the Evanston region
146
+ # client.list_devices(:devices => {:and => [{:attribute_key => 'building'}, {:attributes => {'region' => 'Evanston'}}]})
147
+ def list_devices(selection = {:devices => "all"}, opts = {})
148
+ query = Query.new(Search.new("devices", selection),
149
+ Find.new(opts[:limit]),
150
+ nil)
151
+ Cursor.new(Device, remoter, "/v2/devices", query, media_types(:accept => [media_type("error", "v1"), media_type("device-collection", "v2")],
152
+ :content => media_type("query", "v1")))
153
+ end
154
+
155
+ # Delete a device by key
156
+ #
157
+ # * +device_key+ [String] - The device key to delete by
158
+ #
159
+ # On succces:
160
+ # - Return true if Device found, false if Device not found
161
+ # On failure:
162
+ # - Raises HttpException
163
+ #
164
+ # ==== Example
165
+ # # Delete device keyed 'heatpump4576'
166
+ # deleted = client.delete_device('heatpump4576')
167
+ # if deleted
168
+ # puts "Device was deleted"
169
+ # end
170
+ def delete_device(device_key)
171
+ result = remoter.delete("/v2/devices/#{URI.escape(device_key)}")
172
+ case result.code
173
+ when HttpResult::OK
174
+ true
175
+ when HttpResult::NOT_FOUND
176
+ false
177
+ else
178
+ raise HttpException.new(result)
179
+ end
180
+ end
181
+
182
+ # Delete a set of devices by Selection criteria
183
+ #
184
+ # * +selection+ - Device search criteria. See Selection.
185
+ #
186
+ # On success:
187
+ # - Return a DeleteSummary object
188
+ # On failure:
189
+ # - Raises HttpException
190
+ #
191
+ # ==== Example
192
+ # # Delete all devices in building 'b4346'
193
+ # summary = client.delete_devices(:devices => {:attributes => {'building' => 'b4346'}})
194
+ # puts "Number of devices deleted: #{summary.deleted}"
195
+ def delete_devices(selection)
196
+ query = Query.new(Search.new("devices", selection),
197
+ Find.new,
198
+ nil)
199
+
200
+ remoter.delete("/v2/devices", JSON.dump(query.to_hash)).on_success do |result|
201
+ json = JSON.parse(result.body)
202
+ DeleteSummary.new(json['deleted'])
203
+ end
204
+ end
205
+
206
+ # Update a device
207
+ #
208
+ # * +device+ - Updated Device object.
209
+ #
210
+ # On success:
211
+ # - Return updated Device on found, nil on Device not found
212
+ # On failure:
213
+ # - Raises HttpException
214
+ #
215
+ # ==== Example
216
+ #
217
+ # # Get a device and update it's name
218
+ # device = client.get_device('building1234')
219
+ # if device
220
+ # device.name = "Updated name"
221
+ # client.update_device(device)
222
+ # end
223
+ def update_device(device)
224
+ remoter.put("/v2/devices/#{URI.escape(device.key)}", JSON.dump(device.to_hash)).on_success do |result|
225
+ json = JSON.parse(result.body)
226
+ Device.from_hash(json)
227
+ end
228
+ end
229
+
230
+ # Write multiple datapoints to multiple device sensors. This function
231
+ # is generally useful for importing data to many devices at once.
232
+ #
233
+ # * +bulk_write+ - The write request to send to the backend. Yielded to the block.
234
+ #
235
+ # On success:
236
+ # - Returns MultiStatus
237
+ # On partial success:
238
+ # - Returns MultiStatus
239
+ # On failure:
240
+ # - Raises HttpException
241
+ #
242
+ # ==== Example
243
+ # # Write to 'device1' and 'device2' with different sensor readings
244
+ # status = client.write_bulk do |write|
245
+ # ts = Time.now
246
+ # write.add('device1', 'temp1', TempoIQ::DataPoint.new(ts, 1.23))
247
+ # write.add('device2', 'temp1', TempoIQ::DataPoint.new(ts, 2.34))
248
+ # end
249
+ #
250
+ # if status.succes?
251
+ # puts "All datapoints written successfully"
252
+ # elsif status.partial_success?
253
+ # status.failures.each do |device_key, message|
254
+ # puts "Failed to write #{device_key}, message: #{message}"
255
+ # end
256
+ # end
257
+ def write_bulk(bulk_write = nil, &block)
258
+ bulk = bulk_write || BulkWrite.new
259
+ if block_given?
260
+ yield bulk
261
+ elsif bulk_write.nil?
262
+ raise ClientError.new("You must pass either a bulk write object, or provide a block")
263
+ end
264
+
265
+ result = remoter.post("/v2/write", JSON.dump(bulk.to_hash))
266
+ if result.code == HttpResult::OK
267
+ MultiStatus.new
268
+ elsif result.code == HttpResult::MULTI
269
+ json = JSON.parse(result.body)
270
+ MultiStatus.new(json)
271
+ else
272
+ raise HttpException.new(result)
273
+ end
274
+ end
275
+
276
+ # Write to multiple sensors in a single device, at the same timestamp. Useful for
277
+ # 'sampling' from all the sensors on a device and ensuring that the timestamps align.
278
+ #
279
+ # * +device_key+ [String] - Device key to write to
280
+ # * +ts+ [Time] - Timestamp that datapoints will be written at
281
+ # * +values+ [Hash] - Hash from sensor_key => value
282
+ #
283
+ # On success:
284
+ # - Return true
285
+ # On failure:
286
+ # - Raises HttpException
287
+ #
288
+ # ==== Example
289
+ #
290
+ # ts = Time.now
291
+ # status = client.write_device('device1', ts, 'temp1' => 4.0, 'temp2' => 4.2)
292
+ # if status.succes?
293
+ # puts "All datapoints written successfully"
294
+ # end
295
+ def write_device(device_key, ts, values)
296
+ bulk = BulkWrite.new
297
+ values.each do |sensor_key, value|
298
+ bulk.add(device_key, sensor_key, DataPoint.new(ts, value))
299
+ end
300
+ write_bulk(bulk).success?
301
+ end
302
+
303
+ # Read from a set of Devices / Sensors, with an optional functional pipeline
304
+ # to transform the values.
305
+ #
306
+ # * +selection+ [Selection] - Device selection, describes which Devices / Sensors we should operate on
307
+ # * +start+ [Time] - Read start interval
308
+ # * +stop+ [Time] - Read stop interval
309
+ # * +pipeline+ [Pipeline] (optional)- Functional pipeline transformation. Supports analytic computation on a stream of DataPoints.
310
+ #
311
+ # On success:
312
+ # - Return a Cursor of Row objects
313
+ # On failure:
314
+ # - Raise an HttpException
315
+ #
316
+ # ==== Examples
317
+ # # Read raw datapoints from Device 'bulding4567' Sensor 'temp1'
318
+ # start = Time.utc(2014, 1, 1)
319
+ # stop = Time.utc(2014, 1, 2)
320
+ # rows = client.read({:devices => {:key => 'building4567'}, :sensors => {:key => 'temp1'}}, start, stop)
321
+ # rows.each do |row|
322
+ # puts "Data at timestamp: #{row.ts}, value: #{row.value('building4567', 'temp1')}"
323
+ # end
324
+ #
325
+ # # Find the daily mean temperature in Device 'building4567' across sensors 'temp1' and 'temp2'
326
+ # start = Time.utc(2014, 1, 1)
327
+ # stop = Time.utc(2014, 2, 2)
328
+ # rows = client.read({:devices => {:key => 'building4567'}, :sensors => {:key => 'temp1'}}, start, stop) do |pipeline|
329
+ # pipeline.rollup("1day", :mean, start)
330
+ # pipeline.aggregate(:mean)
331
+ # end
332
+ #
333
+ # rows.each do |row|
334
+ # puts "Data at timestamp: #{row.ts}, value: #{row.value('building4567', 'temp1')}"
335
+ # end
336
+ def read(selection, start, stop, pipeline = Pipeline.new, opts = {}, &block)
337
+ if block_given?
338
+ yield pipeline
339
+ end
340
+
341
+ query = Query.new(Search.new("devices", selection),
342
+ Read.new(start, stop, opts[:limit]),
343
+ pipeline)
344
+
345
+ Cursor.new(Row, remoter, "/v2/read", query, media_types(:accept => [media_type("error", "v1"), media_type("datapoint-collection", "v2")],
346
+ :content => media_type("query", "v1")))
347
+ end
348
+
349
+ # Read the latest value from a set of Devices / Sensors, with an optional functional pipeline
350
+ # to transform the values.
351
+ #
352
+ # * +selection+ [Selection] - Device selection, describes which Devices / Sensors we should operate on
353
+ # * +pipeline+ [Pipeline] (optional)- Functional pipeline transformation. Supports analytic computation on a stream of DataPoints.
354
+ #
355
+ # On success:
356
+ # - Return a Cursor of Row objects with only one Row inside
357
+ # On failure:
358
+ # - Raise an HttpException
359
+ #
360
+ # ==== Example
361
+ # # Find the latest DataPoints from Device 'bulding4567' Sensor 'temp1'
362
+ # rows = client.latest({:devices => {:key => 'building4567'}, :sensors => {:key => 'temp1'}})
363
+ # rows.each do |row|
364
+ # puts "Data at timestamp: #{row.ts}, value: #{row.value('building4567', 'temp1')}"
365
+ # end
366
+ def latest(selection, pipeline = Pipeline.new, &block)
367
+ if block_given?
368
+ yield pipeline
369
+ end
370
+
371
+ query = Query.new(Search.new("devices", selection),
372
+ Single.new(false),
373
+ pipeline)
374
+
375
+ Cursor.new(Row, remoter, "/v2/single", query)
376
+ end
377
+
378
+ # Delete datapoints by device and sensor key, start and stop date
379
+ #
380
+ # + *device_key* [String] - Device key to read from
381
+ # + *sensor_key* [String] - Sensor key to read from
382
+ # * +start+ [Time] - Read start interval
383
+ # * +stop+ [Time] - Read stop interval
384
+ #
385
+ # On success:
386
+ # _ Return a DeleteSummary describing the number of points deleted
387
+ # On failure:
388
+ # - Raise an HttpException
389
+ #
390
+ # ==== Example
391
+ # # Delete data from 'device1', 'temp' from 2013
392
+ # start = Time.utc(2013, 1, 1)
393
+ # stop = Time.utc(2013, 12, 31)
394
+ # summary = client.delete_datapoints('device1', 'temp', start, stop)
395
+ # puts "Deleted #{summary.deleted} points"
396
+ def delete_datapoints(device_key, sensor_key, start, stop)
397
+ delete_range = {:start => start.iso8601(3), :stop => stop.iso8601(3)}
398
+ result = remoter.delete("/v2/devices/#{URI.escape(device_key)}/sensors/#{URI.escape(sensor_key)}/datapoints", JSON.dump(delete_range))
399
+ case result.code
400
+ when HttpResult::OK
401
+ json = JSON.parse(result.body)
402
+ DeleteSummary.new(json['deleted'])
403
+ else
404
+ raise HttpException.new(result)
405
+ end
406
+ end
407
+
408
+ # Convenience function to read from a single Device, and single Sensor
409
+ #
410
+ # + *device_key* [String] - Device key to read from
411
+ # + *sensor_key* [String] - Sensor key to read from
412
+ # * +start+ [Time] - Read start interval
413
+ # * +stop+ [Time] - Read stop interval
414
+ # * +pipeline+ [Pipeline] (optional)- Functional pipeline transformation. Supports analytic computation on a stream of DataPoints.
415
+ #
416
+ # On success:
417
+ # - Return a Cursor of DataPoint objects.
418
+ # On failure:
419
+ # - Raise an HttpException
420
+ #
421
+ # ==== Example
422
+ # # Read from 'device1', 'temp1'
423
+ # start = Time.utc(2014, 1, 1)
424
+ # stop = Time.utc(2014, 1, 2)
425
+ # datapoints = client.read_device_sensor('device1', 'temp1', start, stop)
426
+ # datapoints.each do |point|
427
+ # puts "DataPoint ts: #{point.ts}, value: #{point.value}"
428
+ # end
429
+ def read_device_sensor(device_key, sensor_key, start, stop, pipeline = nil, &block)
430
+ selection = {:devices => {:key => device_key}, :sensors => {:key => sensor_key}}
431
+ read(selection, start, stop, pipeline).map do |row|
432
+ sub_key = row.values.map { |device_key, sensors| sensors.keys.first }.first || sensor_key
433
+ DataPoint.new(row.ts, row.value(device_key, sub_key))
434
+ end
435
+ end
436
+
437
+ private
438
+
439
+ def media_types(types)
440
+ {
441
+ "Accept" => types[:accept],
442
+ "Content-Type" => types[:content]
443
+ }
444
+ end
445
+
446
+ def media_type(media_resource, media_version, suffix = "json")
447
+ "#{MEDIA_PREFIX}.#{media_resource}.#{media_version}+#{suffix}"
448
+ end
449
+ end
450
+ end