atrea_control 1.3.1 → 2.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtreaControl
4
+ module Duplex
5
+ # Communication with RD5 unit (read/write)
6
+ class Unit
7
+ include AtreaControl::Logger
8
+
9
+ attr_reader :current_mode, :current_power, :outdoor_temperature
10
+ # @return [DateTime] store time of last update
11
+ attr_reader :valid_for
12
+ # @return [UserCtrl]
13
+ attr_reader :user_ctrl
14
+
15
+ # @param [String, Integer] user_id
16
+ # @param [String, Integer] unit_id
17
+ # @param [String, Integer] sid
18
+ # @param [AtreaControl::Duplex::UserCtrl] user_ctrl
19
+ def initialize(user_id:, unit_id:, sid:, user_ctrl: nil)
20
+ @user_id = user_id
21
+ @unit_id = unit_id
22
+ @sid = sid
23
+ @user_ctrl = user_ctrl || UserCtrl.new(user_id: user_id, unit_id: unit_id, sid: sid)
24
+ end
25
+
26
+ def name
27
+ @user_ctrl.name
28
+ end
29
+
30
+ def mode
31
+ current_mode || values[:current_mode]
32
+ end
33
+
34
+ def power
35
+ current_power || values[:current_power]
36
+ end
37
+
38
+ # @param [String] value 0 - power-off; 1 - automat
39
+ def mode=(value)
40
+ v = [parser.input(@user_ctrl.sensors["mode_input"], value.to_s)]
41
+ v << parser.input(@user_ctrl.sensors["mode_switch"], "2")
42
+ write(*v)
43
+ end
44
+
45
+ def power=(value)
46
+ v = parser.input(@user_ctrl.sensors["power_input"], value.to_s)
47
+ write(v)
48
+ end
49
+
50
+ def values
51
+ parser.values(read.body).each do |name, value|
52
+ instance_variable_set :"@#{name}", value
53
+ end
54
+ as_json
55
+ end
56
+
57
+ def as_json(_options = nil)
58
+ {
59
+ current_mode: current_mode,
60
+ current_power: current_power,
61
+ outdoor_temperature: outdoor_temperature,
62
+ valid_for: valid_for,
63
+ }
64
+ end
65
+
66
+ def to_json(*args)
67
+ values.to_json(*args)
68
+ end
69
+
70
+ # Additional "parameters" for each sensors
71
+ # @note its changed in time ?
72
+ def params
73
+ response = request.call(_t: "user/params.xml")
74
+ Nokogiri::XML response.body
75
+ end
76
+
77
+ private
78
+
79
+ def parser
80
+ @parser ||= ::AtreaControl::SensorParser.new(@user_ctrl)
81
+ end
82
+
83
+ # Request to RD5
84
+ def request
85
+ @request ||= Request.new(user_id: @user_id, unit_id: @unit_id, sid: @sid)
86
+ end
87
+
88
+ def read
89
+ request.call(_t: "config/xml.xml")
90
+ end
91
+
92
+ # @param [Array<String>] values in format SENSOR0000VALUE
93
+ def write(*values)
94
+ inputs = values.to_h { |i| [i, nil] }
95
+ logger.debug("set RD5 #{inputs}")
96
+ request.call({ _t: "config/xml.cgi", _w: 1 }.merge(inputs))
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,138 @@
1
+ require "nokogiri"
2
+
3
+ module AtreaControl
4
+ module Duplex
5
+ # Parse `userCtrl.xml`
6
+ class UserCtrl
7
+ # @param (see #initialize)
8
+ def self.data(user_id:, unit_id:, sid:)
9
+ user_ctrl = new(user_id: user_id, unit_id: unit_id, sid: sid)
10
+ {
11
+ name: user_ctrl.name,
12
+ sensors: user_ctrl.sensors,
13
+ modes: user_ctrl.modes,
14
+ user_modes: user_ctrl.user_modes,
15
+ }
16
+ end
17
+
18
+ # @param [String, Integer] user_id
19
+ # @param [String, Integer] unit_id
20
+ # @param [String, Integer] sid
21
+ def initialize(user_id:, unit_id:, sid:)
22
+ @user_id = user_id
23
+ @unit_id = unit_id
24
+ @sid = sid
25
+ end
26
+
27
+ # Request to RD5
28
+ def request
29
+ @request ||= Request.new(user_id: @user_id, unit_id: @unit_id, sid: @sid)
30
+ end
31
+
32
+ # Get and parse XML with user/unit configuration source
33
+ def user_ctrl
34
+ response = request.call(_t: "lang/userCtrl.xml")
35
+ Nokogiri::XML response.body
36
+ end
37
+
38
+ # User-defined name of home (RD5 unit)
39
+ # @return [String]
40
+ def name
41
+ @name ||= user_texts["UnitName"]
42
+ end
43
+
44
+ # Gather ID of sensors based on userCtrl config file
45
+ def sensors
46
+ return @sensors if @sensors
47
+
48
+ power = user_ctrl.xpath("//body/content/i[@title='$currentPower']")
49
+ mode = user_ctrl.xpath("//body/content/i[@title='$currentMode']")
50
+ outdoor_temp = user_ctrl.xpath("//body/content/i[@title='$outdoorTemp']")
51
+
52
+ switch_mode = mode.xpath("displayval").text[/values\.(\w+)/, 1]
53
+
54
+ power_input = power.xpath("onchange").text[/getUrlPar\('(\w+)',val\)/, 1]
55
+ m = mode.xpath("onchange").text.match(/getUrlPar\('(\w+)',val\).*getUrlPar\(.(\w+).,2\)/)
56
+
57
+ @sensors = {
58
+ "outdoor_temperature" => outdoor_temp.attribute("id").value, # "I10208"
59
+ "current_power" => power.attribute("id").value, # "H10704"
60
+ "current_mode" => mode.attribute("id").value, # "H10705"
61
+ "current_mode_switch" => switch_mode, # "H10712"
62
+ "power_input" => power_input, # "H10708"
63
+ "mode_input" => m[1], # "H10709"
64
+ "mode_switch" => m[2], # "H10701"
65
+ }
66
+ end
67
+
68
+ # Generic modes
69
+ # @return [Hash]
70
+ def modes
71
+ return @modes if @modes
72
+
73
+ @modes = {}
74
+ user_ctrl.xpath("//op[@id='Mode']/i").each do |mode|
75
+ m = translate_mode(mode)
76
+ @modes[m[:id]] = m[:value]
77
+ end
78
+ @modes
79
+ end
80
+
81
+ # Modes by inputs - switches - named by user custom texts
82
+ # @return [Hash]
83
+ def user_modes
84
+ return @user_modes if @user_modes
85
+
86
+ @user_modes = {}
87
+ user_ctrl.xpath("//op[@id='ModeText']/i").each do |mode|
88
+ m = translate_mode(mode)
89
+ @user_modes[m[:id]] = m[:value]
90
+ end
91
+ @user_modes
92
+ end
93
+
94
+ private
95
+
96
+ # User defined texts in RD5 unit
97
+ # @return [Hash]
98
+ def user_texts
99
+ return @user_texts if @user_texts
100
+
101
+ response = request.call(_t: "config/texts.xml")
102
+ xml = Nokogiri::XML response.body
103
+ @user_texts = xml.xpath("//i").to_h do |node|
104
+ value = node.attributes["value"].value
105
+ id = node.attributes["id"].value
106
+ [id, value.gsub(/%u([\dA-Z]{4})/) { |i| +"" << i[Regexp.last_match(1)].to_i(16) }]
107
+ end
108
+ end
109
+
110
+ # @param [Nokogiri::XML::Element] mode
111
+ # @return [Hash{Symbol->String}]
112
+ def translate_mode(mode)
113
+ id = mode.attributes["id"].value
114
+ title = mode.attributes["title"].value
115
+ title = if title.start_with?("$")
116
+ I18n.t(title[/\w+/])
117
+ else
118
+ user_texts[title]
119
+ end
120
+ { id: id, value: title }
121
+ end
122
+
123
+ # Re-generate copy of locale files
124
+ # @note internal use only
125
+ def update_locales_files!
126
+ { cs: "0", de: "1", en: "2" }.each do |name, atrea_id|
127
+ response = request.call(_t: "lang/texts_#{atrea_id}.xml")
128
+
129
+ xml = Nokogiri::XML response.body
130
+ # `eval` JS object (= hash)
131
+ locale = eval xml.xpath("//words/text()")[0].text
132
+ yaml = { name.to_s => JSON.parse(locale.to_json) }.to_yaml
133
+ File.write(File.expand_path("../../../config/locales/#{name}.yml", __dir__), yaml)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -1,228 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "logger"
4
- require "nokogiri"
5
- require "rest-client"
6
- require "selenium-webdriver"
7
-
8
3
  module AtreaControl
9
4
  # Controller for +control.atrea.eu+
10
- class Duplex
5
+ module Duplex
11
6
  CONTROL_URI = "https://control.atrea.eu/"
7
+ CONTROL_VERSION = "003001009"
12
8
 
13
- attr_reader :current_mode, :current_power, :outdoor_temperature
14
- # @return [DateTime] store time of last update
15
- attr_reader :valid_for
16
-
17
- attr_accessor :user_id, :unit_id, :auth_token
18
-
19
- # @param [String] login
20
- # @param [String] password
21
- # @param [Hash] sensors_map which box is related to sensor ID
22
- # @option sensors_map [String] :outdoor_temperature
23
- # @option sensors_map [String] :current_power
24
- # @option sensors_map [String] :current_mode
25
- def initialize(login:, password:, sensors_map: Config.default_sensors_map)
26
- @login = login
27
- @password = password
28
- @sensors = sensors_map
29
- end
30
-
31
- # @return [Selenium::WebDriver::Firefox::Driver]
32
- def driver
33
- return @driver if defined?(@driver)
34
-
35
- options = Selenium::WebDriver::Firefox::Options.new
36
- options.headless! unless ENV["NO_HEADLESS"]
37
- @driver ||= Selenium::WebDriver.for :firefox, capabilities: [options]
38
- end
39
-
40
- def logged?
41
- user&.[] "loged"
42
- end
43
-
44
- def login_in_progress?
45
- @login_in_progress
46
- end
47
-
48
- # Login into control
49
- def login
50
- @login_in_progress = true
51
- logger.debug "start new login"
52
- driver.get CONTROL_URI
53
- submit_login_form unless logged?
54
- finish_login
55
- refresh!
56
- inspect
57
- ensure
58
- @login_in_progress = false
59
- end
60
-
61
- # Submit given credentials and proceed login
62
- def submit_login_form
63
- form = driver.find_element(id: "loginFrm")
64
- username = form.find_element(name: "username")
65
- username.send_keys @login
66
- password = form.find_element(name: "password")
67
- password.send_keys @password
68
-
69
- submit = form.find_element(css: "input[type=submit]")
70
- submit.click
71
- end
72
-
73
- # Retrieve dashboard URI from object tag and open it again
74
- def open_dashboard
75
- uri = driver.find_element(tag_name: "object").attribute "data"
76
- driver.get uri
77
- user_id && unit_id && auth_token
78
- logger.debug "#{name} login success"
79
- end
80
-
81
- # @return [String]
82
- def name
83
- @name ||= driver.find_element(css: "div#pageTitle > h2")&.text if logged?
84
- @name
85
- end
86
-
87
- # # @return [String] ID of logged user
88
- # def user_id
89
- # @user_id ||= driver.execute_script("return window._user")
90
- # end
91
- #
92
- # # @return [String] ID of recuperation unit
93
- # def unit_id
94
- # @unit_id ||= driver.execute_script("return window._unit")
95
- # end
96
- #
97
- # # @return [String] session token
98
- # def auth_token
99
- # @auth_token ||= user&.[]("auth")
100
- # end
101
-
102
- # Window.user object from atrea
103
- # @return [Hash, nil]
104
- def user
105
- driver.execute_script("return window.user")
106
- end
107
-
108
- # @return [String]
109
- def current_mode_name
110
- return current_mode unless logged?
111
-
112
- element = sensor_element(@sensors[:current_mode])
113
- element.find_element(css: "div:first-child").text
114
- end
115
-
116
- # quit selenium browser
117
- def close
118
- driver.quit
119
- ensure
120
- remove_instance_variable :@driver
121
- end
122
-
123
- alias logout! close
124
-
125
- def as_json(_options = nil)
126
- {
127
- current_mode: current_mode,
128
- current_power: current_power,
129
- outdoor_temperature: outdoor_temperature,
130
- valid_for: valid_for,
131
- }
132
- end
133
-
134
- alias values as_json
135
-
136
- def to_json(*args)
137
- as_json.to_json(*args)
138
- end
139
-
140
- # def inspect
141
- # "<AtreaControl name: '#{name}' outdoor_temperature: '#{outdoor_temperature}°C' current_power: '#{current_power}%' current_mode: '#{current_mode}' valid_for: '#{valid_for}'>"
142
- # end
143
-
144
- def call_unit!
145
- return false if @login_in_progress
146
-
147
- logger.debug "call_unit!"
148
- parse_response(response_comm_unit)
149
- @valid_for = Time.now
150
- as_json
151
- end
152
-
153
- private
154
-
155
- # @see scripts.php -> loadRD5Values(node, init)
156
- # @note
157
- # if(values[key]>32767) values[key]-=65536;
158
- # if(params[key] && params[key].offset)
159
- # values[key]=values[key]-params[key].offset;
160
- # if(params[key] && params[key].coef)
161
- # values[key]=values[key]/params[key].coef;
162
- def parse_response(response)
163
- xml = Nokogiri::XML response.body
164
- sensors_values = @sensors.transform_values do |id|
165
- value = xml.xpath("//O[@I=\"#{id}\"]/@V").last&.value.to_i
166
- value -= 65_536 if value > 32_767
167
- # value -= 0 if "offset"
168
- # value -= 0 if "coef"
169
- value
170
- end
171
- refresh_data(sensors_values)
172
- end
173
-
174
- # @param [Hash] values
175
- # @return [Hash]
176
- def refresh_data(values)
177
- @outdoor_temperature = values[:outdoor_temperature].to_f / 10.0
178
- @current_power = values[:current_power].to_f
179
- @current_mode = mode_map[values[:current_mode]]
180
-
181
- as_json
182
- end
183
-
184
- # ? I10204 ?
185
- def mode_map
186
- { 0 => "Vypnuto", 1 => "Automat", 2 => "Větrání", 6 => "Rozvážení" }
187
- end
188
-
189
- def logger
190
- @logger ||= ::Logger.new($stdout)
191
- end
192
-
193
- def finish_login
194
- 13.times do |i|
195
- return true if open_dashboard
196
- rescue Selenium::WebDriver::Error::NoSuchElementError => _e
197
- t = [3 * (1 + i), 25].min
198
- logger.debug "waiting #{t}s for login..."
199
- sleep t
200
- end
201
- raise Error, "unable to login"
202
- end
203
-
204
- def sensor_element(sensor_id)
205
- driver.find_element id: "contentBox#{sensor_id}"
206
- end
207
-
208
- # @return [RestClient::Response]
209
- def response_comm_unit
210
- params = {
211
- _user: user_id.to_i,
212
- _unit: unit_id,
213
- auth: auth_token || "null",
214
- _t: "config/xml.xml",
215
- }
216
- autologin_token = CGI.escape([@login, @password].join("\b"))
217
- RestClient.get "https://control.atrea.eu/comm/sw/unit.php", { Cookie: "autoLogin=#{autologin_token}", params: params }
218
- end
219
-
220
- # Update tokens based on current state
221
- def refresh!
222
- @user_id = driver.execute_script("return window._user")
223
- @unit_id = driver.execute_script("return window._unit")
224
- @auth_token = user&.[]("auth")
225
- end
9
+ autoload :Login, "atrea_control/duplex/login"
10
+ autoload :Request, "atrea_control/duplex/request"
11
+ autoload :Unit, "atrea_control/duplex/unit"
12
+ autoload :UserCtrl, "atrea_control/duplex/user_ctrl"
226
13
 
227
14
  end
228
15
  end
@@ -0,0 +1,11 @@
1
+ require "logger"
2
+
3
+ module AtreaControl
4
+ module Logger
5
+
6
+ def logger
7
+ @logger ||= ::Logger.new($stdout)
8
+ end
9
+
10
+ end
11
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module AtreaControl
6
+ # Call RD5 unit ang get current sensors values
7
+ # parse it and return Hash
8
+ class SensorParser
9
+ include AtreaControl::Logger
10
+
11
+ # @param [AtreaControl::Duplex::UserCtrl] user_ctrl
12
+ def initialize(user_ctrl)
13
+ @user_ctrl = user_ctrl
14
+ end
15
+
16
+ def values(xml)
17
+ format_data(parse(xml))
18
+ end
19
+
20
+ def input(sensor, value)
21
+ sensor + value.to_s.rjust(5, "0")
22
+ end
23
+
24
+ # @see scripts.php -> loadRD5Values(node, init)
25
+ # @note
26
+ # if(values[key]>32767) values[key]-=65536;
27
+ # if(params[key] && params[key].offset)
28
+ # values[key]=values[key]-params[key].offset;
29
+ # if(params[key] && params[key].coef)
30
+ # values[key]=values[key]/params[key].coef;
31
+ def parse(xml)
32
+ xml = Nokogiri::XML xml
33
+ @user_ctrl.sensors.transform_values do |id|
34
+ # node = xml.xpath("//O[@I=\"#{id}\"]/@V").last
35
+ node = xml.xpath("//O[@I=\"#{id}\"]").last
36
+ logger.debug node.to_s
37
+ value = node.attribute("V").value.to_i
38
+ value -= 65_536 if value > 32_767
39
+ # value -= 0 if "offset"
40
+ # value = value / coef if "coef"
41
+ value
42
+ end
43
+ end
44
+
45
+ # @param [Hash] values
46
+ # @return [Hash]
47
+ def format_data(values)
48
+ {
49
+ "current_mode" => parse_current_mode(values),
50
+ "current_power" => values["current_power"].to_f,
51
+ "outdoor_temperature" => values["outdoor_temperature"].to_f / 10.0,
52
+ "valid_for" => Time.now,
53
+ }
54
+ end
55
+
56
+ private
57
+
58
+ # `current_mode_switch` = mode trigger by wall switch or something similar
59
+ # `current_mode` = is common "builtin" mode
60
+ def parse_current_mode(values)
61
+ if values['current_mode_switch'].positive?
62
+ @user_ctrl.user_modes[values['current_mode_switch'].to_s]
63
+ else
64
+ @user_ctrl.modes[values['current_mode'].to_s]
65
+ end
66
+ end
67
+ end
68
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module AtreaControl
4
4
 
5
- VERSION = "1.3.1"
5
+ VERSION = "2.0.2"
6
6
 
7
7
  end
data/lib/atrea_control.rb CHANGED
@@ -1,22 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "atrea_control/version"
4
+ require "i18n"
5
+ require "yaml"
4
6
 
5
7
  module AtreaControl
6
8
  class Error < StandardError; end
7
9
 
8
10
  autoload :Duplex, "atrea_control/duplex"
11
+ autoload :Logger, "atrea_control/logger"
12
+ autoload :SensorParser, "atrea_control/sensor_parser"
9
13
 
10
- # Provide map sensor to element ID
11
- module Config
12
- module_function
14
+ I18n.load_path.concat Dir["#{File.expand_path("../config/locales", __dir__)}/*.yml"]
15
+ I18n.default_locale = :cs
13
16
 
14
- def default_sensors_map
15
- {
16
- outdoor_temperature: "I10208",
17
- current_power: "H10704",
18
- current_mode: "H10705",
19
- }
20
- end
21
- end
22
17
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atrea_control
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lukáš Pokorný
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-12-11 00:00:00.000000000 Z
11
+ date: 2022-01-20 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: i18n
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.8'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: nokogiri
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -73,16 +87,22 @@ files:
73
87
  - atrea_control.gemspec
74
88
  - bin/console
75
89
  - bin/setup
90
+ - config/locales/cs.yml
91
+ - config/locales/de.yml
92
+ - config/locales/en.yml
76
93
  - lib/atrea_control.rb
77
94
  - lib/atrea_control/duplex.rb
95
+ - lib/atrea_control/duplex/login.rb
96
+ - lib/atrea_control/duplex/request.rb
97
+ - lib/atrea_control/duplex/unit.rb
98
+ - lib/atrea_control/duplex/user_ctrl.rb
99
+ - lib/atrea_control/logger.rb
100
+ - lib/atrea_control/sensor_parser.rb
78
101
  - lib/atrea_control/version.rb
79
102
  homepage: https://github.com/luk4s/atrea_control
80
103
  licenses: []
81
104
  metadata:
82
- allowed_push_host: https://rubygems.org
83
- homepage_uri: https://github.com/luk4s/atrea_control
84
- source_code_uri: https://github.com/luk4s/atrea_control
85
- changelog_uri: https://github.com/luk4s/atrea_control/CHANGELOG.md
105
+ rubygems_mfa_required: 'true'
86
106
  post_install_message:
87
107
  rdoc_options: []
88
108
  require_paths: