atrea_control 1.3.1 → 2.0.2

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.
@@ -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: