From aa181b34fae1e2226c1f61a74be72a546c7ea458 Mon Sep 17 00:00:00 2001 From: Lukas Pokorny Date: Fri, 24 Jan 2025 18:10:43 +0100 Subject: [PATCH] start work for remove selenium (#172) * draft: start work for remove selenium * refactored Login class for new login procedure * fix processing invalid (expired) data - check * raise exception if session expired * version++, changelog, readme, test several version of ruby * test only ruby3-latest * nocov version file --- .github/workflows/main.yml | 10 +- .gitignore | 1 + .rubocop.yml | 2 +- .rubocop_todo.yml | 45 +++-- .ruby-version | 2 +- CHANGELOG.md | 10 + Gemfile.lock | 63 +++---- README.md | 56 +++--- atrea_control.gemspec | 1 - bin/console | 8 +- lib/atrea_control.rb | 6 + lib/atrea_control/duplex.rb | 2 +- lib/atrea_control/duplex/login.rb | 176 +++++++++--------- lib/atrea_control/duplex/request.rb | 1 + lib/atrea_control/duplex/unit.rb | 27 ++- lib/atrea_control/duplex/user_ctrl.rb | 2 +- lib/atrea_control/sensor_parser.rb | 5 +- lib/atrea_control/version.rb | 3 +- spec/atrea_control/duplex/login_spec.rb | 78 ++++---- spec/atrea_control/duplex/unit_spec.rb | 52 +++++- spec/atrea_control/duplex/user_ctrl_spec.rb | 4 +- spec/atrea_control/duplex_spec.rb | 3 - spec/fixtures/files/core.php-init.xml | 1 + spec/fixtures/files/data.php-getdata.xml | 1 + spec/fixtures/files/data.php-getrecord.xml | 1 + .../fixtures/files/handle.php-unitQuery_0.xml | 1 + .../files/handle.php-unitQuery_009.xml | 1 + 27 files changed, 318 insertions(+), 244 deletions(-) delete mode 100644 spec/atrea_control/duplex_spec.rb create mode 100644 spec/fixtures/files/core.php-init.xml create mode 100644 spec/fixtures/files/data.php-getdata.xml create mode 100644 spec/fixtures/files/data.php-getrecord.xml create mode 100644 spec/fixtures/files/handle.php-unitQuery_0.xml create mode 100644 spec/fixtures/files/handle.php-unitQuery_009.xml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9f570c2..040d9d4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,17 +1,19 @@ name: Ruby -on: [push,pull_request] +on: [push, pull_request] jobs: spec: runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: [3.2, 3.3, 3] steps: - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.2.2 + ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - - uses: browser-actions/setup-firefox@latest - name: Run tests - run: bundle exec rspec + run: bundle exec rspec \ No newline at end of file diff --git a/.gitignore b/.gitignore index 325940a..97e56cc 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ # rspec failure tracking .rspec_status +.env \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index 6960291..7338810 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,7 +5,7 @@ require: AllCops: NewCops: enable SuggestExtensions: false - TargetRubyVersion: 3.2 + TargetRubyVersion: 3.3 Style/StringLiterals: Enabled: true diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 4922a8c..4cd8899 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,17 +1,24 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2023-03-01 20:32:36 UTC using RuboCop version 1.47.0. +# on 2025-01-24 16:50:34 UTC using RuboCop version 1.70.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 3 +# Offense count: 1 +# Configuration parameters: Severity, Include. +# Include: **/*.gemspec +Gemspec/RequiredRubyVersion: + Exclude: + - 'atrea_control.gemspec' + +# Offense count: 5 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: Max: 34 -# Offense count: 2 +# Offense count: 3 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: Max: 21 @@ -28,28 +35,30 @@ RSpec/ExpectInHook: Exclude: - 'spec/atrea_control/duplex/unit_spec.rb' -# Offense count: 7 -# Configuration parameters: EnforcedStyle. +# Offense count: 4 +# Configuration parameters: . # SupportedStyles: have_received, receive RSpec/MessageSpies: - Exclude: - - 'spec/atrea_control/duplex/login_spec.rb' - - 'spec/atrea_control/duplex/unit_spec.rb' + EnforcedStyle: receive -# Offense count: 2 +# Offense count: 1 RSpec/MultipleExpectations: Max: 3 -# Offense count: 3 +# Offense count: 4 +# This cop supports unsafe autocorrection (--autocorrect-all). +RSpec/ReceiveMessages: + Exclude: + - 'spec/atrea_control/duplex/unit_spec.rb' + +# Offense count: 1 RSpec/StubbedMock: Exclude: - - 'spec/atrea_control/duplex/login_spec.rb' - 'spec/atrea_control/duplex/unit_spec.rb' -# Offense count: 6 +# Offense count: 9 RSpec/SubjectStub: Exclude: - - 'spec/atrea_control/duplex/login_spec.rb' - 'spec/atrea_control/duplex/unit_spec.rb' - 'spec/atrea_control/duplex/user_ctrl_spec.rb' @@ -57,7 +66,6 @@ RSpec/SubjectStub: # Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. RSpec/VerifiedDoubles: Exclude: - - 'spec/atrea_control/duplex/login_spec.rb' - 'spec/atrea_control/duplex/unit_spec.rb' - 'spec/atrea_control/duplex/user_ctrl_spec.rb' @@ -66,7 +74,7 @@ Security/Eval: Exclude: - 'lib/atrea_control/duplex/user_ctrl.rb' -# Offense count: 2 +# Offense count: 3 # Configuration parameters: AllowedConstants. Style/Documentation: Exclude: @@ -74,3 +82,10 @@ Style/Documentation: - 'test/**/*' - 'lib/atrea_control.rb' - 'lib/atrea_control/logger.rb' + +# Offense count: 9 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. +# URISchemes: http, https +Layout/LineLength: + Max: 238 diff --git a/.ruby-version b/.ruby-version index a0891f5..9c25013 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.4 +3.3.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b37cd7..992e373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,14 @@ ## [Unreleased] +## [3.0.0] - 2025-01-25 +### Removed +- selenium-based login procedure +### Added +- token / phpsessionid-based login in background +- "session" validity check & expiration (exception) +### Changed +- ruby version 3.3+ +- +## [2.2.0] - 2024-10-20 ### Added - timestamp from atrea server ### Changed diff --git a/Gemfile.lock b/Gemfile.lock index 7c3b015..55dfb0a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,10 @@ PATH remote: . specs: - atrea_control (2.3.0) + atrea_control (3.0.0) i18n (~> 1.14) nokogiri (~> 1.15) rest-client (~> 2.1) - selenium-webdriver (~> 4.25.0) GEM remote: https://rubygems.org/ @@ -13,55 +12,54 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) - base64 (0.2.0) - bigdecimal (3.1.8) + bigdecimal (3.1.9) coderay (1.1.3) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.5) crack (1.0.0) bigdecimal rexml diff-lcs (1.5.1) docile (1.4.1) domain_name (0.6.20240107) - hashdiff (1.1.1) + hashdiff (1.1.2) http-accept (1.7.0) - http-cookie (1.0.7) + http-cookie (1.0.8) domain_name (~> 0.5) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.7.5) + json (2.9.1) language_server-protocol (3.17.0.3) - logger (1.6.1) + logger (1.6.5) method_source (1.1.0) mime-types (3.6.0) logger mime-types-data (~> 3.2015) - mime-types-data (3.2024.1001) + mime-types-data (3.2025.0107) netrc (0.11.0) - nokogiri (1.17.2-arm64-darwin) + nokogiri (1.18.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.17.2-x86_64-darwin) + nokogiri (1.18.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.17.2-x86_64-linux) + nokogiri (1.18.2-x86_64-linux-gnu) racc (~> 1.4) parallel (1.26.3) - parser (3.3.5.1) + parser (3.3.7.0) ast (~> 2.4.1) racc - pry (0.15.0) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) public_suffix (6.0.1) racc (1.8.1) rainbow (3.1.1) rake (13.2.1) - regexp_parser (2.9.2) + regexp_parser (2.10.0) rest-client (2.1.0) http-accept (>= 1.7.0, < 2.0) http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - rexml (3.3.9) + rexml (3.4.0) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -74,45 +72,40 @@ GEM rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.1) - rubocop (1.68.0) + rspec-support (3.13.2) + rubocop (1.70.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.33.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.37.0) parser (>= 3.3.1.0) - rubocop-rspec (3.2.0) + rubocop-rspec (3.4.0) rubocop (~> 1.61) ruby-progressbar (1.13.0) - rubyzip (2.3.2) - selenium-webdriver (4.25.0) - base64 (~> 0.2) - logger (~> 1.4) - rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 3.0) - websocket (~> 1.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) - unicode-display_width (2.6.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) webmock (3.24.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket (1.2.11) PLATFORMS arm64-darwin-22 arm64-darwin-23 + arm64-darwin-24 x86_64-darwin-20 x86_64-linux @@ -127,4 +120,4 @@ DEPENDENCIES webmock (~> 3.14) BUNDLED WITH - 2.5.17 + 2.6.3 diff --git a/README.md b/README.md index 929a812..25b4fec 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # atrea_control Ventilation systems by https://www.atrea.eu are build with web UI portal - but this portal did not provide any API interface... -This gem provide simple DSL by parsing content of https://control.atrea.eu with selenium webdriver. +This gem provide way how to connect to portal and obtain data from unit as API. ## Highlights @@ -19,25 +19,38 @@ This gem provide simple DSL by parsing content of https://control.atrea.eu with Add this line to your application's Gemfile: ```ruby -gem 'atrea_control' +gem "atrea_control" ``` And then execute: +```bash +bundle install +``` - $ bundle install +Or +```bash +bundle add atrea_control +``` Or install it yourself as: - $ gem install atrea_control +```bash +gem install atrea_control +``` ## Usage At the begin you need obtain `user_id`, `unit_id` and `sid` (auth token). For this use "Login" + +* `user_id` is atrea internal ID of user account +* `unit_id` is atrea `ident` - identification number of airflow unit (ventilation system) - 10 digits +* `sid` is session ID - auth token, valid for logged session. Its validity is unknown + ```ruby tokens = AtreaControl::Duplex::Login.user_tokens login: "myhome", password: "sup3r-S3CR3T-kocicka" tokens # => { user_id: "1234", unit_id: "85425324672", sid: 4012 } ``` -I recommend to store then somewhere... +I recommend to store then somewhere... Then you can call Unit for data... Example usage: @@ -46,6 +59,7 @@ control = AtreaControl::Duplex::Unit.new user_id: "1234", unit_id: "85425324672" control.values # => { current_power: 88.0, current_mode: "CO2" } control.power # => 88.0 ``` + ### Dig deeper `AtreaControl::Duplex::Unit` expect optional argument `user_ctrl` which should be object respond to @@ -56,36 +70,12 @@ control.power # => 88.0 __Please check [lib/atrea_control/duplex/user_ctrl.rb](./lib/atrea_control/duplex/user_ctrl.rb) for more details !__ -## Development / TODO -Login is currently done by selenium - fill login form. -I found that Atre submit form to BE, generate some "empty" HTML and JS which onLoad start doing request to queue for "login". - -Re-login user, add login procedure into queue: -```bash -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" -``` -Response is time in seconds when login will ready: -```xml - -``` -Based it su shown countdown ... - - -Request for current queue status -```bash -curl 'https://control.atrea.eu/apps/rd5Control/handle.php?Sync=1&action=unitQuery&query=loged&user=XXXX&unit=NNNNNNN' -``` -if queue is processed: -```xml - -``` -else -```xml - -``` +This object is used to translate internal unit modes to user-friendly texts & translations. -Goal is to obtain "SID". +I strong recommend to use `AtreaControl::Duplex::UserCtrl` object from "cache", +for optimize network traffic - because they are static data. +## Development 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. To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). diff --git a/atrea_control.gemspec b/atrea_control.gemspec index 36d2d54..3b8f2c9 100644 --- a/atrea_control.gemspec +++ b/atrea_control.gemspec @@ -39,5 +39,4 @@ Gem::Specification.new do |spec| spec.add_dependency "i18n", "~> 1.14" spec.add_dependency "nokogiri", "~> 1.15" spec.add_dependency "rest-client", "~> 2.1" - spec.add_dependency "selenium-webdriver", "~> 4.25.0" end diff --git a/bin/console b/bin/console index 1f9e740..0b56097 100755 --- a/bin/console +++ b/bin/console @@ -8,8 +8,8 @@ require "atrea_control" # with your gem easier. You can also use a different console, if you like. # (If you use this, don't forget to add pry to your Gemfile!) -# require "pry" -# Pry.start +require "pry" +Pry.start -require "irb" -IRB.start(__FILE__) +# require "irb" +# IRB.start(__FILE__) diff --git a/lib/atrea_control.rb b/lib/atrea_control.rb index dce971b..76d2e24 100644 --- a/lib/atrea_control.rb +++ b/lib/atrea_control.rb @@ -7,6 +7,12 @@ module AtreaControl class Error < StandardError; end + class SessionExpired < Error + def message + "Session expired. Please perform login again." + end + end + autoload :Duplex, "atrea_control/duplex" autoload :Logger, "atrea_control/logger" autoload :SensorParser, "atrea_control/sensor_parser" diff --git a/lib/atrea_control/duplex.rb b/lib/atrea_control/duplex.rb index 21b8838..135efca 100644 --- a/lib/atrea_control/duplex.rb +++ b/lib/atrea_control/duplex.rb @@ -4,7 +4,7 @@ module AtreaControl # Controller for +control.atrea.eu+ module Duplex CONTROL_URI = "https://control.atrea.eu/" - CONTROL_VERSION = "003001009" + CONTROL_VERSION = "003001022" autoload :Login, "atrea_control/duplex/login" autoload :Request, "atrea_control/duplex/request" diff --git a/lib/atrea_control/duplex/login.rb b/lib/atrea_control/duplex/login.rb index 188a86d..331fca6 100644 --- a/lib/atrea_control/duplex/login.rb +++ b/lib/atrea_control/duplex/login.rb @@ -1,23 +1,19 @@ # frozen_string_literal: true -require "digest" +require "nokogiri" require "rest-client" -require "selenium-webdriver" +require "securerandom" module AtreaControl module Duplex - # Process login into RD5 with selenium to get `sid` ( auth_token ) for direct API communication + # Process login into RD5 to get `sid` ( auth_token ) for direct API communication class Login include AtreaControl::Logger # @return [Hash] - user_id, unit_id, sid def self.user_tokens(login:, password:) - i = new(login: login, password: password) - tokens = i.user - - tokens - ensure - i.close + instance = new(login: login, password: password) + instance.call end # @param [String] login @@ -26,101 +22,107 @@ def initialize(login:, password:) @login = login @password = password end - # - # def crypto_password - # md5 = Digest::MD5.new - # md5 << "\r\n" - # md5 << @password - # md5.hexdigest - # end - # - # def token - # RestClient.get "#{AtreaControl::Duplex::CONTROL_URI}/config/login.cgi", params: { magic: crypto_password } - # end - def user - raise AtreaControl::Error, "Must be logged in" unless login + # Perform login procedure for retrieve `sid` (auth_token) + # @return [Hash] - user_id, unit_id, sid + # @raise [AtreaControl::Error] if login failed + def call + @sid = sid + if @sid == "0" + re_login = RestClient.post "#{AtreaControl::Duplex::CONTROL_URI}/apps/rd5Control/handle.php?action=unitLogin&user=#{user_id}&unit=#{unit_id}&table=userUnits&idPwd=#{unit[:iid]}&#{SecureRandom.hex(2)}&_ts=#{SecureRandom.hex(4)}", + { comm: "config/login.cgi?magic=" }, headers + time = Nokogiri::XML(re_login.body).at_xpath("//sended")["time"].to_i + logger.debug "Login in #{time} seconds..." + time.times do + @sid = sid + break if @sid != "0" + + sleep 1 + end + raise AtreaControl::Error, "Login failed" if @sid == "0" + + logger.debug "Login complete !" + else + logger.debug "Login is not necessary ! SID: #{@sid}" + end + { user_id:, unit_id:, sid: @sid } + end - logger.debug "refresh user data based on session" - @user_id = driver.execute_script("return window._user") - @unit_id = driver.execute_script("return window._unit") - @auth_token = driver.execute_script("return window.user")&.[]("auth") # sid + private - { user_id: @user_id, unit_id: @unit_id, sid: @auth_token } - end + # @!group Login steps, order is important - # @return [Selenium::WebDriver::Firefox::Driver] - def driver - return @driver if defined?(@driver) + # Retrieve user details from RD5 core.php?action=init + # @return [Hash] - user_id, name + def user + core_init = RestClient.get "#{AtreaControl::Duplex::CONTROL_URI}/core/core.php?action=init&_ts=#{SecureRandom.hex(4)}", + headers + client = Nokogiri::XML(core_init.body).at_xpath("//client") + user_id = client["id"] + name = client["name"] + logger.debug "User ID: #{user_id}, User Name: #{name}" + + { user_id:, name: } + end - # options = Selenium::WebDriver::Firefox::Options.new - # options.headless! unless ENV["NO_HEADLESS"] - # @driver ||= Selenium::WebDriver.for :firefox, capabilities: [options] - options = Selenium::WebDriver::Firefox::Options.new - options.add_argument "-headless" unless ENV["NO_HEADLESS"] - @driver ||= Selenium::WebDriver::Firefox::Driver.new options: options + def user_id + @user_id ||= user[:user_id] end - # Login into control - def login - return driver if @logged - - @login_in_progress = true - logger.debug "start new login..." - driver.get "#{AtreaControl::Duplex::CONTROL_URI}?action=logout" - submit_login_form - finish_login - driver - ensure - @login_in_progress = false + # For some reason, this requests must be done before `unit_id` requested + def run_rd5_app + RestClient.post "#{AtreaControl::Duplex::CONTROL_URI}/core/core.php?Sync=1&action=run&object=app&lng=28&rVer=1&_ts=#{SecureRandom.hex(4)}", + { name: "rd5Control", path: "apps/rd5Control/" }, headers + RestClient.post "#{AtreaControl::Duplex::CONTROL_URI}/core/core.php?Sync=1&action=load&object=setting&_ts=#{SecureRandom.hex(4)}", + { path: "apps/rd5Control" }, headers end - # Submit given credentials and proceed login - def submit_login_form - form = driver.find_element(id: "loginFrm") - username = form.find_element(name: "username") - username.send_keys @login - password = form.find_element(name: "password") - password.send_keys @password - logger.debug "Submit login form..." - - submit = form.find_element(css: "input[type=submit]") - submit.click + # Retrieve overview of RD5 unit + # @return [Hash] - unit_number (digit code/ID from list) and iid (unit salt?) + def unit + return @unit if @unit + + # run_rd5_app + units_table = RestClient.get "#{AtreaControl::Duplex::CONTROL_URI}/_data/data.php?Sync=1&action=getdata&rH&rE&table=userUnits&ds=rd5&_ts=#{SecureRandom.hex(4)}", + headers + item = Nokogiri::XML(units_table.body).at_xpath("//i") + unit_number = item["unit"] + iid = item["id"] + @unit ||= { unit_number:, iid: } end - # Retrieve dashboard URI from object tag and open it again - def open_dashboard - uri = driver.find_element(tag_name: "object").attribute "data" - # Open "iframe" with atrea dashboard - it propagate window objects... - driver.get uri - logger.debug "login success" - @logged = true + # With `unit_number` from `unit` method, get `unit_id` from RD5 unit records + # @return [String] - unit_id + def unit_id + return @unit_id if @unit_id + + records = RestClient.get "#{AtreaControl::Duplex::CONTROL_URI}/_data/data.php?Sync=1&action=getrecord&id=#{unit[:unit_number]}&table=units&ds=rd5&_ts=#{SecureRandom.hex(4)}", + headers + @unit_id ||= Nokogiri::XML(records.body).at_xpath("//table/i")["ident"] end - # quit selenium browser - def close - begin - driver.quit - rescue StandardError - nil - end - logger.debug "driver closed & destroyed" - ensure - remove_instance_variable :@driver + def sid + data = RestClient.get "#{AtreaControl::Duplex::CONTROL_URI}/apps/rd5Control/handle.php?Sync=1&action=unitQuery&query=loged&user=#{user_id}&unit=#{unit_id}&#{SecureRandom.hex(2)}&_ts=#{SecureRandom.hex(4)}", + headers + logger.debug data.body + Nokogiri::XML(data.body).at_xpath("//login")["sid"] end - private + # @!group Private methods - def finish_login - 30.times do |i| - return true if open_dashboard - rescue Selenium::WebDriver::Error::NoSuchElementError => e - logger.debug e.message - logger.debug "#{i + 1}/30 attempt for login..." - sleep 10 + # @return [String] session ID from PHP BE + def php_session_id + return @php_session_id if @php_session_id + + payload = { username: @login, password: @password } + RestClient.post "#{AtreaControl::Duplex::CONTROL_URI}?action=login", payload do |response| + @php_session_id = response.cookies["PHPSESSID"] end - File.write("/tmp/failed_login-#{@login}.html", driver.page_source) - raise AtreaControl::Error, "unable to login" + @php_session_id + end + + def headers + { cookies: { PHPSESSID: php_session_id }, "App-name": "rd5Control" } end end end diff --git a/lib/atrea_control/duplex/request.rb b/lib/atrea_control/duplex/request.rb index 8b73553..d9f50b2 100644 --- a/lib/atrea_control/duplex/request.rb +++ b/lib/atrea_control/duplex/request.rb @@ -29,3 +29,4 @@ def call(params) end end end +# https://control.atrea.eu/comm/sw/unit.php?ver=003001022&_user=2113&_unit=126399332270040&auth=49852&_t=config/xml.xml&_X=Ti&_async=1 diff --git a/lib/atrea_control/duplex/unit.rb b/lib/atrea_control/duplex/unit.rb index 1be8da5..0970b22 100644 --- a/lib/atrea_control/duplex/unit.rb +++ b/lib/atrea_control/duplex/unit.rb @@ -9,8 +9,6 @@ class Unit attr_reader :current_mode, :current_power, :outdoor_temperature, :preheat_temperature, :input_temperature # @return [Boolean] preheating air is ON ? attr_reader :preheating - # @return [DateTime] store time of last update - attr_reader :valid_for # @return [UserCtrl] attr_reader :user_ctrl # @return [Time, DateTime] @@ -69,6 +67,8 @@ def values parsed.each do |name, value| instance_variable_set :"@#{name}", value end + raise AtreaControl::SessionExpired unless valid? + as_json end @@ -80,7 +80,6 @@ def as_json(_options = nil) preheat_temperature: preheat_temperature, input_temperature: input_temperature, preheating: preheating, - valid_for: valid_for, timestamp: timestamp, } end @@ -89,15 +88,29 @@ def to_json(*) values.to_json(*) end - # Additional "parameters" for each sensors - # @note its changed in time ? + # Expire cached data and fetch them again + # @return [Hash] new values + def refresh! + remove_instance_variable(:@parsed) if defined?(@parsed) + values + end + + # Data are valid if timestamp within 15.minutes + def valid? + return false unless timestamp + + timestamp > (Time.now - (15 * 60.0)) + end + + private + + # Additional "parameters" for each sensor + # @note it's changed in time ? def params response = request.call(_t: "user/params.xml") Nokogiri::XML response.body end - private - def parser @parser ||= ::AtreaControl::SensorParser.new(@user_ctrl) end diff --git a/lib/atrea_control/duplex/user_ctrl.rb b/lib/atrea_control/duplex/user_ctrl.rb index 1470035..f393e22 100644 --- a/lib/atrea_control/duplex/user_ctrl.rb +++ b/lib/atrea_control/duplex/user_ctrl.rb @@ -33,7 +33,7 @@ def request # Get and parse XML with user/unit configuration source def user_ctrl - response = request.call(_t: "lang/userCtrl.xml") + response = request.call(_t: "lang/userCtrl.xml", _async: 1) Nokogiri::XML response.body end diff --git a/lib/atrea_control/sensor_parser.rb b/lib/atrea_control/sensor_parser.rb index 1259fc8..c6ddaf9 100644 --- a/lib/atrea_control/sensor_parser.rb +++ b/lib/atrea_control/sensor_parser.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "nokogiri" +require "time" module AtreaControl # Call RD5 unit ang get current sensors values @@ -43,6 +44,7 @@ def parse(xml) preheating = %w[C10200 C10202 C10215 C10217].any? do |i| xml.xpath("//O[@I=\"#{i}\"]/@V").last&.value == "1" end + # @note timestamp seems to be localtime of creation of xml parsed["timestamp"] = xml.xpath("//RD5WEB").attribute("t").to_s parsed["preheating"] = preheating parsed @@ -58,8 +60,7 @@ def format_data(values) "preheat_temperature" => values["preheat_temperature"].to_f / 10.0, "input_temperature" => values["input_temperature"].to_f / 10.0, "preheating" => values["preheating"], - "timestamp" => DateTime.parse(values["timestamp"]), - "valid_for" => Time.now, + "timestamp" => Time.strptime(values["timestamp"], "%Y-%m-%d %H:%M:%S"), } end diff --git a/lib/atrea_control/version.rb b/lib/atrea_control/version.rb index 168e411..74ab472 100644 --- a/lib/atrea_control/version.rb +++ b/lib/atrea_control/version.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# :nocov: module AtreaControl - VERSION = "2.3.0" + VERSION = "3.0.0" end diff --git a/spec/atrea_control/duplex/login_spec.rb b/spec/atrea_control/duplex/login_spec.rb index 7dc0158..d742f8a 100644 --- a/spec/atrea_control/duplex/login_spec.rb +++ b/spec/atrea_control/duplex/login_spec.rb @@ -1,64 +1,54 @@ # frozen_string_literal: true -FIXTURE_LOGIN_PATH = File.join(__dir__, "../../fixtures/files/login.html") -AtreaControl::Duplex::CONTROL_URI = "file://#{FIXTURE_LOGIN_PATH}".freeze +FIXTURE_CORE_INIT_PATH = File.join(__dir__, "../../fixtures/files/core.php-init.xml") +FIXTURE_UNITS_TABLE_PATH = File.join(__dir__, "../../fixtures/files/data.php-getdata.xml") +FIXTURE_UNIT_RECORD_PATH = File.join(__dir__, "../../fixtures/files/data.php-getrecord.xml") +FIXTURE_UNIT_QUERY_PATH_0 = File.join(__dir__, "../../fixtures/files/handle.php-unitQuery_0.xml") +FIXTURE_UNIT_QUERY_PATH = File.join(__dir__, "../../fixtures/files/handle.php-unitQuery_009.xml") RSpec.describe AtreaControl::Duplex::Login do subject(:duplex) { described_class.new login: "myhome", password: "secret" } before do - WebMock.disable_net_connect!(allow_localhost: true) - end - - after do - duplex.close - end + stub_request(:post, /\?action=login/) + .to_return(headers: { "Set-Cookie" => "PHPSESSID=12345" }) - describe "#login" do - it "fill form, wait until unit response" do - expect(duplex).to receive(:open_dashboard).and_raise Selenium::WebDriver::Error::NoSuchElementError - expect(duplex).to receive(:sleep) - expect(duplex).to receive(:open_dashboard).and_return true - duplex.login - end - end + stub_request(:get, %r{.*core/core.php\?.*action=init}) + .with(headers: { "Cookie" => "PHPSESSID=12345", "App-name" => "rd5Control" }) + .to_return(body: File.read(FIXTURE_CORE_INIT_PATH)) - describe "#open_dashboard" do - it "grab object data" do - fixture = File.join(__dir__, "../../fixtures/files/texts.xml") - stub_request(:get, - %r{https://control.atrea.eu/comm/sw/unit.php}).to_return(body: File.read(fixture)) - duplex.driver.get "file://#{File.join(__dir__, "../../fixtures/files/logged.html")}" - expect { duplex.open_dashboard }.not_to raise_exception - end - end + stub_request(:post, %r{/core/core.php\?Sync=1&action=run&object=app&lng=28&rVer=1}) + .to_return(status: 200) - describe "#user" do - subject(:user) { duplex.user } + stub_request(:post, %r{/core/core.php\?Sync=1&action=load&object=setting}) + .to_return(status: 200) - let(:driver) { nil } + stub_request(:get, %r{/_data/data.php\?.*action=getdata.*&ds=rd5}) + .to_return(body: File.read(FIXTURE_UNITS_TABLE_PATH)) - before do - duplex.driver.get "file://#{File.join(__dir__, "../../fixtures/files/dashboard.html")}" - allow(duplex).to receive(:login).and_return driver - end + stub_request(:get, %r{/_data/data.php\?.*action=getrecord}) + .to_return(body: File.read(FIXTURE_UNIT_RECORD_PATH)) - context "without login" do - it { expect { user }.to raise_error AtreaControl::Error } - end + stub_request(:get, %r{/apps/rd5Control/handle.php\?.*action=unitQuery}) + .to_return(body: File.read(FIXTURE_UNIT_QUERY_PATH_0)) + end - context "with login return credentials" do - let(:driver) { double } + describe ".user_tokens" do + it "returns user tokens" do + stub_request(:get, %r{/apps/rd5Control/handle.php\?.*action=unitQuery}) + .to_return(body: File.read(FIXTURE_UNIT_QUERY_PATH)) - it { expect(user).to include(user_id: match(/\d+/), unit_id: match(/\d+/), sid: be_a(Integer)) } + tokens = described_class.user_tokens(login: "myhome", password: "secret") + expect(tokens).to include(:user_id, :unit_id, :sid) end end - it ".user_tokens" do - dbl = spy("Login") - allow(described_class).to receive(:new).and_return dbl - described_class.user_tokens(login: "myhome", password: "secret") - expect(dbl).to have_received(:user).exactly 1 - expect(dbl).to have_received(:close).at_least 1 + describe "#call" do + it "raise error when login failed" do + stub_request(:post, %r{/apps/rd5Control/handle.php.*action=unitLogin}) + .to_return(status: 200, body: "") + + expect { duplex.call }.to raise_error(AtreaControl::Error) + end end end diff --git a/spec/atrea_control/duplex/unit_spec.rb b/spec/atrea_control/duplex/unit_spec.rb index e560f07..5938e3a 100644 --- a/spec/atrea_control/duplex/unit_spec.rb +++ b/spec/atrea_control/duplex/unit_spec.rb @@ -14,6 +14,7 @@ before do allow(unit).to receive(:request).and_return(request) + allow(unit).to receive(:valid?).and_return(true) end describe "#name" do @@ -54,8 +55,8 @@ describe "#timestamp" do subject(:timestamp) { unit.timestamp } - it { is_expected.to be_a DateTime } - it { is_expected.to eq DateTime.parse("2021-10-26 21:45:51") } + it { is_expected.to be_a Time } + it { is_expected.to eq Time.parse("2021-10-26 21:45:51") } end end @@ -77,4 +78,51 @@ unit.mode = 1 end end + + describe "#refresh!" do + let(:parser) { instance_double(AtreaControl::SensorParser) } + + before do + allow(unit).to receive(:read).and_return spy(body: "") + allow(unit).to receive(:parser).and_return parser + allow(parser).to receive(:values).and_return({ timestamp: 1 }, { timestamp: 2 }) + end + + # rubocop:disable RSpec/ExampleLength + it "remove cached values" do + aggregate_failures "demonstrate cache" do + expect(unit.values).to include timestamp: 1 + expect(unit.values).to include timestamp: 1 + end + expect(unit.refresh!).to include timestamp: 2 + expect(unit.values).to include timestamp: 2 + end + # rubocop:enable RSpec/ExampleLength + end + + describe "#valid?" do + subject(:valid?) { unit.valid? } + + before do + allow(unit).to receive(:valid?).and_call_original + end + + context "when timestamp is nil" do + before { allow(unit).to receive(:timestamp).and_return nil } + + it { is_expected.to be false } + end + + context "when timestamp is older than 15 minutes" do + before { allow(unit).to receive(:timestamp).and_return Time.now - (20 * 60.0) } + + it { is_expected.to be false } + end + + context "when timestamp is newer than 15 minutes" do + before { allow(unit).to receive(:timestamp).and_return Time.now - (10 * 60.0) } + + it { is_expected.to be true } + end + end end diff --git a/spec/atrea_control/duplex/user_ctrl_spec.rb b/spec/atrea_control/duplex/user_ctrl_spec.rb index f967803..38fb997 100644 --- a/spec/atrea_control/duplex/user_ctrl_spec.rb +++ b/spec/atrea_control/duplex/user_ctrl_spec.rb @@ -12,8 +12,8 @@ before do request = instance_double(AtreaControl::Duplex::Request) allow(user_ctrl).to receive(:request).and_return(request) - allow(request).to receive(:call).with(_t: "lang/userCtrl.xml").and_return spy(body: File.read(user_ctrl_fixture)) - allow(request).to receive(:call).with(_t: "config/texts.xml").and_return spy(body: File.read(user_texts_fixture)) + allow(request).to receive(:call).with(include(_t: "lang/userCtrl.xml")).and_return spy(body: File.read(user_ctrl_fixture)) + allow(request).to receive(:call).with(include(_t: "config/texts.xml")).and_return spy(body: File.read(user_texts_fixture)) end it "#name" do diff --git a/spec/atrea_control/duplex_spec.rb b/spec/atrea_control/duplex_spec.rb deleted file mode 100644 index 6eecc20..0000000 --- a/spec/atrea_control/duplex_spec.rb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -AtreaControl::Duplex::CONTROL_URI = "file://#{File.join(__dir__, "../fixtures/files/login.html")}".freeze diff --git a/spec/fixtures/files/core.php-init.xml b/spec/fixtures/files/core.php-init.xml new file mode 100644 index 0000000..adcd667 --- /dev/null +++ b/spec/fixtures/files/core.php-init.xml @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/spec/fixtures/files/data.php-getdata.xml b/spec/fixtures/files/data.php-getdata.xml new file mode 100644 index 0000000..a1d1db8 --- /dev/null +++ b/spec/fixtures/files/data.php-getdata.xml @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/spec/fixtures/files/data.php-getrecord.xml b/spec/fixtures/files/data.php-getrecord.xml new file mode 100644 index 0000000..078ba48 --- /dev/null +++ b/spec/fixtures/files/data.php-getrecord.xml @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/spec/fixtures/files/handle.php-unitQuery_0.xml b/spec/fixtures/files/handle.php-unitQuery_0.xml new file mode 100644 index 0000000..4b4c374 --- /dev/null +++ b/spec/fixtures/files/handle.php-unitQuery_0.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spec/fixtures/files/handle.php-unitQuery_009.xml b/spec/fixtures/files/handle.php-unitQuery_009.xml new file mode 100644 index 0000000..63d8af4 --- /dev/null +++ b/spec/fixtures/files/handle.php-unitQuery_009.xml @@ -0,0 +1 @@ + \ No newline at end of file