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 +4 -4
- data/README.md +11 -9
- data/examples/set_mode.rb +18 -33
- data/examples/test_save_hooks.rb +90 -0
- data/examples/test_thermostat_object.rb +27 -0
- data/examples/test_token.rb +19 -4
- data/lib/ecobee.rb +44 -12
- data/lib/ecobee/client.rb +16 -5
- data/lib/ecobee/register.rb +8 -8
- data/lib/ecobee/thermostat.rb +212 -0
- data/lib/ecobee/token.rb +181 -119
- data/lib/ecobee/version.rb +1 -1
- metadata +7 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4e2b2a04ef326e3e40a7e7c4191de520a5792d4d
|
4
|
+
data.tar.gz: 88ff1e1a5805e3a157b0c7b5ce093210d3ab8393
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
-
|
15
|
-
-
|
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
|
19
|
-
-
|
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
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
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
|
-
|
65
|
-
|
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
|
+
#
|
data/examples/test_token.rb
CHANGED
@@ -3,18 +3,33 @@
|
|
3
3
|
# Refreshes token; displays details on saved token. -- @robzr
|
4
4
|
|
5
5
|
require 'pp'
|
6
|
-
|
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
|
-
|
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.
|
33
|
+
puts "Expires At: #{token.access_token_expire}"
|
19
34
|
puts "Scope: #{token.scope}"
|
20
|
-
puts "Type: #{token.
|
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
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
23
|
-
|
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
|
-
}
|
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
|
-
}
|
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']} " +
|
data/lib/ecobee/register.rb
CHANGED
@@ -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 =
|
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"
|
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 :
|
6
|
-
:
|
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
|
-
:
|
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
|
-
|
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
|
-
@
|
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
|
-
@
|
35
|
-
parse_token_file
|
36
|
-
@status = @refresh_token ? :ready : :authorization_pending
|
34
|
+
@authorization_thread, @pin, @status, @token_type = nil
|
37
35
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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
|
-
|
49
|
-
|
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
|
-
"#{@
|
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 && @
|
58
|
-
@
|
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
|
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
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
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
|
-
@
|
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
|
-
@
|
108
|
+
@token_type = result['token_type']
|
91
109
|
@status = :ready
|
92
|
-
|
93
|
-
|
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
|
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' => @
|
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
|
-
|
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
|
-
@
|
130
|
-
@
|
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
|
134
|
-
|
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
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
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
|
-
@
|
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
|
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
|
192
|
-
|
193
|
-
|
194
|
-
|
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
|
199
|
-
|
200
|
-
@
|
201
|
-
@
|
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
|
-
|
209
|
-
|
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
|
215
|
-
|
216
|
-
|
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
|
data/lib/ecobee/version.rb
CHANGED
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.
|
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-
|
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.
|
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:
|