atrea_control 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "login_discovery"
4
+
5
+ module AtreaControl
6
+ module Duplex
7
+ # Discover account tokens, then poll until a usable unit session or deadline.
8
+ class LoginSession
9
+ def initialize(unit_id:, request:, wait:, diagnostic:)
10
+ @request = request
11
+ @wait = wait
12
+ @diagnostic = diagnostic
13
+ @discovery = LoginDiscovery.new(request: request, unit_id: unit_id)
14
+ end
15
+
16
+ def call
17
+ @user_id = @discovery.user_id
18
+ @unit = @discovery.unit
19
+ sid = query
20
+ if sid == "0"
21
+ connect
22
+ sid = poll
23
+ @diagnostic.call("login_complete")
24
+ else
25
+ @diagnostic.call("session_reused")
26
+ end
27
+ { user_id: @user_id, unit_id: @unit[:ident], sid: sid }
28
+ end
29
+
30
+ private
31
+
32
+ def query
33
+ body = @request.call("session_query", :get, "/apps/rd5Control/handle.php", { Sync: 1, action: "unitQuery", query: "loged", user: @user_id, unit: @unit[:ident] })
34
+ LoginXML.value(LoginXML.node(body, "/root/login")["sid"], "session SID")
35
+ end
36
+
37
+ def connect
38
+ params = { Sync: 1, action: "unitLogin", user: @user_id, unit: @unit[:ident], table: "userUnits", idPwd: @unit[:iid] }
39
+ body = @request.call("unit_login", :post, "/apps/rd5Control/handle.php", params, { comm: "config/login.cgi?magic=" })
40
+ LoginXML.node(body, "/root/sended")
41
+ end
42
+
43
+ def poll
44
+ loop do
45
+ sid = query
46
+ return sid unless sid == "0"
47
+
48
+ @wait.call
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtreaControl
4
+ module Duplex
5
+ # Build one coherent command from discovered mappings and one fresh configuration.
6
+ class RD5Command
7
+ COMMAND_PROFILE = /
8
+ send2Unit \s* \( \s* getUrlPar \s* \( \s* '(?<input>H[0-9]{5})' \s* , \s* val \s* \)
9
+ \s* \+ \s* \( \s* values \s* \. \s* (?<switch>H[0-9]{5}) \s* == \s* 1 \s* \?
10
+ \s* '&' \s* \+ \s* getUrlPar \s* \( \s* '\k<switch>' \s* , \s* 2 \s* \)
11
+ \s* : \s* '' \s* \) \s* \) \s* ; \s*
12
+ /x
13
+ POWER_UI_TAIL = /
14
+ (?: if \s* \( \s* val \s* > \s* 0 \s* && \s* values \s* \. \s* H10715 \s* == \s* 0 \s* \) \s* \{
15
+ \s* flyDown \s* = \s* false \s* ; \s* this \s* \. \s* nextSibling \s* \. \s* nextSibling
16
+ \s* \. \s* onmousedown \s* \( \s* \) \s* ; \s* \} \s*
17
+ )?
18
+ /x
19
+
20
+ def self.normalize(value)
21
+ integer = case value
22
+ when Integer then value
23
+ when Float then integral_float(value)
24
+ when String then Integer(value, 10) if value.match?(/\A[0-9]+\z/)
25
+ end
26
+ raise ArgumentError, "Expected nonnegative integral command value" unless integer && integer >= 0
27
+
28
+ integer
29
+ end
30
+
31
+ def self.integral_float(value)
32
+ value.to_i if value.finite? && value == value.to_i
33
+ end
34
+ private_class_method :integral_float
35
+
36
+ def self.encode(sensor, value)
37
+ raise AtreaControl::UnsupportedConfiguration, "Invalid holding-register mapping" unless sensor.is_a?(String) && sensor.match?(/\AH[0-9]{5}\z/)
38
+ raise ArgumentError, "Expected unsigned 16-bit integer" unless value.is_a?(Integer) && (0..65_535).cover?(value)
39
+
40
+ sensor + format("%05d", value)
41
+ end
42
+
43
+ # Only supported, complete onchange forms; never execute or extract arbitrary JS.
44
+ def self.discover(source, power: false)
45
+ # Whitespace is allowed between tokens, never inside identifiers or quoted IDs.
46
+ tail = power ? POWER_UI_TAIL : //
47
+ match = source.match(/\A\s*function\s*\(\s*val\s*\)\s*\{\s*saved\s*=\s*true\s*;\s*#{COMMAND_PROFILE}#{tail}\}\s*\z/)
48
+ raise AtreaControl::UnsupportedConfiguration, "Unsupported command profile" unless match
49
+
50
+ { input: match[:input], switch: match[:switch] }
51
+ end
52
+
53
+ def initialize(configuration, user_ctrl, parser)
54
+ @configuration = configuration
55
+ @user_ctrl = user_ctrl
56
+ @parser = parser
57
+ end
58
+
59
+ def build(setting, value)
60
+ case setting
61
+ when :power then @configuration.validate_power!(value)
62
+ when :mode then @configuration.validate_mode!(value, @user_ctrl.modes)
63
+ else raise ArgumentError, "Unknown command setting"
64
+ end
65
+ input = @parser.input(@user_ctrl.sensors["#{setting}_input"], value)
66
+ switch = @user_ctrl.sensors["#{setting}_switch"]
67
+ override = @parser.input(switch, 2)
68
+ @configuration.temporary_control?(switch) ? [input, override] : [input]
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtreaControl
4
+ module Duplex
5
+ # Immutable policy over validated, unsigned registers from one RD5 snapshot.
6
+ class RD5Configuration
7
+ REGISTERS = %w[H10510 H10501 H11300 H11700 C10508 C10509 I11401 H10700 H10701].freeze
8
+ POWER_DOMAINS = { 0 => [0, *(12..100)].freeze, 1 => (0..100), 2 => (0..2),
9
+ 4 => [0, *(10..12), *(20..22), *(30..38)].freeze, }.freeze
10
+ MODE_REQUIREMENTS = {
11
+ 0 => {}, 2 => {}, 1 => { "C10508" => 0 }, 6 => { "C10508" => 0 }, 7 => { "C10508" => 0 },
12
+ 3 => { "C10508" => 0, "C10509" => 1 }, 4 => { "C10508" => 0, "H11700" => 1 }, 5 => { "I11401" => 1 },
13
+ }.transform_values(&:freeze).freeze
14
+
15
+ # Recognize known logic as data, never execute configuration JavaScript.
16
+ INLET_SOURCE_RULE = <<~JS.gsub(/\s+/, "").freeze
17
+ function() {
18
+ var val, na=false;
19
+ if(values.H11300==0){
20
+ if(values.H10501==1 && values.H10200>0 && values.I10200!=126.0)
21
+ val=values.I10200;
22
+ else if(values.H10501==2 && values.H10201>0 && values.I10201!=126.0)
23
+ val=values.I10201;
24
+ else
25
+ na=true;
26
+ }else if(values.H11300==1){
27
+ val=values.I10204;
28
+ }else na=true;
29
+ if(na) return words['unavail'];
30
+ return val;
31
+ }
32
+ JS
33
+
34
+ def self.input_temperature_source(source)
35
+ source.gsub(/\s+/, "") == INLET_SOURCE_RULE ? "rd5_standard" : "unsupported"
36
+ end
37
+
38
+ def initialize(registers)
39
+ @registers = registers.dup.freeze
40
+ @configuration = @registers.slice(*REGISTERS).freeze
41
+ freeze
42
+ end
43
+
44
+ def [](id)
45
+ @configuration[id]
46
+ end
47
+
48
+ # Command policy must explicitly request its prerequisites.
49
+ def fetch(id)
50
+ @configuration.fetch(id) { raise AtreaControl::UnsupportedConfiguration, "Missing configuration register #{id}" }
51
+ end
52
+
53
+ def to_h
54
+ @configuration
55
+ end
56
+
57
+ def validate_power!(value)
58
+ domain = POWER_DOMAINS.fetch(fetch("H10510")) { raise AtreaControl::UnsupportedConfiguration, "Unsupported power profile" }
59
+ raise ArgumentError, "Power outside configured range" unless value.is_a?(Integer) && domain.include?(value)
60
+
61
+ value
62
+ end
63
+
64
+ def validate_mode!(value, modes)
65
+ raise ArgumentError, "Mode outside 0..7" unless value.is_a?(Integer) && (0..7).cover?(value)
66
+ raise AtreaControl::UnsupportedConfiguration, "Undiscovered mode" unless modes.key?(value.to_s)
67
+
68
+ MODE_REQUIREMENTS.fetch(value).each do |id, expected|
69
+ raise AtreaControl::UnsupportedConfiguration, "Mode unavailable: #{id}" unless fetch(id) == expected
70
+ end
71
+ value
72
+ end
73
+
74
+ # Use the discovered switch from this same validated snapshot, including custom mappings.
75
+ def temporary_control?(id)
76
+ state = @registers.fetch(id) { raise AtreaControl::UnsupportedConfiguration, "Missing control register #{id}" }
77
+ raise AtreaControl::UnsupportedConfiguration, "Unknown control state for #{id}" unless [0, 1, 2].include?(state)
78
+
79
+ state != 0
80
+ end
81
+
82
+ # Supported userCtrl inlet source rule; return unsigned temperature or nil.
83
+ def input_temperature
84
+ case self["H11300"]
85
+ when 0
86
+ case self["H10501"]
87
+ when 1 then fan_temperature("H10200", "I10200")
88
+ when 2 then fan_temperature("H10201", "I10201")
89
+ end
90
+ when 1
91
+ @registers["I10204"]
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def fan_temperature(fan, sensor)
98
+ speed = @registers[fan]
99
+ temperature = @registers[sensor]
100
+ temperature if speed&.positive? && temperature != 1260
101
+ end
102
+ end
103
+ end
104
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "faraday"
4
+ require "timeout"
4
5
 
5
6
  module AtreaControl
6
7
  module Duplex
@@ -12,7 +13,15 @@ module AtreaControl
12
13
  # @param [String, Integer] unit_id
13
14
  # @param [String, Integer] sid
14
15
  # @note `ver` is done by atrea server
15
- def initialize(user_id:, unit_id:, sid:)
16
+ def initialize(user_id:, unit_id:, sid:, timeout: 60, open_timeout: 10, read_timeout: 60) # rubocop:disable Metrics/ParameterLists
17
+ { timeout:, open_timeout:, read_timeout: }.each do |key, value|
18
+ raise ArgumentError, "#{key} must be positive and finite" unless value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
19
+ end
20
+ raise ArgumentError, "timeout must not exceed 60 seconds" if timeout > 60
21
+
22
+ @timeout = timeout
23
+ @open_timeout = open_timeout
24
+ @read_timeout = read_timeout
16
25
  @params = {
17
26
  _user: user_id.to_i,
18
27
  _unit: unit_id,
@@ -23,15 +32,34 @@ module AtreaControl
23
32
 
24
33
  # @param [Hash] params
25
34
  # @option params [String] :_t ("config/xml.xml") file name
26
- def call(params)
35
+ def call(params = nil, **keywords)
36
+ params = keywords.merge(params.to_h)
37
+ response = Timeout.timeout(@timeout, Timeout::Error) { connection.get("#{AtreaControl::Duplex::CONTROL_URI}/comm/sw/unit.php", @params.merge(params)) }
38
+ logger.debug "stage=unit_request status=#{response.status}"
39
+ raise AtreaControl::SessionExpired, cause: nil if [401, 403].include?(response.status)
40
+ raise AtreaControl::TransportError, "Unit request failed (HTTP #{response.status})", cause: nil unless (200...300).cover?(response.status)
41
+
42
+ response
43
+ rescue Faraday::TimeoutError, Timeout::Error
44
+ raise AtreaControl::UnitConnectionTimeout, "Unit request timed out", cause: nil
45
+ rescue Faraday::Error => e
46
+ raise AtreaControl::UnitConnectionTimeout, "Unit request timed out", cause: nil if e.wrapped_exception.is_a?(Timeout::Error)
47
+
48
+ raise AtreaControl::TransportError, "Unit connection failed", cause: nil
49
+ rescue IOError, SystemCallError, SocketError
50
+ raise AtreaControl::TransportError, "Unit connection failed", cause: nil
51
+ end
52
+
53
+ private
54
+
55
+ def connection
27
56
  @connection ||= Faraday.new do |f|
57
+ f.options.open_timeout = @open_timeout
58
+ f.options.timeout = @read_timeout
28
59
  f.request :url_encoded
29
- f.adapter Faraday.default_adapter
60
+ f.adapter(:net_http) { |http| http.max_retries = 0 }
30
61
  end
31
-
32
- @connection.get("#{AtreaControl::Duplex::CONTROL_URI}/comm/sw/unit.php", @params.merge(params))
33
62
  end
34
63
  end
35
64
  end
36
65
  end
37
- # https://control.atrea.eu/comm/sw/unit.php?ver=003001022&_user=2113&_unit=126399332270040&auth=49852&_t=config/xml.xml&_X=Ti&_async=1
@@ -1,27 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require_relative "unit_commands"
5
+
3
6
  module AtreaControl
4
7
  module Duplex
5
8
  # Communication with RD5 unit (read/write)
6
9
  class Unit
7
10
  include AtreaControl::Logger
11
+ include UnitCommands
12
+
13
+ CURRENT_FIELDS = %w[current_mode current_power outdoor_temperature preheat_temperature required_temperature input_temperature preheating timestamp].freeze
8
14
 
9
- attr_reader :current_mode, :current_power, :outdoor_temperature, :preheat_temperature, :input_temperature
10
- # @return [Boolean] preheating air is ON ?
11
- attr_reader :preheating
15
+ CURRENT_FIELDS.each do |field|
16
+ define_method(field) { parsed[field] }
17
+ end
12
18
  # @return [UserCtrl]
13
19
  attr_reader :user_ctrl
14
- # @return [Time, DateTime]
15
- attr_writer :timestamp
16
20
 
17
21
  # @param [String, Integer] user_id
18
22
  # @param [String, Integer] unit_id
19
23
  # @param [String, Integer] sid
20
24
  # @param [AtreaControl::Duplex::UserCtrl] user_ctrl
21
- def initialize(user_id:, unit_id:, sid:, user_ctrl: nil)
25
+ def initialize(user_id:, unit_id:, sid:, user_ctrl: nil, time_zone: nil, verification_registers: nil) # rubocop:disable Metrics/ParameterLists
22
26
  @user_id = user_id
23
27
  @unit_id = unit_id
24
28
  @sid = sid
29
+ @time_zone = time_zone
30
+ @verification_registers = WriteVerification.registers(verification_registers)
25
31
  @user_ctrl = user_ctrl || UserCtrl.new(user_id: user_id, unit_id: unit_id, sid: sid)
26
32
  end
27
33
 
@@ -30,58 +36,45 @@ module AtreaControl
30
36
  end
31
37
 
32
38
  def mode
33
- current_mode || values[:current_mode]
39
+ current_mode
34
40
  end
35
41
 
36
42
  def power
37
- current_power || values[:current_power]
43
+ current_power
38
44
  end
39
45
 
40
- def timestamp
41
- @timestamp ||= values[:timestamp]
46
+ # Preserve the writer for loaded, valid snapshots without sharing mutable Time objects.
47
+ def timestamp=(value)
48
+ candidate = parsed.merge("timestamp" => value.dup.freeze).freeze
49
+ raise AtreaControl::SessionExpired unless valid?(candidate)
50
+
51
+ @snapshot = candidate
42
52
  end
43
53
 
44
54
  def preheating?
45
- preheating || values[:preheating]
55
+ preheating
46
56
  end
47
57
 
48
- # @param [String] value 0 - power-off; 1 - automat
49
- def mode=(value)
50
- v = [parser.input(@user_ctrl.sensors["mode_input"], value.to_s)]
51
- v << parser.input(@user_ctrl.sensors["mode_switch"], "2")
52
- write(*v)
53
- end
58
+ def parsed
59
+ raise @snapshot_error if @snapshot_error
54
60
 
55
- def power=(value)
56
- v = [parser.input(@user_ctrl.sensors["power_input"], value.to_s)]
57
- # overtake schedule control
58
- v << "H1070000002" if value.to_i.positive?
59
- write(*v)
60
- end
61
+ load_snapshot unless @snapshot
62
+ raise AtreaControl::SessionExpired unless valid?
61
63
 
62
- def parsed
63
- @parsed ||= parser.values(read.body)
64
+ @snapshot
64
65
  end
65
66
 
66
67
  def values
67
- parsed.each do |name, value|
68
- instance_variable_set :"@#{name}", value
69
- end
70
- raise AtreaControl::SessionExpired unless valid?
68
+ parsed.transform_keys(&:to_sym)
69
+ end
71
70
 
72
- as_json
71
+ # Configuration belongs to the same fresh snapshot as parsed readings.
72
+ def configuration
73
+ parsed && @configuration
73
74
  end
74
75
 
75
76
  def as_json(_options = nil)
76
- {
77
- current_mode: current_mode,
78
- current_power: current_power,
79
- outdoor_temperature: outdoor_temperature,
80
- preheat_temperature: preheat_temperature,
81
- input_temperature: input_temperature,
82
- preheating: preheating,
83
- timestamp: timestamp,
84
- }
77
+ values
85
78
  end
86
79
 
87
80
  def to_json(*)
@@ -91,19 +84,39 @@ module AtreaControl
91
84
  # Expire cached data and fetch them again
92
85
  # @return [Hash] new values
93
86
  def refresh!
94
- remove_instance_variable(:@parsed) if defined?(@parsed)
87
+ @snapshot_error = nil
88
+ load_snapshot
95
89
  values
96
90
  end
97
91
 
98
- # Data are valid if timestamp within 15.minutes
99
- def valid?
100
- return false unless timestamp
92
+ # Inspect the snapshot directly: no lazy fetch and no timestamp-reader recursion.
93
+ def valid?(snapshot = @snapshot)
94
+ timestamp = snapshot && snapshot["timestamp"]
95
+ return false unless timestamp.is_a?(Time)
101
96
 
102
- timestamp > (Time.now - (15 * 60.0))
97
+ now = Time.now
98
+ timestamp > now - 900 && timestamp <= now + 60
103
99
  end
104
100
 
105
101
  private
106
102
 
103
+ # Publish only a complete, fresh snapshot. A failed attempt stays failed until refresh!.
104
+ def load_snapshot
105
+ @snapshot = nil
106
+ @configuration = nil
107
+ @registers = nil
108
+ bundle = parser.snapshot(read.body)
109
+ candidate = bundle.fetch(:values).transform_values { |value| value.dup.freeze }.freeze
110
+ raise AtreaControl::SessionExpired unless valid?(candidate)
111
+
112
+ @configuration = bundle.fetch(:configuration)
113
+ @registers = bundle[:registers]
114
+ @snapshot = candidate
115
+ rescue StandardError => e
116
+ @snapshot_error = e
117
+ raise
118
+ end
119
+
107
120
  # Additional "parameters" for each sensor
108
121
  # @note it's changed in time ?
109
122
  def params
@@ -112,7 +125,7 @@ module AtreaControl
112
125
  end
113
126
 
114
127
  def parser
115
- @parser ||= ::AtreaControl::SensorParser.new(@user_ctrl)
128
+ @parser ||= ::AtreaControl::SensorParser.new(@user_ctrl, time_zone: @time_zone)
116
129
  end
117
130
 
118
131
  # Request to RD5
@@ -123,13 +136,6 @@ module AtreaControl
123
136
  def read
124
137
  request.call(_t: "config/xml.xml")
125
138
  end
126
-
127
- # @param [Array<String>] values in format SENSOR0000VALUE
128
- def write(*values)
129
- inputs = values.to_h { |i| [i, nil] }
130
- logger.debug("set RD5 #{inputs}")
131
- request.call({ _t: "config/xml.cgi", _w: 1 }.merge(inputs))
132
- end
133
139
  end
134
140
  end
135
141
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "write_verification"
4
+
5
+ module AtreaControl
6
+ module Duplex
7
+ # RD5 command dispatch and opt-in requested-value verification for Unit.
8
+ module UnitCommands
9
+ %i[mode power].each do |setting|
10
+ define_method(:"#{setting}=") { |value| public_send(:"set_#{setting}", value, verify: false) }
11
+ define_method(:"set_#{setting}") do |value, verify: true, timeout: 30, poll_interval: 5|
12
+ normalized = RD5Command.normalize(value)
13
+ verification = WriteVerification.new(timeout: timeout, poll_interval: poll_interval)
14
+ next command(setting, normalized) unless verify
15
+
16
+ verification.run { verified_command(setting, normalized, verification) }
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def verified_command(setting, normalized, verification)
23
+ register = @verification_registers&.fetch(setting, nil)
24
+ raise AtreaControl::UnsupportedConfiguration, "No verified readback mapping for #{setting}" unless register
25
+
26
+ @snapshot_error = nil
27
+ load_snapshot
28
+ verification.remaining
29
+ baseline = @snapshot.fetch("timestamp")
30
+ raise AtreaControl::UnsupportedConfiguration, "Missing baseline readback register #{register}" unless @registers.key?(register)
31
+
32
+ commands = RD5Command.new(configuration, @user_ctrl, parser).build(setting, normalized)
33
+ verification.dispatch { write(*commands) }
34
+ verification.confirm do
35
+ load_snapshot
36
+ raw = @registers.fetch(register) { raise AtreaControl::ProtocolError, "Missing readback register #{register}" }
37
+ @snapshot.fetch("timestamp") > baseline && raw == normalized
38
+ end
39
+ normalized
40
+ end
41
+
42
+ def command(setting, normalized)
43
+ commands = RD5Command.new(configuration, @user_ctrl, parser).build(setting, normalized)
44
+ write(*commands)
45
+ end
46
+
47
+ # @param [Array<String>] values in format SENSOR0000VALUE
48
+ def write(*values)
49
+ inputs = values.to_h { |i| [i, nil] }
50
+ logger.debug("set RD5 #{inputs}")
51
+ # The device may apply a command even if its response fails or times out.
52
+ # Discard prewrite data on every attempt; the next reader must fetch again.
53
+ @snapshot = @configuration = @registers = @snapshot_error = nil
54
+ params = { _t: "config/xml.cgi", _w: 1 }.merge(inputs)
55
+ request.call(params)
56
+ end
57
+ end
58
+ end
59
+ end