atrea_control 1.2.1 → 2.0.0

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,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: