ecobee 0.2.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: f15c138cad29178c3935acfdffc88446ca994d35
4
- data.tar.gz: 58581cf67d84e8e0e43b83b2375069aa70d41a9b
3
+ metadata.gz: 4e2b2a04ef326e3e40a7e7c4191de520a5792d4d
4
+ data.tar.gz: 88ff1e1a5805e3a157b0c7b5ce093210d3ab8393
5
5
  SHA512:
6
- metadata.gz: 6cb824a3793ecd6582479f00c94fad5d3819d4a7da373abdadbed6fdf7bb108a9072e7091780fb6f42842d60c648e827750a53e4a77c9455d3b59bc09faf325d
7
- data.tar.gz: 5f8afab66b9bb155caf5b6f4691a72a8ada13765cfe64d3f83555bfcd910473d6f9d51115d87e81fbb8804593f90ed17d5f01c5ef0ecc04cd06c2d994358e9ed
6
+ metadata.gz: 5b902bc54d038513952d96a1e0b962a65b2748d8ff80447c700eb62ba6b793c15fb9bc907db76fff960ae656b08a4c3f3610699331fc5783f2902a37a9fe0a89
7
+ data.tar.gz: 445de99fe41e275297d56859752c232e940da0604e43ec13881d09a4d6ab3b3f0fc2b3f3ce9a91b4cebf044c470881f6847b66d26d33841575df17edd096c2cc
data/README.md CHANGED
@@ -2,21 +2,23 @@
2
2
 
3
3
  Ecobee API Ruby Gem. Implements:
4
4
  - OAuth PIN-based token registration & renewal
5
- - Persistent HTTP connection
6
- - Methods for GET & POST requests
5
+ - Persistent HTTP connection management
6
+ - Methods for GET & POST requests w/ JSON parsing & error handling
7
7
  - Persistent storage for API key & refresh tokens
8
8
  - Example usage scripts (see /examples/\*)
9
9
 
10
- Status:
11
- - Working, but is very basic. Contact me with feature requests.
12
-
13
10
  TODO:
14
- - Document API
15
- - Convert token storage to optional block/proc
11
+ - Move all HTTP interaction to a dedicated/shared class
12
+ - Add and test more robust Status != 0 handling via Exceptions; document
13
+ - Add retries for specific !0 statuses
14
+ - Add random timeout padding to avoid race conditions with multiple clients when refreshing tokens
15
+ - Add RDoc documentation
16
+ - Add block/proc support to token storage routines
16
17
  - Add timeout to Ecobee::Token#wait
17
18
  - Add redirect based registration
18
- - Implement throttling / blocking
19
- - Helper methods/classes for building/reading requests
19
+ - Implement throttling algorithm based on API feedback
20
+ - Create helper methods/classes with more abstraction
21
+ - Create examples of proper error handling
20
22
 
21
23
  ## Installation
22
24
 
data/examples/set_mode.rb CHANGED
@@ -5,8 +5,7 @@
5
5
 
6
6
  require 'pp'
7
7
 
8
- #require 'ecobee'
9
- require_relative '../lib/ecobee.rb'
8
+ require_relative '../lib/ecobee'
10
9
 
11
10
  @hvac_modes = Ecobee::HVAC_MODES + ['quit']
12
11
 
@@ -16,17 +15,11 @@ class TestFunctions
16
15
  end
17
16
 
18
17
  def print_summary
19
- response = @client.get(
20
- 'thermostat',
21
- {
22
- 'selection' => {
23
- 'selectionType' => 'registered',
24
- 'selectionMatch' => '',
25
- 'includeEquipmentStatus' => 'true',
26
- 'includeSettings' => 'true'
27
- }
28
- }
29
- )
18
+ response = @client.get(:thermostat,
19
+ Ecobee::Selection(
20
+ includeEquipmentStatus: true,
21
+ includeSettings: true
22
+ ))
30
23
 
31
24
  puts "Found %d thermostats." % response['thermostatList'].length
32
25
 
@@ -43,30 +36,22 @@ class TestFunctions
43
36
  end
44
37
 
45
38
  def update_mode(mode)
46
- @client.post(
47
- 'thermostat',
48
- body: {
49
- 'selection' => {
50
- 'selectionType' => 'registered',
51
- 'selectionMatch' => '',
52
- },
53
- 'thermostat' => {
54
- 'settings' => {
55
- 'hvacMode' => mode
56
- }
57
- }
58
- }
59
- )
39
+ @client.post('thermostat',
40
+ body: {
41
+ 'thermostat' => {
42
+ 'settings' => { 'hvacMode' => mode }
43
+ }
44
+ }.merge(Ecobee::Selection()))
60
45
  end
61
46
  end
62
47
 
63
- token = Ecobee::Token.new(
64
- app_key: ENV['ECOBEE_APP_KEY'],
65
- app_name: 'ecobee-gem'
66
- )
48
+ token = Ecobee::Token.new(app_key: ENV['ECOBEE_APP_KEY'])
49
+
50
+ if token.pin
51
+ puts token.pin_message
52
+ token.wait
53
+ end
67
54
 
68
- puts token.pin_message if token.pin
69
- token.wait
70
55
  test_functions = TestFunctions.new(
71
56
  Ecobee::Client.new(token: token)
72
57
  )
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ class TestSave
4
+ require 'pp'
5
+
6
+ def initialize
7
+ @callbacks = {}
8
+ Thread.new {
9
+ loop do
10
+ run_hooks
11
+ sleep 5
12
+ end
13
+ }
14
+ end
15
+
16
+
17
+ def register(type, *callback, &block)
18
+ if block_given?
19
+ @callbacks[type] = block
20
+ else
21
+ @callbacks[type] = callback[0] if callback.length > 0
22
+ end
23
+ end
24
+
25
+ def run_hooks
26
+ if rand(2) == 0
27
+ puts "Running Load"
28
+ token_data_load
29
+ else
30
+ puts "Running Save"
31
+ token_data_save
32
+ end
33
+ end
34
+
35
+ def token_data_load
36
+ load_data = read_from_file if @save_files_enabled
37
+ if @callbacks[:load].respond_to? :call
38
+ load_data = @callbacks[:load].call(load_data)
39
+ end
40
+ process_load_data load_data if load_data
41
+ end
42
+
43
+ def token_data_save
44
+ save_data = get_save_data
45
+ if @callbacks[:save].respond_to? :call
46
+ save_data = @callbacks[:save].call(save_data)
47
+ end
48
+ write_to_file save_data if @save_files_enabled
49
+ end
50
+
51
+ end
52
+
53
+ # Load:
54
+ # - Reads file
55
+ # - callback filter
56
+ # - Processes to memory
57
+ #
58
+ # Save:
59
+ # - Processes from memory
60
+ # - callback filter
61
+ # - writes to file (read all file; overwrite only this section; write)
62
+
63
+ # access_token
64
+ # - is it expired?
65
+ # - No: use existing
66
+ # - Yes: re-load from file; doublecheck
67
+
68
+ # refresh - only run if token is expired or via init if @refresh_token supplied (?)
69
+ # - gets new token
70
+ # - loads to memory
71
+ # - runs save
72
+
73
+ # config elements:
74
+ #
75
+ # app_key
76
+ #
77
+ # (@status = :ready)
78
+ # access_token
79
+ # access_token_expire
80
+ # refresh_token
81
+ # scope (read-only)
82
+ # token_type (read-only)
83
+ # (@status = :authorization_pending)
84
+ # pin
85
+ # code
86
+ # code_expire
87
+
88
+ config[:pin]
89
+ config[:code]
90
+ config[:code_expire]
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'pp'
4
+ require 'benchmark'
5
+
6
+ require_relative '../lib/ecobee'
7
+
8
+ token = Ecobee::Token.new(app_key: ENV['ECOBEE_APP_KEY'])
9
+ if token.pin
10
+ puts token.pin_message
11
+ token.wait
12
+ end
13
+
14
+ thermostat = Ecobee::Thermostat.new(token: token)
15
+
16
+ puts "Mode: " + thermostat[:settings][:hvacMode]
17
+
18
+ #thermostat[:devices][0][:sensors].each do |sensor|
19
+ # puts "Sensor: #{sensor[:name]}"
20
+ #end
21
+
22
+ #puts thermostat.keys
23
+
24
+ #thermostat[:program][:climates].each do |climate|
25
+ # puts "Climate: #{climate[:name]}"
26
+ #end
27
+ #
@@ -3,18 +3,33 @@
3
3
  # Refreshes token; displays details on saved token. -- @robzr
4
4
 
5
5
  require 'pp'
6
- require_relative '../lib/ecobee.rb'
6
+
7
+ require_relative '../lib/ecobee'
8
+
9
+ load_lambda = lambda do |config|
10
+ config
11
+ end
12
+
13
+ save_lambda = lambda do |config|
14
+ config
15
+ end
7
16
 
8
17
  token = Ecobee::Token.new(
9
18
  app_key: ENV['ECOBEE_APP_KEY'],
10
- app_name: 'ecobee-gem'
19
+ callbacks: {
20
+ load: load_lambda,
21
+ save: save_lambda
22
+ }
11
23
  )
12
24
 
13
25
  puts token.pin_message if token.pin
14
26
  token.wait
15
27
 
28
+ token.config_save
29
+
30
+ puts "APP Key: #{token.app_key}"
16
31
  puts "Access Token: #{token.access_token}"
17
32
  puts "Refresh Token: #{token.refresh_token}"
18
- puts "Expires At: #{token.access_expires_at}"
33
+ puts "Expires At: #{token.access_token_expire}"
19
34
  puts "Scope: #{token.scope}"
20
- puts "Type: #{token.type}"
35
+ puts "Type: #{token.token_type}"
data/lib/ecobee.rb CHANGED
@@ -2,33 +2,65 @@ require 'pp'
2
2
  require 'json'
3
3
  require 'net/http'
4
4
 
5
- require 'ecobee/client'
6
- require 'ecobee/register'
7
- require 'ecobee/token'
8
- require 'ecobee/version'
9
- #require_relative 'ecobee/client'
10
- #require_relative 'ecobee/register'
11
- #require_relative 'ecobee/token'
12
- #require_relative 'ecobee/version'
5
+ #require 'ecobee/client'
6
+ #require 'ecobee/register'
7
+ #require 'ecobee/token'
8
+ #require 'ecobee/version'
9
+
10
+ require_relative 'ecobee/client'
11
+ require_relative 'ecobee/register'
12
+ require_relative 'ecobee/thermostat'
13
+ require_relative 'ecobee/token'
14
+ require_relative 'ecobee/version'
13
15
 
14
16
  module Ecobee
15
17
  API_HOST = 'api.ecobee.com'
16
18
  API_PORT = 443
19
+
17
20
  CONTENT_TYPE = ['application/json', { 'charset' => 'UTF-8' }]
21
+
18
22
  DEFAULT_FILES = [
19
23
  '~/Library/Mobile Documents/com~apple~CloudDocs/.ecobee_token',
20
24
  '~/.ecobee_token'
21
25
  ]
22
- HVAC_MODES = ['auto', 'auxHeatOnly', 'cool', 'heat', 'off']
23
- REFRESH_INTERVAL_PAD = 120
26
+
27
+ AUTH_ERRORS = %w{
28
+ authorization_expired
29
+ authorization_pending
30
+ invalid_client
31
+ slow_down
32
+ }
33
+
34
+ FAN_MODES = %w{auto on}
35
+
36
+ HVAC_MODES = %w{auto auxHeatOnly cool heat off}
37
+
38
+ REFRESH_PAD = 120
24
39
  REFRESH_TOKEN_CHECK = 10
40
+
25
41
  SCOPES = [:smartWrite, :smartRead]
42
+
26
43
  URL_BASE= "https://#{API_HOST}:#{API_PORT}"
27
44
  URL_API = "#{URL_BASE}/1/"
28
45
  URL_GET_PIN = URL_BASE +
29
46
  '/authorize?response_type=ecobeePin&client_id=%s&scope=%s'
30
47
  URL_TOKEN = "#{URL_BASE}/token"
31
48
 
49
+ def self.FanMode(mode)
50
+ { 'auto' => 'Auto',
51
+ 'on' => 'On'
52
+ }.fetch(mode, 'Unknown')
53
+ end
54
+
55
+ def self.Mode(mode)
56
+ { 'auto' => 'Auto',
57
+ 'auxHeatOnly' => 'Aux Heat Only',
58
+ 'cool' => 'Cool',
59
+ 'heat' => 'Heat',
60
+ 'off' => 'Off'
61
+ }.fetch(mode, 'Unknown')
62
+ end
63
+
32
64
  def self.Model(model)
33
65
  { 'idtSmart' => 'ecobee Smart',
34
66
  'idtEms' => 'ecobee Smart EMS',
@@ -37,7 +69,7 @@ module Ecobee
37
69
  'athenaSmart' => 'ecobee3 Smart',
38
70
  'athenaEms' => 'ecobee3 EMS',
39
71
  'corSmart' => 'Carrier or Bryant Cor',
40
- }[model] || 'Unknown'
72
+ }.fetch(model, "Unknown (#{model})")
41
73
  end
42
74
 
43
75
  def self.ResponseCode(code)
@@ -59,7 +91,7 @@ module Ecobee
59
91
  15 => 'Duplicate data violation.',
60
92
  16 => 'Invalid token. Token has been deauthorized by user. You must ' +
61
93
  're-request authorization.'
62
- }[code] || 'Unknown Error.'
94
+ }.fetch(code.to_i, 'Unknown Error.')
63
95
  end
64
96
 
65
97
  def self.Selection(arg = {})
data/lib/ecobee/client.rb CHANGED
@@ -8,27 +8,36 @@ module Ecobee
8
8
  end
9
9
 
10
10
  def get(arg, options = nil)
11
- new_uri = URL_API + arg.sub(/^\//, '')
11
+ new_uri = URL_API + arg.to_s.sub(/^\//, '')
12
12
  new_uri += '?json=' + options.to_json if options
13
-
14
13
  request = Net::HTTP::Get.new(URI(URI.escape(new_uri)))
15
14
  request['Content-Type'] = *CONTENT_TYPE
16
15
  request['Authorization'] = @token.authorization
17
16
  http_response = http.request request
18
- validate_status JSON.parse(http_response.body)
17
+ response = validate_status JSON.parse(http_response.body)
18
+ # if response == :retry
19
+ # get(arg, options)
20
+ # else
21
+ # response
22
+ # end
19
23
  rescue JSON::ParserError => msg
20
24
  raise ClientError.new("JSON::ParserError => #{msg}")
21
25
  end
22
26
 
23
27
  def post(arg, options: {}, body: nil)
24
- new_uri = URL_API + arg.sub(/^\//, '')
28
+ new_uri = URL_API + arg.to_s.sub(/^\//, '')
25
29
  request = Net::HTTP::Post.new(URI new_uri)
26
30
  request.set_form_data({ 'format' => 'json' }.merge(options))
27
31
  request.body = JSON.generate(body) if body
28
32
  request['Content-Type'] = *CONTENT_TYPE
29
33
  request['Authorization'] = @token.authorization
30
34
  http_response = http.request request
31
- validate_status JSON.parse http_response.body
35
+ response = validate_status JSON.parse http_response.body
36
+ # if response == :retry
37
+ # post(arg, options: options, body: body)
38
+ # else
39
+ # response
40
+ # end
32
41
  rescue JSON::ParserError => msg
33
42
  raise ClientError.new("JSON::ParserError => #{msg}")
34
43
  end
@@ -38,6 +47,8 @@ module Ecobee
38
47
  raise ClientError.new('Missing Status')
39
48
  elsif !response['status'].key? 'code'
40
49
  raise ClientError.new('Missing Status Code')
50
+ elsif response['status']['code'] == 14
51
+ :retry
41
52
  elsif response['status']['code'] != 0
42
53
  raise ClientError.new(
43
54
  "GET Error: #{response['status']['code']} " +
@@ -1,13 +1,11 @@
1
1
  module Ecobee
2
- require 'date'
3
-
4
2
  class Register
5
3
  attr_reader :expires_at, :result
6
4
 
7
5
  def initialize(app_key: nil, scope: SCOPES[0])
8
6
  raise ArgumentError.new('Missing app_key') unless app_key
9
7
  @result = get_pin(app_key: app_key, scope: scope)
10
- @expires_at = DateTime.now.strftime('%s').to_i + result['expires_in'] * 60
8
+ @expires_at = Time.now.to_i + result['expires_in'] * 60
11
9
  end
12
10
 
13
11
  def code
@@ -33,18 +31,20 @@ module Ecobee
33
31
  result = JSON.parse Net::HTTP.get(uri_pin)
34
32
  if result.key? 'error'
35
33
  raise Ecobee::TokenError.new(
36
- "Register Error: (%s) %s" % [result['error'], result['error_description']]
34
+ sprintf("Register Error: (%s) %s",
35
+ result['error'],
36
+ result['error_description'])
37
37
  )
38
+ else
39
+ result
38
40
  end
39
- result
40
41
  rescue SocketError => msg
41
42
  raise Ecobee::TokenError.new("GET failed: #{msg}")
42
43
  rescue JSON::ParserError => msg
43
44
  raise Ecobee::TokenError.new("Parse Error: #{msg}")
44
- rescue Exception => msg
45
- raise Ecobee::TokenError.new("Unknown Error: #{msg}")
45
+ # rescue Exception => msg
46
+ # raise Ecobee::TokenError.new("Unknown Error: #{msg}")
46
47
  end
47
48
 
48
49
  end
49
-
50
50
  end
@@ -0,0 +1,212 @@
1
+ module Ecobee
2
+ class ThermostatError < StandardError ; end
3
+
4
+ class Thermostat < Hash
5
+
6
+ DEFAULT_SELECTION_ARGS = {
7
+ includeRuntime: true,
8
+ includeExtendedRuntime: true,
9
+ includeElectricity: true,
10
+ includeSettings: true,
11
+ includeLocation: true,
12
+ includeProgram: true,
13
+ includeEvents: true,
14
+ includeDevice: true,
15
+ includeTechnician: true,
16
+ includeUtility: true,
17
+ includeAlerts: true,
18
+ includeWeather: true,
19
+ includeOemConfig: true,
20
+ includeEquipmentStatus: true,
21
+ includeNotificationSettings: true,
22
+ includeVersion: true,
23
+ includeSensors: true
24
+ }
25
+
26
+ attr_accessor :client
27
+ attr_reader :auto_refresh, :orig_response
28
+
29
+ def initialize(
30
+ auto_refresh: 0,
31
+ client: nil,
32
+ fake_index: nil,
33
+ index: 0,
34
+ fake_max_index: 0,
35
+ selection: nil,
36
+ selection_args: {},
37
+ token: nil,
38
+ to_sym: true
39
+ )
40
+ @auto_refresh = auto_refresh
41
+ # TODO: add auto-refresh thread handling
42
+ @client = client || Ecobee::Client.new(token: token)
43
+ @to_sym = to_sym
44
+ @fake_index = fake_index
45
+ @fake_max_index = fake_max_index
46
+ @index = index
47
+ @selection ||= Ecobee::Selection(
48
+ DEFAULT_SELECTION_ARGS.merge(selection_args)
49
+ )
50
+ refresh
51
+ end
52
+
53
+ def cool_range(with_delta: false)
54
+ if with_delta
55
+ low_range = [@orig_response['settings']['coolRangeLow'] / 10,
56
+ desired_heat + heat_cool_min_delta].max
57
+ else
58
+ low_range = @orig_response['settings']['coolRangeLow'] / 10
59
+ end
60
+ (low_range..@orig_response['settings']['coolRangeHigh'] / 10)
61
+ end
62
+
63
+ def heat_cool_min_delta
64
+ @orig_response['settings']['heatCoolMinDelta'] / 10
65
+ end
66
+
67
+ def desired_cool
68
+ @orig_response['runtime']['desiredCool'] / 10
69
+ end
70
+
71
+ def desired_cool=(temp)
72
+ set_hold(cool_hold_temp: temp.to_i * 10)
73
+ end
74
+
75
+ def desired_fan_mode
76
+ @orig_response['runtime']['desiredFanMode']
77
+ end
78
+
79
+ def desired_fan_mode=(fan)
80
+ set_hold(fan: fan)
81
+ end
82
+
83
+ def desired_heat
84
+ @orig_response['runtime']['desiredHeat'] / 10
85
+ end
86
+
87
+ def desired_heat=(temp)
88
+ set_hold(heat_hold_temp: temp.to_i * 10)
89
+ end
90
+
91
+ def desired_range
92
+ (desired_heat..desired_cool)
93
+ end
94
+
95
+ def heat_range(with_delta: false)
96
+ if with_delta
97
+ high_range = [@orig_response['settings']['heatRangeHigh'] / 10,
98
+ desired_cool - heat_cool_min_delta].min
99
+ else
100
+ high_range = @orig_response['settings']['heatRangeHigh'] / 10
101
+ end
102
+ (@orig_response['settings']['heatRangeLow'] / 10..high_range)
103
+ end
104
+
105
+ def index
106
+ @fake_index || @index
107
+ end
108
+
109
+ def max_index
110
+ [@fake_index || 0, @max_index, @fake_max_index].max
111
+ end
112
+
113
+ def mode
114
+ @orig_response['settings']['hvacMode']
115
+ end
116
+
117
+ def mode=(mode)
118
+ set_mode(mode)
119
+ end
120
+
121
+ def model
122
+ Ecobee::Model(@orig_response['modelNumber'])
123
+ end
124
+
125
+ def my_selection
126
+ {
127
+ 'selection' => {
128
+ 'selectionType' => 'thermostats',
129
+ 'selectionMatch' => @orig_response['identifier']
130
+ }
131
+ }
132
+ end
133
+
134
+ def name
135
+ if @fake_index
136
+ "Fake No. #{@fake_index}"
137
+ else
138
+ @orig_response['name']
139
+ end
140
+ end
141
+
142
+ def refresh
143
+ response = @client.get(:thermostat, @selection)
144
+ if @index + 1 > response['thermostatList'].length
145
+ raise ThermostatError.new('No such thermostat')
146
+ end
147
+ @max_index = response['thermostatList'].length - 1
148
+ @orig_response = response['thermostatList'][@index]
149
+
150
+ self.replace(@to_sym ? to_sym(@orig_response) : @orig_response)
151
+ end
152
+
153
+ def humidity
154
+ @orig_response['runtime']['actualHumidity']
155
+ end
156
+
157
+ def temperature
158
+ @orig_response['runtime']['actualTemperature'] / 10.0
159
+ end
160
+
161
+ def to_sym?
162
+ @to_sym
163
+ end
164
+
165
+ def to_sym(obj = self)
166
+ if obj.is_a? Hash
167
+ Hash[obj.map do |key, val|
168
+ if key.is_a? String
169
+ [key.to_sym, to_sym(val)]
170
+ else
171
+ [key, to_sym(val)]
172
+ end
173
+ end]
174
+ elsif obj.is_a? Array
175
+ obj.map { |item| to_sym(item) }
176
+ else
177
+ obj
178
+ end
179
+ end
180
+
181
+ def set_hold(
182
+ cool_hold_temp: @orig_response['runtime']['desiredCool'],
183
+ fan: nil,
184
+ heat_hold_temp: @orig_response['runtime']['desiredHeat'],
185
+ hold_type: 'nextTransition'
186
+ )
187
+ params = {
188
+ 'holdType' => 'nextTransition',
189
+ 'coolHoldTemp' => cool_hold_temp,
190
+ 'heatHoldTemp' => heat_hold_temp
191
+ }
192
+ params.merge!({ 'fan' => fan }) if fan
193
+
194
+ update(functions: [{ 'type' => 'setHold', 'params' => params }])
195
+ end
196
+
197
+ def set_mode(mode)
198
+ update(thermostat: { 'settings' => { 'hvacMode' => mode } })
199
+ end
200
+
201
+ def update(
202
+ functions: nil,
203
+ thermostat: nil
204
+ )
205
+ body = my_selection
206
+ body.merge!({ 'functions' => functions }) if functions
207
+ body.merge!({ 'thermostat' => thermostat }) if thermostat
208
+ @client.post(:thermostat, body: body)
209
+ end
210
+
211
+ end
212
+ end
data/lib/ecobee/token.rb CHANGED
@@ -1,63 +1,77 @@
1
1
  module Ecobee
2
- require 'date'
3
-
4
2
  class Token
5
- attr_reader :access_expires_at,
6
- :access_token,
3
+ attr_reader :access_token,
4
+ :access_token_expire,
5
+ :app_key,
6
+ :callbacks,
7
7
  :pin,
8
- :pin_message,
9
8
  :refresh_token,
10
9
  :result,
11
10
  :status,
12
11
  :scope,
13
- :type
12
+ :token_type
13
+
14
+ #AUTH_ERRORS = %w(slow_down authorization_pending authorization_expired)
15
+ #VALID_STATUS = [:authorization_pending, :ready]
14
16
 
15
17
  def initialize(
16
- access_expires_at: nil,
17
18
  access_token: nil,
19
+ access_token_expire: nil,
18
20
  app_key: nil,
19
- app_name: nil,
20
- code: nil,
21
+ callbacks: {},
21
22
  refresh_token: nil,
22
23
  scope: SCOPES[0],
23
24
  token_file: DEFAULT_FILES
24
25
  )
25
- @access_expires_at = access_expires_at
26
26
  @access_token = access_token
27
+ @access_token_expire = access_token_expire
27
28
  @app_key = app_key
28
- @app_name = app_name
29
- @code = code
29
+ @callbacks = callbacks
30
30
  @refresh_token = refresh_token
31
31
  @scope = scope
32
32
  @token_file = expand_files token_file
33
33
 
34
- @code_expires_at, @pin, @type = nil
35
- parse_token_file
36
- @status = @refresh_token ? :ready : :authorization_pending
34
+ @authorization_thread, @pin, @status, @token_type = nil
37
35
 
38
- if @refresh_token
39
- refresh
40
- else
41
- register unless pin_is_valid
42
- check_for_token
43
- launch_monitor_thread unless @status == :ready
44
- end
36
+ @refresh_pad = REFRESH_PAD + rand(REFRESH_PAD)
37
+
38
+ config_load
39
+ access_token()
45
40
  end
46
41
 
47
42
  def access_token
48
- refresh
49
- @access_token
43
+ if(@access_token && @access_token_expire &&
44
+ Time.now.to_i + @refresh_pad < @access_token_expire)
45
+ @status = :ready
46
+ @access_token
47
+ else
48
+ refresh_access_token
49
+ end
50
50
  end
51
51
 
52
52
  def authorization
53
- "#{@type} #{@access_token}"
53
+ "#{@token_type} #{access_token}"
54
+ end
55
+
56
+ def config_load
57
+ config = config_read_our_section
58
+ if @callbacks[:load].respond_to? :call
59
+ config = @callbacks[:load].call(config)
60
+ end
61
+ config_load_to_memory config
62
+ end
63
+
64
+ def config_save
65
+ config = config_dump()
66
+ if @callbacks[:save].respond_to? :call
67
+ config = @callbacks[:save].call(config)
68
+ end
69
+ config_write_section config
54
70
  end
55
71
 
56
72
  def pin_is_valid
57
- if @pin && @code && @code_expires_at
58
- @code_expires_at.to_i >= DateTime.now.strftime('%s').to_i
59
- else
60
- false
73
+ if @pin && @access_token && @access_token_expire
74
+ @access_token_expire.to_i >= Time.now.to_i
61
75
  end
62
76
  end
63
77
 
@@ -66,37 +80,51 @@ module Ecobee
66
80
  "enter the PIN #{@pin || ''}"
67
81
  end
68
82
 
69
- def refresh
70
- return if Time.now.to_i + REFRESH_INTERVAL_PAD < @access_expires_at
83
+ def refresh_access_token
71
84
  response = Net::HTTP.post_form(
72
85
  URI(URL_TOKEN),
73
86
  'grant_type' => 'refresh_token',
74
- 'refresh_token' => @refresh_token,
87
+ 'refresh_token' => @refresh_token || 0,
75
88
  'client_id' => @app_key
76
89
  )
77
90
  result = JSON.parse(response.body)
78
91
  if result.key? 'error'
79
- pp result
80
- raise Ecobee::TokenError.new(
81
- "Result Error: (%s) %s" % [result['error'],
82
- result['error_description']]
83
- )
92
+ if result['error'] == 'invalid_grant'
93
+ @status = :authorization_pending
94
+ check_for_authorization
95
+ else
96
+ puts "DUMPING(result): #{result.pretty_inspect}"
97
+ raise Ecobee::TokenError.new(
98
+ "Result Error: (%s) %s" % [result['error'],
99
+ result['error_description']]
100
+ )
101
+ end
84
102
  else
85
103
  @access_token = result['access_token']
86
- @access_expires_at = Time.now.to_i + result['expires_in']
104
+ @access_token_expire = Time.now.to_i + result['expires_in']
105
+ @pin = nil
87
106
  @refresh_token = result['refresh_token']
88
- @pin, @code, @code_expires_at = nil
89
107
  @scope = result['scope']
90
- @type = result['token_type']
108
+ @token_type = result['token_type']
91
109
  @status = :ready
92
- write_token_file
93
- end
110
+ config_save
111
+ @access_token
112
+ end
94
113
  rescue SocketError => msg
95
114
  raise Ecobee::TokenError.new("POST failed: #{msg}")
96
115
  rescue JSON::ParserError => msg
97
116
  raise Ecobee::TokenError.new("Result parsing: #{msg}")
98
- rescue Exception => msg
99
- raise Ecobee::TokenError.new("Unknown Error: #{msg}")
117
+ # rescue Exception => msg
118
+ # raise Ecobee::TokenError.new("Unknown Error: #{msg}")
119
+ end
120
+
121
+ def register_callback(type, *callback, &block)
122
+ if block_given?
123
+ puts "Registering #{type}"
124
+ @callbacks[type] = block
125
+ else
126
+ @callbacks[type] = callback[0] if callback.length > 0
127
+ end
100
128
  end
101
129
 
102
130
  def wait
@@ -106,16 +134,36 @@ module Ecobee
106
134
 
107
135
  private
108
136
 
109
- def check_for_token
137
+ def check_for_authorization
138
+ check_for_authorization_single
139
+ if @status == :authorization_pending
140
+ unless @authorization_thread && @authorization_thread.alive?
141
+ @authorization_thread = Thread.new {
142
+ loop do
143
+ # TODO: consider some intelligent throttling
144
+ sleep REFRESH_TOKEN_CHECK
145
+ break if @status == :ready
146
+ check_for_authorization_single
147
+ end
148
+ }
149
+ end
150
+ end
151
+ end
152
+
153
+ def check_for_authorization_single
110
154
  response = Net::HTTP.post_form(
111
155
  URI(URL_TOKEN),
112
156
  'grant_type' => 'ecobeePin',
113
- 'code' => @code,
157
+ 'code' => @access_token,
114
158
  'client_id' => @app_key
115
159
  )
116
160
  result = JSON.parse(response.body)
117
161
  if result.key? 'error'
118
- unless ['slow_down', 'authorization_pending'].include? result['error']
162
+ @status = :authorization_pending
163
+
164
+ if result['error'] == 'invalid_client'
165
+ register
166
+ elsif !['slow_down', 'authorization_pending'].include? result['error']
119
167
  # TODO: throttle or just ignore...?
120
168
  pp result
121
169
  raise Ecobee::TokenError.new(
@@ -126,59 +174,67 @@ module Ecobee
126
174
  else
127
175
  @status = :ready
128
176
  @access_token = result['access_token']
129
- @type = result['token_type']
130
- @access_expires_at = Time.now.to_i + result['expires_in']
177
+ @token_type = result['token_type']
178
+ @access_token_expire = Time.now.to_i + result['expires_in']
131
179
  @refresh_token = result['refresh_token']
132
180
  @scope = result['scope']
133
- @pin, @code, @code_expires_at = nil
134
- write_token_file
181
+ @pin = nil
182
+ config_save
183
+ @access_token
135
184
  end
136
185
  rescue SocketError => msg
137
186
  raise Ecobee::TokenError.new("POST failed: #{msg}")
138
187
  rescue JSON::ParserError => msg
139
188
  raise Ecobee::TokenError.new("Result parsing: #{msg}")
140
- rescue Exception => msg
141
- raise Ecobee::TokenError.new("Unknown Error: #{msg}")
189
+ # rescue Exception => msg
190
+ # raise Ecobee::TokenError.new("Unknown Error: #{msg}")
142
191
  end
143
192
 
144
- def expand_files(token_file)
145
- if token_file.is_a? Array
146
- token_file.map { |tf| File.expand_path tf }
147
- else
148
- expand_files [token_file]
149
- end
150
- end
151
-
152
- def launch_monitor_thread
153
- Thread.new {
154
- loop do
155
- sleep REFRESH_TOKEN_CHECK
156
- break if @status == :ready
157
- check_for_token
193
+ def config_load_to_memory(config)
194
+ @app_key ||= config['app_key']
195
+ if !@access_token
196
+ @access_token = config['access_token']
197
+ @access_token_expire = config['access_token_expire'].to_i
198
+ elsif(config.key?('access_token') &&
199
+ config['access_token_expire'].to_i > @access_token_expire)
200
+ @access_token = config['access_token']
201
+ @access_token_expire = config['access_token_expire'].to_i
202
+ if config['refresh_token']
203
+ @refresh_token = config['refresh_token']
204
+ @scope = config['scope']
205
+ @token_type = config['token_type']
206
+ elsif config.key?('pin')
207
+ @pin = config['pin']
158
208
  end
159
- }
160
- end
161
-
162
- def parse_token_file
163
- return unless (all_config = read_token_file).is_a? Hash
164
- section = (@app_name && all_config.key?(@app_name)) ? @app_name : @app_key
165
- return unless all_config.key?(section)
166
- config = all_config[section]
167
- @app_key ||= config.key?('app_key') ? config['app_key'] : @app_name
209
+ end
168
210
  if config.key?('refresh_token')
169
- @access_expires_at ||= config['access_expires_at'].to_i
170
- @access_token ||= config['access_token']
171
211
  @refresh_token ||= config['refresh_token']
172
212
  @scope ||= config['scope']
173
- @type ||= config['token_type']
213
+ @token_type ||= config['token_type']
174
214
  elsif config.key?('pin')
175
- @code ||= config['code']
176
- @code_expires_at ||= config['code_expires_at'].to_i
177
215
  @pin ||= config['pin']
178
216
  end
179
217
  end
180
218
 
181
- def read_json_file(file)
219
+ def config_read_our_section
220
+ all_config = config_read_all_sections
221
+ if @app_name && all_config.key?(@app_name)
222
+ our_section = @app_name
223
+ else
224
+ our_section = @app_key
225
+ end
226
+ return all_config[our_section] || {}
227
+ end
228
+
229
+ def config_read_all_sections
230
+ @token_file.each do |tf|
231
+ result = config_read_file(tf)
232
+ return result if result.length > 0
233
+ end
234
+ {}
235
+ end
236
+
237
+ def config_read_file(file)
182
238
  JSON.parse(
183
239
  File.open(file, 'r').read(16 * 1024)
184
240
  )
@@ -188,56 +244,62 @@ module Ecobee
188
244
  {}
189
245
  end
190
246
 
191
- def read_token_file
192
- @token_file.each do |tf|
193
- result = read_json_file(tf)
194
- return result if result.length > 0
247
+ def config_dump
248
+ config = {}
249
+ config['access_token'] = @access_token
250
+ config['access_token_expire'] = @access_token_expire
251
+ if @refresh_token
252
+ config['refresh_token'] = @refresh_token
253
+ config['scope'] = @scope
254
+ config['token_type'] = @token_type
255
+ elsif @pin
256
+ config['pin'] = @pin
195
257
  end
258
+ config
196
259
  end
197
260
 
198
- def register
199
- result = Register.new(app_key: @app_key, scope: @scope)
200
- @pin = result.pin
201
- @code = result.code
202
- @code_expires_at = result.expires_at
203
- @scope = result.scope
204
- write_token_file
205
- result
206
- end
261
+ def config_write_section(config)
262
+ all_config = config_read_all_sections
263
+ all_config.delete(@app_key)
264
+ all_config[@app_key] = config
207
265
 
208
- def write_token_file
209
- @token_file.each do |file|
210
- return if write_json_file file
266
+ @token_file.each do |file_name|
267
+ return true if config_write_file(config: all_config, file_name: file_name)
211
268
  end
269
+ nil
212
270
  end
213
271
 
214
- def write_json_file(file)
215
- if config = read_token_file
216
- config.delete(@app_name)
217
- config.delete(@app_key)
218
- end
219
- section = @app_name || @app_key
220
- config[section] = {}
221
- config[section]['app_key'] = @app_key if @app_key && section != @app_key
222
- if @refresh_token
223
- config[section]['access_token'] = @access_token
224
- config[section]['access_expires_at'] = @access_expires_at
225
- config[section]['refresh_token'] = @refresh_token
226
- config[section]['token_type'] = @type
227
- config[section]['scope'] = @scope
228
- elsif @pin
229
- config[section]['pin'] = @pin
230
- config[section]['code'] = @code
231
- config[section]['code_expires_at'] = @code_expires_at
232
- end
233
- File.open(file, 'w') do |tf|
234
- tf.puts JSON.pretty_generate(config)
272
+ def config_write_file(config: nil, file_name: nil)
273
+ File.open(file_name, 'w') do |file|
274
+ file.puts JSON.pretty_generate(config)
235
275
  end
236
276
  true
237
277
  rescue Errno::ENOENT
238
278
  nil
239
279
  end
240
280
 
281
+ def expand_files(token_file)
282
+ if token_file.is_a? NilClass
283
+ nil
284
+ elsif token_file.is_a? Array
285
+ token_file.map { |tf| File.expand_path tf }
286
+ else
287
+ expand_files [token_file]
288
+ end
289
+ end
290
+
291
+ def register
292
+ @status = :authorization_pending
293
+ result = Register.new(app_key: @app_key, scope: @scope)
294
+ @pin = result.pin
295
+ @access_token = result.code
296
+ @access_token_expire = result.expires_at
297
+ @scope = result.scope
298
+ check_for_authorization
299
+ config_save
300
+ @access_token if @status == :ready
301
+ end
302
+
241
303
  end
242
304
 
243
305
  class TokenError < StandardError
@@ -1,3 +1,3 @@
1
1
  module Ecobee
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ecobee
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rob Zwissler
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2016-08-09 00:00:00.000000000 Z
11
+ date: 2016-08-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -71,10 +71,13 @@ files:
71
71
  - bin/setup
72
72
  - ecobee.gemspec
73
73
  - examples/set_mode.rb
74
+ - examples/test_save_hooks.rb
75
+ - examples/test_thermostat_object.rb
74
76
  - examples/test_token.rb
75
77
  - lib/ecobee.rb
76
78
  - lib/ecobee/client.rb
77
79
  - lib/ecobee/register.rb
80
+ - lib/ecobee/thermostat.rb
78
81
  - lib/ecobee/token.rb
79
82
  - lib/ecobee/version.rb
80
83
  homepage: https://github.com/robzr/ecobee
@@ -97,8 +100,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
97
100
  version: '0'
98
101
  requirements: []
99
102
  rubyforge_project:
100
- rubygems_version: 2.5.1
103
+ rubygems_version: 2.0.14.1
101
104
  signing_key:
102
105
  specification_version: 4
103
106
  summary: Ecobee API - token registration, persistent HTTP GET/PUSH
104
107
  test_files: []
108
+ has_rdoc: