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.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +12 -1
- data/.rubocop_todo.yml +7 -1
- data/.ruby-version +1 -1
- data/AGENTS.md +51 -0
- data/CHANGELOG.md +63 -1
- data/Gemfile +4 -4
- data/Gemfile.lock +49 -47
- data/README.md +215 -62
- data/atrea_control.gemspec +5 -4
- data/docs/rd5-cloud-protocol.md +253 -0
- data/docs/superpowers/plans/2026-10-24-rd5-reliability.md +238 -0
- data/lib/atrea_control/duplex/login.rb +94 -109
- data/lib/atrea_control/duplex/login_discovery.rb +82 -0
- data/lib/atrea_control/duplex/login_session.rb +53 -0
- data/lib/atrea_control/duplex/rd5_command.rb +72 -0
- data/lib/atrea_control/duplex/rd5_configuration.rb +104 -0
- data/lib/atrea_control/duplex/request.rb +34 -6
- data/lib/atrea_control/duplex/unit.rb +58 -52
- data/lib/atrea_control/duplex/unit_commands.rb +59 -0
- data/lib/atrea_control/duplex/user_ctrl.rb +75 -64
- data/lib/atrea_control/duplex/write_verification.rb +75 -0
- data/lib/atrea_control/duplex.rb +2 -0
- data/lib/atrea_control/logger.rb +3 -1
- data/lib/atrea_control/sensor_parser.rb +94 -30
- data/lib/atrea_control/version.rb +2 -1
- data/lib/atrea_control.rb +7 -0
- metadata +32 -9
|
@@ -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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
39
|
+
current_mode
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
def power
|
|
37
|
-
current_power
|
|
43
|
+
current_power
|
|
38
44
|
end
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
55
|
+
preheating
|
|
46
56
|
end
|
|
47
57
|
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
@parsed ||= parser.values(read.body)
|
|
64
|
+
@snapshot
|
|
64
65
|
end
|
|
65
66
|
|
|
66
67
|
def values
|
|
67
|
-
parsed.
|
|
68
|
-
|
|
69
|
-
end
|
|
70
|
-
raise AtreaControl::SessionExpired unless valid?
|
|
68
|
+
parsed.transform_keys(&:to_sym)
|
|
69
|
+
end
|
|
71
70
|
|
|
72
|
-
|
|
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
|
-
|
|
87
|
+
@snapshot_error = nil
|
|
88
|
+
load_snapshot
|
|
95
89
|
values
|
|
96
90
|
end
|
|
97
91
|
|
|
98
|
-
#
|
|
99
|
-
def valid?
|
|
100
|
-
|
|
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
|
-
|
|
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
|