atrea_control 1.4.0 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +1 -1
- data/CHANGELOG.md +11 -0
- data/Gemfile.lock +7 -5
- data/README.md +50 -16
- data/lib/atrea_control/duplex/login.rb +107 -0
- data/lib/atrea_control/duplex/request.rb +29 -0
- data/lib/atrea_control/duplex/unit.rb +93 -0
- data/lib/atrea_control/duplex/user_ctrl.rb +138 -0
- data/lib/atrea_control/duplex.rb +6 -269
- data/lib/atrea_control/logger.rb +11 -0
- data/lib/atrea_control/sensor_parser.rb +63 -0
- data/lib/atrea_control/version.rb +1 -1
- data/lib/atrea_control.rb +2 -13
- metadata +8 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 01f3c707897b8cd1626855caaa5cd4758ebeae0497881dd5164c2fb67a610327
|
4
|
+
data.tar.gz: f567bde666512960777679842366f249b0eff130e5a3fd1c487d5d652ef11687
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6f4a7ef12bbfde071d91b350e6b100d4528c3275a2642680cf26117ace31ef9ca9ed3072aaad9cb38189f336ec563d4b764db13238248295fc84ecbfe6977f65
|
7
|
+
data.tar.gz: d933b73a8afe23d567aba8f56d1d58060f53699fc5fadaa2eb897e9ff1a6f2e9aa1ed314b2f805a4923d08038c8db8924b7dfc2e5e155c6219a376e2fe05f84e
|
data/.github/workflows/main.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,4 +1,15 @@
|
|
1
1
|
## [Unreleased]
|
2
|
+
### Changed
|
3
|
+
- refactored codebase to more readable classes
|
4
|
+
- selenium used only for login and then close => obtain SID (and user with unit)
|
5
|
+
- login waiting mechanism
|
6
|
+
### Added
|
7
|
+
- found way how to change mode & power (tell unit to change)
|
8
|
+
|
9
|
+
## [1.4.1] - 2021-12-18
|
10
|
+
### Changed
|
11
|
+
- little refactor
|
12
|
+
|
2
13
|
## [1.4.0] - 2021-12-12
|
3
14
|
### Changed
|
4
15
|
- founded way to get config and data from Atrea server
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
atrea_control (
|
4
|
+
atrea_control (2.0.0)
|
5
5
|
i18n (~> 1.8)
|
6
6
|
nokogiri (~> 1.12)
|
7
7
|
rest-client (~> 2.1)
|
@@ -18,7 +18,7 @@ GEM
|
|
18
18
|
concurrent-ruby (1.1.9)
|
19
19
|
crack (0.4.5)
|
20
20
|
rexml
|
21
|
-
diff-lcs (1.
|
21
|
+
diff-lcs (1.5.0)
|
22
22
|
docile (1.4.0)
|
23
23
|
domain_name (0.5.20190701)
|
24
24
|
unf (>= 0.0.5, < 1.0.0)
|
@@ -35,6 +35,8 @@ GEM
|
|
35
35
|
netrc (0.11.0)
|
36
36
|
nokogiri (1.12.5-x86_64-darwin)
|
37
37
|
racc (~> 1.4)
|
38
|
+
nokogiri (1.12.5-x86_64-linux)
|
39
|
+
racc (~> 1.4)
|
38
40
|
parallel (1.21.0)
|
39
41
|
parser (3.0.3.2)
|
40
42
|
ast (~> 2.4.1)
|
@@ -65,16 +67,16 @@ GEM
|
|
65
67
|
diff-lcs (>= 1.2.0, < 2.0)
|
66
68
|
rspec-support (~> 3.10.0)
|
67
69
|
rspec-support (3.10.3)
|
68
|
-
rubocop (1.
|
70
|
+
rubocop (1.24.0)
|
69
71
|
parallel (~> 1.10)
|
70
72
|
parser (>= 3.0.0.0)
|
71
73
|
rainbow (>= 2.2.2, < 4.0)
|
72
74
|
regexp_parser (>= 1.8, < 3.0)
|
73
75
|
rexml
|
74
|
-
rubocop-ast (>= 1.
|
76
|
+
rubocop-ast (>= 1.15.0, < 2.0)
|
75
77
|
ruby-progressbar (~> 1.7)
|
76
78
|
unicode-display_width (>= 1.4.0, < 3.0)
|
77
|
-
rubocop-ast (1.
|
79
|
+
rubocop-ast (1.15.0)
|
78
80
|
parser (>= 3.0.1.1)
|
79
81
|
rubocop-rspec (2.6.0)
|
80
82
|
rubocop (~> 1.19)
|
data/README.md
CHANGED
@@ -10,7 +10,9 @@ This gem provide simple DSL by parsing content of https://control.atrea.eu with
|
|
10
10
|
* temperature
|
11
11
|
* fan power
|
12
12
|
* power mode
|
13
|
-
|
13
|
+
* allow change
|
14
|
+
* power
|
15
|
+
* mode
|
14
16
|
|
15
17
|
## Installation
|
16
18
|
|
@@ -30,27 +32,59 @@ Or install it yourself as:
|
|
30
32
|
|
31
33
|
## Usage
|
32
34
|
|
33
|
-
|
34
|
-
|
35
|
-
Default sensor map:
|
35
|
+
At the begin you need obtain `user_id`, `unit_id` and `sid` (auth token). For this use "Login"
|
36
36
|
```ruby
|
37
|
-
|
38
|
-
|
39
|
-
outdoor_temperature: "I10208",
|
40
|
-
current_power: "H10704",
|
41
|
-
current_mode: "H10705",
|
42
|
-
}
|
43
|
-
end
|
37
|
+
tokens = AtreaControl::Duplex::Login.user_tokens login: "myhome", password: "sup3r-S3CR3T-kocicka"
|
38
|
+
tokens # => { user_id: "1234", unit_id: "85425324672", sid: 4012 }
|
44
39
|
```
|
40
|
+
I recommend to store then somewhere...
|
41
|
+
Then you can call Unit for data...
|
42
|
+
|
45
43
|
Example usage:
|
46
44
|
```ruby
|
47
|
-
control = AtreaControl::Duplex.new
|
48
|
-
control.
|
49
|
-
control.
|
50
|
-
|
45
|
+
control = AtreaControl::Duplex::Unit.new user_id: "1234", unit_id: "85425324672", sid: 4012
|
46
|
+
control.values # => { current_power: 88.0, current_mode: "CO2" }
|
47
|
+
control.power # => 88.0
|
48
|
+
```
|
49
|
+
### Dig deeper
|
50
|
+
`AtreaControl::Duplex::Unit` expect optional argument `user_ctrl` which should be object respond to
|
51
|
+
|
52
|
+
`name` (String) = Name of unit
|
53
|
+
`sensors` (Hash) = Map of sensors, for example: `{ outdoor_temperature: "HI10208", current_power: "H10704" }`
|
54
|
+
`modes` (Hash) = Is a map of "changable" modes - in unit its something like "builtin?" modes. They are translated by unit lang - `{ "0" => "Vypnuto", "1" => "Automat" }`
|
55
|
+
`user_modes` (Hash) = Is a map user specific modes, based on home switches / devices (D1, D2, D3, IN1, IN2 ...). They are translated by user texts - `{ "D1" => "Koupelna", "D2" => "CO2", "IN1" => "ovladač" }`
|
56
|
+
|
57
|
+
__Please check [lib/atrea_control/duplex/user_ctrl.rb](./lib/atrea_control/duplex/user_ctrl.rb) for more details !__
|
58
|
+
|
59
|
+
## Development / TODO
|
60
|
+
Login is currently done by selenium - fill login form.
|
61
|
+
I found that Atre submit form to BE, generate some "empty" HTML and JS which onLoad start doing request to queue for "login".
|
62
|
+
|
63
|
+
Re-login user, add login procedure into queue:
|
64
|
+
```bash
|
65
|
+
curl -X POST -d "comm=config%2Flogin.cgi" "https://control.atrea.eu/apps/rd5Control/handle.php?action=unitLogin&user=XXXX&unit=NNNNNNN&table=userUnits&idPwd=YYYYYYY&NFP"
|
66
|
+
```
|
67
|
+
Response is time in seconds when login will ready:
|
68
|
+
```xml
|
69
|
+
<root><sended time="264"/></root>
|
70
|
+
```
|
71
|
+
Based it su shown countdown ...
|
72
|
+
|
73
|
+
|
74
|
+
Request for current queue status
|
75
|
+
```bash
|
76
|
+
curl 'https://control.atrea.eu/apps/rd5Control/handle.php?Sync=1&action=unitQuery&query=loged&user=XXXX&unit=NNNNNNN'
|
77
|
+
```
|
78
|
+
if queue is processed:
|
79
|
+
```xml
|
80
|
+
<root><login uconn="16395889" sid="010101" ver="3001009"/></root>
|
81
|
+
```
|
82
|
+
else
|
83
|
+
```xml
|
84
|
+
<root><login uconn="16390480" sid="0"/></root>
|
51
85
|
```
|
52
86
|
|
53
|
-
|
87
|
+
Goal is to obtain "SID".
|
54
88
|
|
55
89
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
56
90
|
|
@@ -0,0 +1,107 @@
|
|
1
|
+
require "selenium-webdriver"
|
2
|
+
|
3
|
+
module AtreaControl
|
4
|
+
module Duplex
|
5
|
+
# Process login into RD5 with selenium to get `sid` ( auth_token ) for direct API communication
|
6
|
+
class Login
|
7
|
+
|
8
|
+
include AtreaControl::Logger
|
9
|
+
|
10
|
+
# @return [Hash] - user_id, unit_id, sid
|
11
|
+
def self.user_tokens(login:, password:)
|
12
|
+
i = new(login: login, password: password)
|
13
|
+
tokens = i.user
|
14
|
+
|
15
|
+
tokens
|
16
|
+
ensure
|
17
|
+
i.close
|
18
|
+
end
|
19
|
+
|
20
|
+
# @param [String] login
|
21
|
+
# @param [String] password
|
22
|
+
def initialize(login:, password:)
|
23
|
+
@login = login
|
24
|
+
@password = password
|
25
|
+
end
|
26
|
+
|
27
|
+
def user
|
28
|
+
raise AtreaControl::Error, "Must be logged in" unless login
|
29
|
+
|
30
|
+
logger.debug "refresh user data based on session"
|
31
|
+
@user_id = driver.execute_script("return window._user")
|
32
|
+
@unit_id = driver.execute_script("return window._unit")
|
33
|
+
@auth_token = driver.execute_script("return window.user")&.[]("auth") # sid
|
34
|
+
|
35
|
+
{ user_id: @user_id, unit_id: @unit_id, sid: @auth_token }
|
36
|
+
end
|
37
|
+
|
38
|
+
# @return [Selenium::WebDriver::Firefox::Driver]
|
39
|
+
def driver
|
40
|
+
return @driver if defined?(@driver)
|
41
|
+
|
42
|
+
options = Selenium::WebDriver::Firefox::Options.new
|
43
|
+
options.headless! unless ENV["NO_HEADLESS"]
|
44
|
+
@driver ||= Selenium::WebDriver.for :firefox, capabilities: [options]
|
45
|
+
end
|
46
|
+
|
47
|
+
# Login into control
|
48
|
+
def login
|
49
|
+
return driver if @logged
|
50
|
+
|
51
|
+
@login_in_progress = true
|
52
|
+
logger.debug "start new login..."
|
53
|
+
driver.get "#{AtreaControl::Duplex::CONTROL_URI}?action=logout"
|
54
|
+
submit_login_form
|
55
|
+
finish_login
|
56
|
+
driver
|
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
|
+
logger.debug "Submit login form..."
|
69
|
+
|
70
|
+
submit = form.find_element(css: "input[type=submit]")
|
71
|
+
submit.click
|
72
|
+
end
|
73
|
+
|
74
|
+
# Retrieve dashboard URI from object tag and open it again
|
75
|
+
def open_dashboard
|
76
|
+
uri = driver.find_element(tag_name: "object").attribute "data"
|
77
|
+
# Open "iframe" with atrea dashboard - it propagate window objects...
|
78
|
+
driver.get uri
|
79
|
+
logger.debug "login success"
|
80
|
+
@logged = true
|
81
|
+
end
|
82
|
+
|
83
|
+
# quit selenium browser
|
84
|
+
def close
|
85
|
+
driver.quit rescue nil
|
86
|
+
logger.debug "driver closed & destroyed"
|
87
|
+
ensure
|
88
|
+
remove_instance_variable :@driver
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def finish_login
|
94
|
+
30.times do |i|
|
95
|
+
return true if open_dashboard
|
96
|
+
rescue Selenium::WebDriver::Error::NoSuchElementError => e
|
97
|
+
logger.debug e.message
|
98
|
+
logger.debug "#{i + 1}/30 attempt for login..."
|
99
|
+
sleep 10
|
100
|
+
end
|
101
|
+
File.write("/tmp/failed_login-#{@login}.html", driver.page_source)
|
102
|
+
raise AtreaControl::Error, "unable to login"
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require "rest-client"
|
2
|
+
|
3
|
+
module AtreaControl
|
4
|
+
module Duplex
|
5
|
+
# Process request with duplex unit itself. Handle response
|
6
|
+
class Request
|
7
|
+
|
8
|
+
# @param [String, Integer] user_id
|
9
|
+
# @param [String, Integer] unit_id
|
10
|
+
# @param [String, Integer] sid
|
11
|
+
# @note `ver` is done by atrea server
|
12
|
+
def initialize(user_id:, unit_id:, sid:)
|
13
|
+
@params = {
|
14
|
+
_user: user_id.to_i,
|
15
|
+
_unit: unit_id,
|
16
|
+
auth: sid,
|
17
|
+
ver: AtreaControl::Duplex::CONTROL_VERSION,
|
18
|
+
}
|
19
|
+
end
|
20
|
+
|
21
|
+
# @param [Hash] params
|
22
|
+
# @option params [String] :_t ("config/xml.xml") file name
|
23
|
+
def call(params)
|
24
|
+
RestClient.get "#{AtreaControl::Duplex::CONTROL_URI}/comm/sw/unit.php", params: @params.merge(params)
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -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
|
data/lib/atrea_control/duplex.rb
CHANGED
@@ -1,278 +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
|
-
|
5
|
+
module Duplex
|
11
6
|
CONTROL_URI = "https://control.atrea.eu/"
|
7
|
+
CONTROL_VERSION = "003001009"
|
12
8
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
attr_accessor :user_id, :unit_id, :auth_token
|
18
|
-
attr_writer :user_texts, :user_modes, :modes
|
19
|
-
|
20
|
-
# @param [String] login
|
21
|
-
# @param [String] password
|
22
|
-
# @param [Hash] sensors_map which box is related to sensor ID
|
23
|
-
# @option sensors_map [String] :outdoor_temperature
|
24
|
-
# @option sensors_map [String] :current_power
|
25
|
-
# @option sensors_map [String] :current_mode
|
26
|
-
def initialize(login:, password:, sensors_map: Config.default_sensors_map)
|
27
|
-
@login = login
|
28
|
-
@password = password
|
29
|
-
@sensors = sensors_map
|
30
|
-
end
|
31
|
-
|
32
|
-
# @return [Selenium::WebDriver::Firefox::Driver]
|
33
|
-
def driver
|
34
|
-
return @driver if defined?(@driver)
|
35
|
-
|
36
|
-
options = Selenium::WebDriver::Firefox::Options.new
|
37
|
-
options.headless! unless ENV["NO_HEADLESS"]
|
38
|
-
@driver ||= Selenium::WebDriver.for :firefox, capabilities: [options]
|
39
|
-
end
|
40
|
-
|
41
|
-
def logged?
|
42
|
-
user&.[] "loged"
|
43
|
-
end
|
44
|
-
|
45
|
-
def login_in_progress?
|
46
|
-
@login_in_progress
|
47
|
-
end
|
48
|
-
|
49
|
-
# Login into control
|
50
|
-
def login
|
51
|
-
@login_in_progress = true
|
52
|
-
logger.debug "start new login"
|
53
|
-
driver.get CONTROL_URI
|
54
|
-
submit_login_form unless logged?
|
55
|
-
finish_login
|
56
|
-
refresh!
|
57
|
-
inspect
|
58
|
-
ensure
|
59
|
-
@login_in_progress = false
|
60
|
-
end
|
61
|
-
|
62
|
-
# Submit given credentials and proceed login
|
63
|
-
def submit_login_form
|
64
|
-
form = driver.find_element(id: "loginFrm")
|
65
|
-
username = form.find_element(name: "username")
|
66
|
-
username.send_keys @login
|
67
|
-
password = form.find_element(name: "password")
|
68
|
-
password.send_keys @password
|
69
|
-
|
70
|
-
submit = form.find_element(css: "input[type=submit]")
|
71
|
-
submit.click
|
72
|
-
end
|
73
|
-
|
74
|
-
# Retrieve dashboard URI from object tag and open it again
|
75
|
-
def open_dashboard
|
76
|
-
uri = driver.find_element(tag_name: "object").attribute "data"
|
77
|
-
driver.get uri
|
78
|
-
logger.debug "#{name} login success"
|
79
|
-
end
|
80
|
-
|
81
|
-
# @return [String]
|
82
|
-
def name
|
83
|
-
@name ||= user_texts["UnitName"]
|
84
|
-
end
|
85
|
-
|
86
|
-
# Window.user object from atrea
|
87
|
-
# @return [Hash, nil]
|
88
|
-
def user
|
89
|
-
driver.execute_script("return window.user")
|
90
|
-
end
|
91
|
-
|
92
|
-
# quit selenium browser
|
93
|
-
def close
|
94
|
-
driver.quit
|
95
|
-
ensure
|
96
|
-
remove_instance_variable :@driver
|
97
|
-
end
|
98
|
-
|
99
|
-
alias logout! close
|
100
|
-
|
101
|
-
def as_json(_options = nil)
|
102
|
-
{
|
103
|
-
current_mode: current_mode,
|
104
|
-
current_power: current_power,
|
105
|
-
outdoor_temperature: outdoor_temperature,
|
106
|
-
valid_for: valid_for,
|
107
|
-
}
|
108
|
-
end
|
109
|
-
|
110
|
-
alias values as_json
|
111
|
-
|
112
|
-
def to_json(*args)
|
113
|
-
as_json.to_json(*args)
|
114
|
-
end
|
115
|
-
|
116
|
-
def call_unit!
|
117
|
-
return false if @login_in_progress
|
118
|
-
|
119
|
-
logger.debug "call_unit!"
|
120
|
-
parse_values(response_comm_unit)
|
121
|
-
@valid_for = Time.now
|
122
|
-
as_json
|
123
|
-
end
|
124
|
-
|
125
|
-
def modes
|
126
|
-
return @modes if @modes
|
127
|
-
|
128
|
-
@modes = {}
|
129
|
-
user_ctrl.xpath("//op[@id='Mode']/i").each do |mode|
|
130
|
-
m = translate_mode(mode)
|
131
|
-
@modes[m[:id]] = m[:value]
|
132
|
-
end
|
133
|
-
@modes
|
134
|
-
end
|
135
|
-
|
136
|
-
def user_modes
|
137
|
-
return @user_modes if @user_modes
|
138
|
-
|
139
|
-
@user_modes = {}
|
140
|
-
user_ctrl.xpath("//op[@id='ModeText']/i").each do |mode|
|
141
|
-
m = translate_mode(mode)
|
142
|
-
@user_modes[m[:id]] = m[:value]
|
143
|
-
end
|
144
|
-
@user_modes
|
145
|
-
end
|
146
|
-
|
147
|
-
# User defined texts in RD5 unit
|
148
|
-
# @return [Hash]
|
149
|
-
def user_texts
|
150
|
-
return @user_texts if @user_texts
|
151
|
-
|
152
|
-
response = rd5_request(params_comm_unit.merge(_t: "config/texts.xml"))
|
153
|
-
xml = Nokogiri::XML response.body
|
154
|
-
@user_texts = xml.xpath("//i").map do |node|
|
155
|
-
value = node.attributes["value"].value
|
156
|
-
id = node.attributes["id"].value
|
157
|
-
[id, value.gsub(/%u([\dA-Z]{4})/) { |i| +'' << i[$1].to_i(16) }]
|
158
|
-
end.to_h
|
159
|
-
end
|
160
|
-
|
161
|
-
private
|
162
|
-
|
163
|
-
# @see scripts.php -> loadRD5Values(node, init)
|
164
|
-
# @note
|
165
|
-
# if(values[key]>32767) values[key]-=65536;
|
166
|
-
# if(params[key] && params[key].offset)
|
167
|
-
# values[key]=values[key]-params[key].offset;
|
168
|
-
# if(params[key] && params[key].coef)
|
169
|
-
# values[key]=values[key]/params[key].coef;
|
170
|
-
def parse_values(response)
|
171
|
-
xml = Nokogiri::XML response.body
|
172
|
-
sensors_values = @sensors.transform_values do |id|
|
173
|
-
value = xml.xpath("//O[@I=\"#{id}\"]/@V").last&.value.to_i
|
174
|
-
value -= 65_536 if value > 32_767
|
175
|
-
# value -= 0 if "offset"
|
176
|
-
# value -= 0 if "coef"
|
177
|
-
value
|
178
|
-
end
|
179
|
-
refresh_data(sensors_values)
|
180
|
-
end
|
181
|
-
|
182
|
-
# @param [Hash] values
|
183
|
-
# @return [Hash]
|
184
|
-
def refresh_data(values)
|
185
|
-
@outdoor_temperature = values[:outdoor_temperature].to_f / 10.0
|
186
|
-
@current_power = values[:current_power].to_f
|
187
|
-
@current_mode = if values[:current_mode_switch].positive?
|
188
|
-
user_modes[values[:current_mode_switch].to_s]
|
189
|
-
else
|
190
|
-
modes[values[:current_mode].to_s]
|
191
|
-
end
|
192
|
-
# @current_mode = mode_map[values[:current_mode_name] > 0 ? values[:current_mode_name] : values[:current_mode]]
|
193
|
-
|
194
|
-
as_json
|
195
|
-
end
|
196
|
-
|
197
|
-
def logger
|
198
|
-
@logger ||= ::Logger.new($stdout)
|
199
|
-
end
|
200
|
-
|
201
|
-
def finish_login
|
202
|
-
13.times do |i|
|
203
|
-
return true if open_dashboard
|
204
|
-
rescue Selenium::WebDriver::Error::NoSuchElementError => _e
|
205
|
-
t = [3 * (1 + i), 25].min
|
206
|
-
logger.debug "waiting #{t}s for login..."
|
207
|
-
sleep t
|
208
|
-
end
|
209
|
-
raise Error, "unable to login"
|
210
|
-
end
|
211
|
-
|
212
|
-
def sensor_element(sensor_id)
|
213
|
-
driver.find_element id: "contentBox#{sensor_id}"
|
214
|
-
end
|
215
|
-
|
216
|
-
# @return [RestClient::Response]
|
217
|
-
def response_comm_unit
|
218
|
-
rd5_request(params_comm_unit.merge(_t: "config/xml.xml"))
|
219
|
-
end
|
220
|
-
|
221
|
-
def rd5_request(params)
|
222
|
-
RestClient.get "https://control.atrea.eu/comm/sw/unit.php", { Cookie: "autoLogin=#{autologin_token}", params: params }
|
223
|
-
end
|
224
|
-
|
225
|
-
def user_ctrl
|
226
|
-
response = rd5_request(params_comm_unit.merge(_t: "lang/userCtrl.xml"))
|
227
|
-
xml = Nokogiri::XML response.body
|
228
|
-
end
|
229
|
-
|
230
|
-
def autologin_token
|
231
|
-
@autologin_token ||= CGI.escape([@login, @password].join("\b"))
|
232
|
-
end
|
233
|
-
|
234
|
-
# @note `ver` is done by atrea server
|
235
|
-
def params_comm_unit
|
236
|
-
{
|
237
|
-
_user: user_id.to_i,
|
238
|
-
_unit: unit_id,
|
239
|
-
auth: auth_token || "null",
|
240
|
-
ver: "003001009",
|
241
|
-
}
|
242
|
-
end
|
243
|
-
|
244
|
-
# Update tokens based on current state
|
245
|
-
def refresh!
|
246
|
-
@user_id = driver.execute_script("return window._user")
|
247
|
-
@unit_id = driver.execute_script("return window._unit")
|
248
|
-
@auth_token = user&.[]("auth")
|
249
|
-
end
|
250
|
-
|
251
|
-
# @param [Nokogiri::XML::Element] mode
|
252
|
-
# @return [Hash{Symbol->String}]
|
253
|
-
def translate_mode(mode)
|
254
|
-
id = mode.attributes["id"].value
|
255
|
-
title = mode.attributes["title"].value
|
256
|
-
title = if title.start_with?("$")
|
257
|
-
I18n.t(title[/\w+/])
|
258
|
-
else
|
259
|
-
user_texts[title]
|
260
|
-
end
|
261
|
-
{ id: id, value: title }
|
262
|
-
end
|
263
|
-
|
264
|
-
# Re-generate copy of locale files
|
265
|
-
# @note internal use only
|
266
|
-
def update_locales_files!
|
267
|
-
{ cs: "0", de: "1", en: "2" }.each do |name, atrea_id|
|
268
|
-
response = rd5_request(params_comm_unit.merge(_t: "lang/texts_#{atrea_id}.xml", auth: nil))
|
269
|
-
|
270
|
-
xml = Nokogiri::XML response.body
|
271
|
-
locale = eval xml.xpath("//words/text()")[0].text
|
272
|
-
yaml = { name.to_s => JSON.parse(locale.to_json) }.to_yaml
|
273
|
-
File.write(File.expand_path("../../config/locales/#{name}.yml", __dir__), yaml)
|
274
|
-
end
|
275
|
-
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"
|
276
13
|
|
277
14
|
end
|
278
15
|
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
|
data/lib/atrea_control.rb
CHANGED
@@ -8,21 +8,10 @@ module AtreaControl
|
|
8
8
|
class Error < StandardError; end
|
9
9
|
|
10
10
|
autoload :Duplex, "atrea_control/duplex"
|
11
|
+
autoload :Logger, "atrea_control/logger"
|
12
|
+
autoload :SensorParser, "atrea_control/sensor_parser"
|
11
13
|
|
12
14
|
I18n.load_path.concat Dir["#{File.expand_path("../config/locales", __dir__)}/*.yml"]
|
13
15
|
I18n.default_locale = :cs
|
14
16
|
|
15
|
-
# Provide map sensor to element ID
|
16
|
-
module Config
|
17
|
-
module_function
|
18
|
-
|
19
|
-
def default_sensors_map
|
20
|
-
{
|
21
|
-
outdoor_temperature: "I10208",
|
22
|
-
current_power: "H10704",
|
23
|
-
current_mode: "H10705",
|
24
|
-
current_mode_switch: "H10712",
|
25
|
-
}
|
26
|
-
end
|
27
|
-
end
|
28
17
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: atrea_control
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
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-12-
|
11
|
+
date: 2021-12-25 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: i18n
|
@@ -92,6 +92,12 @@ files:
|
|
92
92
|
- config/locales/en.yml
|
93
93
|
- lib/atrea_control.rb
|
94
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
|
95
101
|
- lib/atrea_control/version.rb
|
96
102
|
homepage: https://github.com/luk4s/atrea_control
|
97
103
|
licenses: []
|