atrea_control 1.2.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,93 @@
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
+ write(v)
42
+ end
43
+
44
+ def power=(value)
45
+ v = [parser.input(@user_ctrl.sensors["power_input"], value.to_s)]
46
+ v << parser.input(@user_ctrl.sensors["mode_switch"], "2")
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
+ private
71
+
72
+ def parser
73
+ @parser ||= ::AtreaControl::SensorParser.new(@user_ctrl)
74
+ end
75
+
76
+ # Request to RD5
77
+ def request
78
+ @request ||= Request.new(user_id: @user_id, unit_id: @unit_id, sid: @sid)
79
+ end
80
+
81
+ def read
82
+ request.call(_t: "config/xml.xml")
83
+ end
84
+
85
+ # @param [Array<String>] values in format SENSOR0000VALUE
86
+ def write(*values)
87
+ inputs = values.to_h { |i| [i, nil] }
88
+ logger.debug("set RD5 #{inputs.keys}")
89
+ request.call({ _t: "config/xml.cgi", _w: 1 }.merge(inputs))
90
+ end
91
+ end
92
+ end
93
+ 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,206 +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
- # @param [String] login
18
- # @param [String] password
19
- # @param [Hash] sensors_map which box is related to sensor ID
20
- # @option sensors_map [String] :outdoor_temperature
21
- # @option sensors_map [String] :current_power
22
- # @option sensors_map [String] :current_mode
23
- def initialize(login:, password:, sensors_map: Config.default_sensors_map)
24
- @login = login
25
- @password = password
26
- @sensors = sensors_map
27
- end
28
-
29
- # @return [Selenium::WebDriver::Firefox::Driver]
30
- def driver
31
- return @driver if defined?(@driver)
32
-
33
- options = Selenium::WebDriver::Firefox::Options.new
34
- options.headless! unless ENV["NO_HEADLESS"]
35
- @driver ||= Selenium::WebDriver.for :firefox, options: options
36
- end
37
-
38
- def logged?
39
- user&.[] "loged"
40
- end
41
-
42
- def login_in_progress?
43
- @login_in_progress
44
- end
45
-
46
- # Login into control
47
- def login
48
- @login_in_progress = true
49
- logger.debug "start new login"
50
- driver.get CONTROL_URI
51
- submit_login_form if user.nil? || !logged?
52
- finish_login
53
- @login_in_progress = false
54
- inspect
55
- end
56
-
57
- # Submit given credentials and proceed login
58
- def submit_login_form
59
- form = driver.find_element(id: "loginFrm")
60
- username = form.find_element(name: "username")
61
- username.send_keys @login
62
- password = form.find_element(name: "password")
63
- password.send_keys @password
64
-
65
- submit = form.find_element(css: "input[type=submit]")
66
- submit.click
67
- end
68
-
69
- # Retrieve dashboard URI from object tag and open it again
70
- def open_dashboard
71
- uri = driver.find_element(tag_name: "object").attribute "data"
72
- driver.get uri
73
- logger.debug "#{name} login success"
74
- end
75
-
76
- # @return [String]
77
- def name
78
- return unless logged?
79
-
80
- container = driver.find_element css: "div#pageTitle > h2"
81
- container.text
82
- end
83
-
84
- # @return [String] ID of logged user
85
- def user_id
86
- @user_id ||= driver.execute_script("return window._user")
87
- end
88
-
89
- # @return [String] ID of recuperation unit
90
- def unit_id
91
- @unit_id ||= driver.execute_script("return window._unit")
92
- end
93
-
94
- # Window.user object from atrea
95
- # @return [Hash, nil]
96
- def user
97
- driver.execute_script("return window.user")
98
- end
99
-
100
- # @return [String]
101
- def current_mode_name
102
- return current_mode unless logged?
103
-
104
- element = sensor_element(@sensors[:current_mode])
105
- element.find_element(css: "div:first-child").text
106
- end
107
-
108
- # quit selenium browser
109
- def close
110
- @user_auth = nil
111
- driver.quit
112
- remove_instance_variable :@driver
113
- end
114
-
115
- alias logout! close
116
-
117
- def as_json(_options = nil)
118
- {
119
- logged: logged?,
120
- current_mode: current_mode_name,
121
- current_power: current_power,
122
- outdoor_temperature: outdoor_temperature,
123
- valid_for: valid_for,
124
- }
125
- end
126
-
127
- alias values as_json
128
-
129
- def to_json(*args)
130
- as_json.to_json(*args)
131
- end
132
-
133
- def inspect
134
- "<AtreaControl name: '#{name}' outdoor_temperature: '#{outdoor_temperature}°C' current_power: '#{current_power}%' current_mode: '#{current_mode_name}' valid_for: '#{valid_for}'>"
135
- end
136
-
137
- def call_unit!
138
- return false if @login_in_progress
139
-
140
- logger.debug "call_unit!"
141
- parse_response(response_comm_unit)
142
- @valid_for = Time.now
143
- as_json
144
- rescue RestClient::Forbidden
145
- logger.debug "session expired..."
146
- close if @logged
147
- login && call_unit!
148
- end
149
-
150
- private
151
-
152
- def parse_response(response)
153
- xml = Nokogiri::XML response.body
154
- sensors_values = @sensors.transform_values do |id|
155
- xml.xpath("//O[@I=\"#{id}\"]/@V").last&.value
156
- end
157
- refresh_data(sensors_values)
158
- end
159
-
160
- # @param [Hash] values
161
- # @return [Hash]
162
- def refresh_data(values)
163
- @outdoor_temperature = values[:outdoor_temperature].to_f / 10
164
- @current_power = values[:current_power].to_f
165
- @current_mode = mode_map[values[:current_mode]]
166
-
167
- as_json
168
- end
169
-
170
- # ? I10204 ?
171
- def mode_map
172
- { "0" => "Vypnuto", "1" => "Automat", "2" => "Větrání", "6" => "Rozvážení" }
173
- end
174
-
175
- def logger
176
- @logger ||= ::Logger.new($stdout)
177
- end
178
-
179
- def finish_login
180
- 13.times do |i|
181
- return true if open_dashboard
182
- rescue Selenium::WebDriver::Error::NoSuchElementError => _e
183
- t = [3 * (1 + i), 25].min
184
- logger.debug "waiting #{t}s for login..."
185
- sleep t
186
- end
187
- raise Error, "unable to login"
188
- end
189
-
190
- def sensor_element(sensor_id)
191
- driver.find_element id: "contentBox#{sensor_id}"
192
- 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"
193
13
 
194
- # @return [RestClient::Response]
195
- def response_comm_unit
196
- params = {
197
- _user: user_id,
198
- _unit: unit_id,
199
- auth: user&.[]("auth"),
200
- _t: "config/xml.xml",
201
- }
202
- autologin_token = CGI.escape([@login, @password].join("\b"))
203
- RestClient.get "https://control.atrea.eu/comm/sw/unit.php", { Cookie: "autoLogin=#{autologin_token}", params: params }
204
- end
205
14
  end
206
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,63 @@
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
+ # @param [AtreaControl::Duplex::UserCtrl] user_ctrl
10
+ def initialize(user_ctrl)
11
+ @user_ctrl = user_ctrl
12
+ end
13
+
14
+ def values(xml)
15
+ format_data(parse(xml))
16
+ end
17
+
18
+ def input(sensor, value)
19
+ sensor + value.to_s.rjust(5, "0")
20
+ end
21
+
22
+ # @see scripts.php -> loadRD5Values(node, init)
23
+ # @note
24
+ # if(values[key]>32767) values[key]-=65536;
25
+ # if(params[key] && params[key].offset)
26
+ # values[key]=values[key]-params[key].offset;
27
+ # if(params[key] && params[key].coef)
28
+ # values[key]=values[key]/params[key].coef;
29
+ def parse(xml)
30
+ xml = Nokogiri::XML xml
31
+ @user_ctrl.sensors.transform_values do |id|
32
+ value = xml.xpath("//O[@I=\"#{id}\"]/@V").last&.value.to_i
33
+ value -= 65_536 if value > 32_767
34
+ # value -= 0 if "offset"
35
+ # value -= 0 if "coef"
36
+ value
37
+ end
38
+ end
39
+
40
+ # @param [Hash] values
41
+ # @return [Hash]
42
+ def format_data(values)
43
+ {
44
+ "current_mode" => parse_current_mode(values),
45
+ "current_power" => values["current_power"].to_f,
46
+ "outdoor_temperature" => values["outdoor_temperature"].to_f / 10.0,
47
+ "valid_for" => Time.now,
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ # `current_mode_switch` = mode trigger by wall switch or something similar
54
+ # `current_mode` = is common "builtin" mode
55
+ def parse_current_mode(values)
56
+ if values['current_mode_switch'].positive?
57
+ @user_ctrl.user_modes[values['current_mode_switch'].to_s]
58
+ else
59
+ @user_ctrl.modes[values['current_mode'].to_s]
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtreaControl
4
- VERSION = "1.2.1"
4
+
5
+ VERSION = "2.0.0"
6
+
5
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.2.1
4
+ version: 2.0.0
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-10-30 00:00:00.000000000 Z
11
+ date: 2021-12-25 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
@@ -44,14 +58,14 @@ dependencies:
44
58
  requirements:
45
59
  - - "~>"
46
60
  - !ruby/object:Gem::Version
47
- version: '3.142'
61
+ version: '4.1'
48
62
  type: :runtime
49
63
  prerelease: false
50
64
  version_requirements: !ruby/object:Gem::Requirement
51
65
  requirements:
52
66
  - - "~>"
53
67
  - !ruby/object:Gem::Version
54
- version: '3.142'
68
+ version: '4.1'
55
69
  description: Read data from web controller of RD5 duplex by Atrea.
56
70
  email:
57
71
  - pokorny@luk4s.cz
@@ -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: