atrea_control 1.4.0 → 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.
- 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: []
|