htcc 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7ab75c3a400f4719a86ea5b1757c17dc2e2d4e6366f1aed56adb2da193bcd79b
4
+ data.tar.gz: f713bf7a5e3fcbab393b17a5fddce1f2211fe6c823ba3a805e14918748e4e635
5
+ SHA512:
6
+ metadata.gz: 1bea671742650e7cbb022f96175b7161f065b0dccb31aa9dfb2c9a7eff459980b3d261130bca230d27169b8206a788e97346e8e5f33e4a6994575cb74748a768
7
+ data.tar.gz: '09cc41bed0804b02a6ed2e0a11383afb28bfcdd2831c7588194a33756607935fb75f3f92c197583c4973c3df4940ab1ce5eddd715c5a4cabaff38f297b620f55'
data/htcc.gemspec ADDED
@@ -0,0 +1,12 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'htcc'
3
+ s.version = '0.1.1'
4
+ s.summary = "A Ruby client for the Honeywell Total Connect Comfort API"
5
+ s.description = "This gem can be used to control Honeywell thermostats that use the Total Connect Comfort platform."
6
+ s.author = 'Lee Folkman'
7
+ s.email = 'lee.folkman@gmail.com'
8
+ s.files = Dir['README.md', 'LICENSE', 'CHANGELOG.md', 'lib/**/*.rb', 'htcc.gemspec']
9
+ s.homepage = 'https://github.com/Folkman/htcc'
10
+ s.license = 'MIT'
11
+ s.platform = Gem::Platform::RUBY
12
+ end
data/lib/htcc.rb ADDED
@@ -0,0 +1,4 @@
1
+ require 'htcc/client'
2
+ require 'htcc/thermostat'
3
+ require 'htcc/scheduler'
4
+ require 'htcc/settings'
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+
6
+ module HTCC
7
+ class Client
8
+ BASE_URL = 'https://mytotalconnectcomfort.com/portal'.freeze
9
+ HEADERS = { 'X-Requested-With': 'XMLHttpRequest' }.freeze
10
+
11
+ attr_reader :devices
12
+
13
+ def initialize(username, password, debug: false, debug_output: nil)
14
+ @debug = debug
15
+ @debug_output = nil
16
+ @devices = []
17
+ login(username, password)
18
+ get_devices if logged_in?
19
+ end
20
+
21
+ def debug=(val)
22
+ @debug = val
23
+ end
24
+
25
+ def logged_in?
26
+ @logged_in
27
+ end
28
+
29
+ def refresh_devices
30
+ @devices = []
31
+ get_devices
32
+ end
33
+
34
+ private
35
+
36
+ def get_devices
37
+ resp = request(
38
+ '/Location/GetLocationListData',
39
+ method: 'post',
40
+ data: { 'page' => '1', 'filter' => '' }
41
+ )
42
+ locations = ::JSON.parse(resp.body)
43
+ @devices = locations.flat_map { |loc| loc['Devices'] }
44
+ @devices.map! do |device|
45
+ case device['DeviceType']
46
+ when 24
47
+ Thermostat.new(device, self)
48
+ else # Other devices?
49
+ Thermostat.new(device, self)
50
+ end
51
+ end
52
+ end
53
+
54
+ def login(username, password)
55
+ resp = request(method: 'post', data: {
56
+ 'UserName': username, 'Password': password, 'timeOffset': '240', 'RememberMe': 'false'
57
+ })
58
+ @cookies = get_cookies(resp)
59
+ @logged_in = resp.get_fields('content-length')[0].to_i < 25 # Successful login
60
+ end
61
+
62
+ def get_cookies(response)
63
+ response.get_fields('set-cookie')
64
+ .map { |c| cookie = c.split(/;|,/)[0]; cookie.split('=')[1] ? cookie : nil }
65
+ .compact
66
+ .join(';')
67
+ end
68
+
69
+ def request(path = '', method: 'get', data: nil, headers: {})
70
+ uri = URI("#{BASE_URL}#{path}")
71
+ http = Net::HTTP.new(uri.host, uri.port)
72
+ http.use_ssl = true
73
+ http.set_debug_output(@debug_output || $stdout) if @debug
74
+ klass = method == 'get' ? Net::HTTP::Get : Net::HTTP::Post
75
+ request = klass.new(uri.request_uri, HEADERS.merge(headers))
76
+ request['Cookie'] = @cookies if @cookies
77
+ request.set_form_data(data) if data
78
+ http.request(request)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTCC
4
+ class Scheduler
5
+ attr_reader :device_id
6
+
7
+ def initialize(device_id, client)
8
+ @device_id = device_id
9
+ @client = client
10
+ end
11
+
12
+ def get_schedule
13
+ resp = @client.send(:request, "/Device/Menu/GetScheduleData/#{device_id}", method: 'post')
14
+ @schedule = JSON.parse(resp.body)
15
+ end
16
+ end
17
+ end
18
+
19
+ # TODO:
20
+
21
+ # EDIT_SCHEDULE_PATH = '/Device/Menu/EditSchedule/' + device_id
22
+ # CONFIRM_SCHEDULE_PATH = '/Device/Menu/SendSchedule?deviceId=' + device_id
23
+ # DISCARD_SCHEDULE_PATH = '/Device/Menu/DiscardChangesInSchedule?deviceID=' + device_id
24
+
25
+ # FormData Encoded:
26
+
27
+ # DeviceID: 123456
28
+ # Days[0]: True
29
+ # Days[1]: True
30
+ # Days[2]: True
31
+ # Days[3]: True
32
+ # Days[4]: True
33
+ # Days[5]: True
34
+ # Days[6]: True
35
+ # DayChange: True
36
+ # Templates[0].Editable: True
37
+ # Templates[0].OrigIsCancelled: false
38
+ # Templates[0].Editable: True
39
+ # Templates[0].Type: WakeOcc1
40
+ # Templates[0].OrigStartTime: 05:45:00
41
+ # Templates[0].OrigFanMode: Auto
42
+ # Templates[0].OrigHeatSetpoint: 68
43
+ # Templates[0].OrigCoolSetpoint: 79
44
+ # Templates[0].StartTime: 05:45:00
45
+ # Templates[0].IsCancelled: false
46
+ # Templates[1].Editable: True
47
+ # Templates[1].OrigIsCancelled: true
48
+ # Templates[1].Editable: True
49
+ # Templates[1].Type: LeaveUnocc1
50
+ # Templates[1].OrigStartTime: 08:00:00
51
+ # Templates[1].OrigFanMode: Auto
52
+ # Templates[1].OrigHeatSetpoint: 62
53
+ # Templates[1].OrigCoolSetpoint: 85
54
+ # Templates[1].StartTime: 08:00:00
55
+ # Templates[1].IsCancelled: true
56
+ # Templates[2].Editable: True
57
+ # Templates[2].OrigIsCancelled: true
58
+ # Templates[2].Editable: True
59
+ # Templates[2].Type: ReturnOcc2
60
+ # Templates[2].OrigStartTime: 18:00:00
61
+ # Templates[2].OrigFanMode: Auto
62
+ # Templates[2].OrigHeatSetpoint: 70
63
+ # Templates[2].OrigCoolSetpoint: 78
64
+ # Templates[2].StartTime: 18:00:00
65
+ # Templates[2].IsCancelled: true
66
+ # Templates[3].Editable: True
67
+ # Templates[3].OrigIsCancelled: false
68
+ # Templates[3].Editable: True
69
+ # Templates[3].Type: SleepUnocc2
70
+ # Templates[3].OrigStartTime: 21:30:00
71
+ # Templates[3].OrigFanMode: Auto
72
+ # Templates[3].OrigHeatSetpoint: 64
73
+ # Templates[3].OrigCoolSetpoint: 78
74
+ # Templates[3].StartTime: 21:30:00
75
+ # Templates[3].IsCancelled: false
76
+ # Templates[0].Type: WakeOcc1
77
+ # Templates[0].HeatSetpoint: 68
78
+ # Templates[0].CoolSetpoint: 79
79
+ # Templates[1].Type: LeaveUnocc1
80
+ # Templates[1].HeatSetpoint: 62
81
+ # Templates[1].CoolSetpoint: 85
82
+ # Templates[2].Type: ReturnOcc2
83
+ # Templates[2].HeatSetpoint: 70
84
+ # Templates[2].CoolSetpoint: 78
85
+ # Templates[3].Type: SleepUnocc2
86
+ # Templates[3].HeatSetpoint: 64
87
+ # Templates[3].CoolSetpoint: 78
88
+ # DisplayUnits: Fahrenheit
89
+ # IsCommercial: False
90
+ # ScheduleFan: True
91
+ # Templates[0].FanMode: Auto
92
+ # Templates[1].FanMode: Auto
93
+ # Templates[2].FanMode: Auto
94
+ # Templates[3].FanMode: Auto
95
+ # ScheduleOtherDays: False
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTCC
4
+ class Settings
5
+ attr_reader :device_id
6
+
7
+ def initialize(device_id, client)
8
+ @device_id = device_id
9
+ @client = client
10
+ end
11
+
12
+ def update(payload)
13
+ resp = @client.send(:request, '/Device/Menu/Settings', method: 'post', data: payload)
14
+ JSON.parse(resp.body)
15
+ end
16
+ end
17
+ end
18
+
19
+ # TODO:
20
+
21
+ # Payload that can be posted to '/Device/Menu/Settings'
22
+ # {
23
+ # "Name":"THERMOSTAT",
24
+ # "ApplySettingsToAllZones":false,
25
+ # "DeviceID":123456,
26
+ # "DisplayUnits":1,
27
+ # "TempHigherThanActive":true,
28
+ # "TempHigherThan":85,
29
+ # "TempHigherThanMinutes":15,
30
+ # "TempLowerThanActive":true,
31
+ # "TempLowerThan":55,
32
+ # "TempLowerThanMinutes":15,
33
+ # "HumidityHigherThanActive":null,
34
+ # "HumidityHigherThan":null,
35
+ # "HumidityHigherThanMinutes":null,
36
+ # "HumidityLowerThanActive":null,
37
+ # "HumidityLowerThan":null,
38
+ # "HumidityLowerThanMinutes":null,
39
+ # "FaultConditionExistsActive":false,
40
+ # "FaultConditionExistsHours":1,
41
+ # "NormalConditionsActive":true,
42
+ # "ThermostatAlertActive":null,
43
+ # "CommunicationFailureActive":true,
44
+ # "CommunicationFailureMinutes":15,
45
+ # "CommunicationLostActive":true,
46
+ # "CommunicationLostHours":1,
47
+ # "DeviceLostActive":null,
48
+ # "DeviceLostHours":null,
49
+ # "TempHigherThanValue":"85°",
50
+ # "TempLowerThanValue":"55°",
51
+ # "HumidityHigherThanValue":"--%",
52
+ # "HumidityLowerThanValue":"--%",
53
+ # "TempHigherThanMinutesText":"For 15 Minutes",
54
+ # "TempLowerThanMinutesText":"For 15 Minutes",
55
+ # "HumidityHigherThanMinutesText":"For 0 Minutes",
56
+ # "HumidityLowerThanMinutesText":"For 0 Minutes",
57
+ # "FaultConditionExistsHoursText":"Every 1 Hour",
58
+ # "CommunicationFailureMinutesText":"For 15 Minutes",
59
+ # "CommunicationLostHoursText":"After 1 Hour",
60
+ # "DeviceLostHoursText":"After 1 Hour"
61
+ # }
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTCC
4
+ class Thermostat
5
+ SYSTEM_MODES = %i[emergency_heat heat off cool auto]
6
+ FAN_MODES = %i[auto on circulate schedule]
7
+ HOLD_TYPES = %i[none temporary permanent]
8
+ EQUIPMENT_OUTPUT_STATUS = %i[off heating cooling fan_running]
9
+
10
+ attr_reader :info
11
+
12
+ def initialize(info, client)
13
+ @info = info
14
+ @client = client
15
+ @refresh = true
16
+ @status = {}
17
+ end
18
+
19
+ def Scheduler
20
+ @scheduler ||= Scheduler.new(id, @client)
21
+ end
22
+
23
+ def Settings
24
+ @settings ||= Settings.new(id, @client)
25
+ end
26
+
27
+ def id
28
+ @info['DeviceID']
29
+ end
30
+
31
+ def mac_address
32
+ @info['MacID']
33
+ end
34
+
35
+ def name
36
+ @info['Name']
37
+ end
38
+
39
+ def connected?
40
+ get_status
41
+ @status['deviceLive'] && !@status['communicationLost']
42
+ end
43
+
44
+ def status(refresh = false)
45
+ get_status if @status.empty? || refresh
46
+ @status
47
+ end
48
+
49
+ def system_mode
50
+ get_status
51
+ SYSTEM_MODES[@status['latestData']['uiData']['SystemSwitchPosition']]
52
+ end
53
+
54
+ def system_mode=(mode)
55
+ unless system_modes.index(mode)
56
+ raise SystemError.new("Unknown mode: #{mode.inspect}. Allowed modes: #{system_modes.inspect}")
57
+ end
58
+ change_setting(system_mode: mode)
59
+ end
60
+
61
+ def has_fan?
62
+ return @has_fan unless @has_fan.nil?
63
+
64
+ get_status if @status.empty?
65
+ @has_fan = @status['latestData']['hasFan']
66
+ end
67
+
68
+ def fan_running?
69
+ get_status
70
+ @status['latestData']['fanData']['fanIsRunning']
71
+ end
72
+
73
+ def fan_mode
74
+ get_status
75
+ FAN_MODES[@status['latestData']['fanData']['fanMode']]
76
+ end
77
+
78
+ def fan_mode=(mode)
79
+ unless fan_modes.index(mode)
80
+ raise FanError.new("Unknown mode: #{mode.inspect}. Allowed modes: #{fan_modes.inspect}")
81
+ end
82
+ change_setting(fan_mode: mode)
83
+ end
84
+
85
+ # Current ambient temperature
86
+ def current_temperature
87
+ get_status
88
+ @status['latestData']['uiData']['DispTemperature']
89
+ end
90
+
91
+ def temperature_unit
92
+ get_status
93
+ @status['latestData']['uiData']['DisplayUnits']
94
+ end
95
+
96
+ # Cooling temperature setting
97
+ def cool_setpoint
98
+ get_status
99
+ @status['latestData']['uiData']['CoolSetpoint']
100
+ end
101
+
102
+ def cool_setpoint=(temp)
103
+ raise_min_setpoint(min_cool_setpoint, temp) if temp < min_cool_setpoint
104
+ raise_max_setpoint(max_cool_setpoint, temp) if temp > max_cool_setpoint
105
+ change_setting(cool_setpoint: temp, hold: :temporary)
106
+ end
107
+
108
+ # Heating temperature setting
109
+ def heat_setpoint
110
+ get_status
111
+ @status['latestData']['uiData']['HeatSetpoint']
112
+ end
113
+
114
+ def heat_setpoint=(temp)
115
+ raise_min_setpoint(min_heat_setpoint, temp) if temp < min_heat_setpoint
116
+ raise_max_setpoint(max_heat_setpoint, temp) if temp > max_heat_setpoint
117
+ change_setting(heat_setpoint: temp, hold: :temporary)
118
+ end
119
+
120
+ def resume_schedule
121
+ change_setting(hold: :none)
122
+ end
123
+
124
+ def hold
125
+ get_status
126
+ HOLD_TYPES[@status['latestData']['uiData']['StatusHeat']] # Both status are the same
127
+ end
128
+
129
+ def hold=(mode)
130
+ unless HOLD_TYPES.index(mode)
131
+ raise HoldError.new("Unknown mode: #{mode.inspect}. Allowed modes: #{HOLD_TYPES.inspect}")
132
+ end
133
+ change_setting(hold: mode)
134
+ end
135
+
136
+ def output_status
137
+ get_status
138
+ status = @status['latestData']['uiData']['EquipmentOutputStatus']
139
+ status = no_refresh { fan_running? ? 3 : status } if status.zero?
140
+ EQUIPMENT_OUTPUT_STATUS[@status['latestData']['uiData']['EquipmentOutputStatus']]
141
+ end
142
+
143
+ def system_modes
144
+ return @system_modes if @system_modes
145
+
146
+ get_status if @status.empty?
147
+ allowed_modes = [
148
+ @status['latestData']['uiData']['SwitchEmergencyHeatAllowed'],
149
+ @status['latestData']['uiData']['SwitchHeatAllowed'],
150
+ @status['latestData']['uiData']['SwitchOffAllowed'],
151
+ @status['latestData']['uiData']['SwitchCoolAllowed'],
152
+ @status['latestData']['uiData']['SwitchAutoAllowed'],
153
+ ]
154
+ @system_modes = SYSTEM_MODES.select.with_index { |_, i| allowed_modes[i] }
155
+ end
156
+
157
+ def fan_modes
158
+ return @fan_modes if @fan_modes
159
+
160
+ get_status if @status.empty?
161
+ allowed_modes = [
162
+ @status['latestData']['fanData']['fanModeAutoAllowed'],
163
+ @status['latestData']['fanData']['fanModeOnAllowed'],
164
+ @status['latestData']['fanData']['fanModeCirculateAllowed'],
165
+ @status['latestData']['fanData']['fanModeFollowScheduleAllowed']
166
+ ]
167
+ @fan_modes = FAN_MODES.select.with_index { |_, i| allowed_modes[i] }
168
+ end
169
+
170
+ def no_refresh(&block)
171
+ @refresh = false
172
+ result = yield
173
+ @refresh = true
174
+ result
175
+ end
176
+
177
+ private
178
+
179
+ def get_status
180
+ return @status unless @refresh || @status.empty?
181
+
182
+ resp = @client.send(:request, "/Device/CheckDataSession/#{id}?_=#{Time.now.to_i}")
183
+ @status = JSON.parse(resp.body)
184
+ end
185
+
186
+ # Required separation between high and low setpoints
187
+ def deadband
188
+ return @deadband if @deadband
189
+
190
+ get_status if @status.empty?
191
+ @deadband = @status['latestData']['uiData']['Deadband']
192
+ end
193
+
194
+ def min_cool_setpoint
195
+ @info['ThermostatData']['MinCoolSetpoint']
196
+ end
197
+
198
+ def max_cool_setpoint
199
+ @info['ThermostatData']['MaxCoolSetpoint']
200
+ end
201
+
202
+ def min_heat_setpoint
203
+ @info['ThermostatData']['MinHeatSetpoint']
204
+ end
205
+
206
+ def max_heat_setpoint
207
+ @info['ThermostatData']['MaxHeatSetpoint']
208
+ end
209
+
210
+ def raise_min_setpoint(min_temp, given_temp)
211
+ raise TemperatureError.new("Minimum setpoint is #{min_temp}. Given: #{given_temp}")
212
+ end
213
+
214
+ def raise_max_setpoint(max_temp, given_temp)
215
+ raise TemperatureError.new("Maximum setpoint is #{max_temp}. Given: #{given_temp}")
216
+ end
217
+
218
+ def payload(
219
+ system_mode: nil,
220
+ heat_setpoint: nil,
221
+ cool_setpoint: nil,
222
+ heat_next_period: nil,
223
+ cool_next_period: nil,
224
+ hold: nil,
225
+ fan_mode: nil
226
+ )
227
+ {
228
+ 'DeviceID': id,
229
+ 'SystemSwitch': SYSTEM_MODES.index(system_mode),
230
+ 'HeatSetpoint': heat_setpoint,
231
+ 'CoolSetpoint': cool_setpoint,
232
+ 'HeatNextPeriod': heat_next_period, # 0 = hold until 00:00, ..., 92 = hold until 23:45
233
+ 'CoolNextPeriod': cool_next_period, # 0 = hold until 00:00, ..., 92 = hold until 23:45
234
+ 'StatusHeat': HOLD_TYPES.index(hold),
235
+ 'StatusCool': HOLD_TYPES.index(hold),
236
+ 'FanMode': FAN_MODES.index(fan_mode)
237
+ }
238
+ end
239
+
240
+ def change_setting(**data)
241
+ resp = @client.send(:request, '/Device/SubmitControlScreenChanges',
242
+ method: 'post',
243
+ data: payload(**data)
244
+ )
245
+ JSON.parse(resp.body)['success'] == 1
246
+ end
247
+
248
+ class FanError < StandardError; end
249
+ class HoldError < StandardError; end
250
+ class SystemError < StandardError; end
251
+ class TemperatureError < StandardError; end
252
+ end
253
+ end
metadata ADDED
@@ -0,0 +1,49 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: htcc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Lee Folkman
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-03-29 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: This gem can be used to control Honeywell thermostats that use the Total
14
+ Connect Comfort platform.
15
+ email: lee.folkman@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - htcc.gemspec
21
+ - lib/htcc.rb
22
+ - lib/htcc/client.rb
23
+ - lib/htcc/scheduler.rb
24
+ - lib/htcc/settings.rb
25
+ - lib/htcc/thermostat.rb
26
+ homepage: https://github.com/Folkman/htcc
27
+ licenses:
28
+ - MIT
29
+ metadata: {}
30
+ post_install_message:
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '0'
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubygems_version: 3.2.3
46
+ signing_key:
47
+ specification_version: 4
48
+ summary: A Ruby client for the Honeywell Total Connect Comfort API
49
+ test_files: []