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
|
@@ -5,7 +5,7 @@ require "nokogiri"
|
|
|
5
5
|
module AtreaControl
|
|
6
6
|
module Duplex
|
|
7
7
|
# Parse `userCtrl.xml`
|
|
8
|
-
class UserCtrl
|
|
8
|
+
class UserCtrl # rubocop:disable Metrics/ClassLength
|
|
9
9
|
# @param (see #initialize)
|
|
10
10
|
def self.data(user_id:, unit_id:, sid:)
|
|
11
11
|
user_ctrl = new(user_id: user_id, unit_id: unit_id, sid: sid)
|
|
@@ -14,6 +14,7 @@ module AtreaControl
|
|
|
14
14
|
sensors: user_ctrl.sensors,
|
|
15
15
|
modes: user_ctrl.modes,
|
|
16
16
|
user_modes: user_ctrl.user_modes,
|
|
17
|
+
input_temperature_source: user_ctrl.input_temperature_source,
|
|
17
18
|
}
|
|
18
19
|
end
|
|
19
20
|
|
|
@@ -33,8 +34,13 @@ module AtreaControl
|
|
|
33
34
|
|
|
34
35
|
# Get and parse XML with user/unit configuration source
|
|
35
36
|
def user_ctrl
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
return @user_ctrl if @user_ctrl
|
|
38
|
+
|
|
39
|
+
response = read(_t: "lang/userCtrl.xml", _async: 1)
|
|
40
|
+
xml = parse_xml(response.body)
|
|
41
|
+
raise AtreaControl::ProtocolError, "Expected userCtrl layout XML" unless xml.at_xpath("/root/layout/body")
|
|
42
|
+
|
|
43
|
+
@user_ctrl = xml
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
# User-defined name of home (RD5 unit)
|
|
@@ -47,99 +53,104 @@ module AtreaControl
|
|
|
47
53
|
def sensors
|
|
48
54
|
return @sensors if @sensors
|
|
49
55
|
|
|
50
|
-
power =
|
|
51
|
-
mode =
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
switch_mode = mode.xpath("displayval").text[/values\.(\w+)/, 1]
|
|
55
|
-
|
|
56
|
-
power_input = power.xpath("onchange").text[/getUrlPar\('(\w+)',val\)/, 1]
|
|
57
|
-
m = mode.xpath("onchange").text.match(/getUrlPar\('(\w+)',val\).*getUrlPar\(.(\w+).,2\)/)
|
|
58
|
-
|
|
59
|
-
preheat_temperature = user_ctrl.xpath("//body/content/i[@title='$requiredTemp']")
|
|
60
|
-
|
|
61
|
-
input_temp = user_ctrl.xpath("//body/content/i[@title='$inletTemp']") # "I10200"
|
|
62
|
-
input_temperature = input_temp.xpath("displayval").text[/val=values\.(\w\d+);/, 1]
|
|
56
|
+
power = sensor_node("$currentPower")
|
|
57
|
+
mode = sensor_node("$currentMode")
|
|
58
|
+
power_command = RD5Command.discover(power.xpath("onchange").text, power: true)
|
|
59
|
+
mode_command = RD5Command.discover(mode.xpath("onchange").text)
|
|
63
60
|
@sensors = {
|
|
64
|
-
"outdoor_temperature" =>
|
|
65
|
-
"preheat_temperature" =>
|
|
66
|
-
"input_temperature" =>
|
|
67
|
-
"current_power" => power
|
|
68
|
-
"current_mode" => mode
|
|
69
|
-
"current_mode_switch" =>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
61
|
+
"outdoor_temperature" => sensor_node("$outdoorTemp")["id"],
|
|
62
|
+
"preheat_temperature" => sensor_node("$requiredTemp")["id"],
|
|
63
|
+
"input_temperature" => sensor_node("$inletTemp", optional: true)&.[]("id"),
|
|
64
|
+
"current_power" => power["id"],
|
|
65
|
+
"current_mode" => mode["id"],
|
|
66
|
+
"current_mode_switch" => mode.xpath("displayval").text[/values\.(\w+)/, 1], # "H10712"
|
|
67
|
+
**power_command.transform_keys { |key| "power_#{key}" },
|
|
68
|
+
**mode_command.transform_keys { |key| "mode_#{key}" },
|
|
69
|
+
}.tap { |mappings| validate_sensors(mappings) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Persist the source-resolution rule with mappings, not the downloaded JavaScript.
|
|
73
|
+
def input_temperature_source
|
|
74
|
+
@input_temperature_source ||= RD5Configuration.input_temperature_source(user_ctrl.xpath("/root/layout/body/content/i[@title='$inletTemp']/displayval").text)
|
|
74
75
|
end
|
|
75
76
|
|
|
76
77
|
# Generic modes
|
|
77
78
|
# @return [Hash]
|
|
78
79
|
def modes
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
@modes = {}
|
|
82
|
-
user_ctrl.xpath("//op[@id='Mode']/i").each do |mode|
|
|
83
|
-
m = translate_mode(mode)
|
|
84
|
-
@modes[m[:id]] = m[:value]
|
|
85
|
-
end
|
|
86
|
-
@modes
|
|
80
|
+
@modes ||= mode_map("Mode")
|
|
87
81
|
end
|
|
88
82
|
|
|
89
83
|
# Modes by inputs - switches - named by user custom texts
|
|
90
84
|
# @return [Hash]
|
|
91
85
|
def user_modes
|
|
92
|
-
|
|
86
|
+
@user_modes ||= mode_map("ModeText")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def read(params)
|
|
92
|
+
request.call(**params)
|
|
93
|
+
end
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
user_ctrl.
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
def sensor_node(title, optional: false)
|
|
96
|
+
user_ctrl.at_xpath("/root/layout/body/content/i[@title='#{title}']") ||
|
|
97
|
+
(raise(AtreaControl::UnsupportedConfiguration, "Missing sensor #{title}") unless optional)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_sensors(mappings)
|
|
101
|
+
mappings.except("input_temperature").each do |field, id|
|
|
102
|
+
raise AtreaControl::UnsupportedConfiguration, "Missing or invalid #{field} mapping" unless id&.match?(/\A[A-Z]\d+\z/)
|
|
98
103
|
end
|
|
99
|
-
|
|
104
|
+
mappings
|
|
100
105
|
end
|
|
101
106
|
|
|
102
|
-
|
|
107
|
+
def mode_map(id)
|
|
108
|
+
nodes = user_ctrl.xpath("/root/layout/options/op[@id='#{id}']/i")
|
|
109
|
+
raise AtreaControl::UnsupportedConfiguration, "Missing #{id} definitions" if nodes.empty?
|
|
110
|
+
|
|
111
|
+
nodes.to_h { |node| translate_mode(node) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_xml(body)
|
|
115
|
+
xml = Nokogiri::XML(body) { |config| config.strict.nonet }
|
|
116
|
+
raise AtreaControl::ProtocolError, "Unexpected configuration DTD" if xml.internal_subset
|
|
117
|
+
|
|
118
|
+
xml
|
|
119
|
+
rescue Nokogiri::XML::SyntaxError
|
|
120
|
+
raise AtreaControl::ProtocolError, "Malformed configuration XML", cause: nil
|
|
121
|
+
end
|
|
103
122
|
|
|
104
123
|
# User defined texts in RD5 unit
|
|
105
124
|
# @return [Hash]
|
|
106
125
|
def user_texts
|
|
107
126
|
return @user_texts if @user_texts
|
|
108
127
|
|
|
109
|
-
response =
|
|
110
|
-
xml =
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
128
|
+
response = read(_t: "config/texts.xml")
|
|
129
|
+
xml = parse_xml(response.body)
|
|
130
|
+
raise AtreaControl::ProtocolError, "Expected user texts XML" unless xml.at_xpath("/root/texts")
|
|
131
|
+
|
|
132
|
+
@user_texts = xml.xpath("/root/texts/i").to_h do |node|
|
|
133
|
+
value = node["value"]
|
|
134
|
+
id = node["id"]
|
|
135
|
+
raise AtreaControl::ProtocolError, "Invalid user text" if id.to_s.empty? || value.nil?
|
|
136
|
+
|
|
114
137
|
[id, value.gsub(/%u([\dA-Z]{4})/) { |i| +"" << i[Regexp.last_match(1)].to_i(16) }]
|
|
115
138
|
end
|
|
116
139
|
end
|
|
117
140
|
|
|
118
141
|
# @param [Nokogiri::XML::Element] mode
|
|
119
|
-
# @return [
|
|
142
|
+
# @return [Array(String, String)]
|
|
120
143
|
def translate_mode(mode)
|
|
121
|
-
id = mode
|
|
122
|
-
title = mode
|
|
144
|
+
id = mode["id"]
|
|
145
|
+
title = mode["title"]
|
|
146
|
+
raise AtreaControl::UnsupportedConfiguration, "Invalid mode definition" if id.to_s.empty? || title.to_s.empty?
|
|
147
|
+
|
|
123
148
|
title = if title.start_with?("$")
|
|
124
149
|
I18n.t(title[/\w+/])
|
|
125
150
|
else
|
|
126
151
|
user_texts[title]
|
|
127
152
|
end
|
|
128
|
-
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# Re-generate copy of locale files
|
|
132
|
-
# @note internal use only
|
|
133
|
-
def update_locales_files!
|
|
134
|
-
{ cs: "0", de: "1", en: "2" }.each do |name, atrea_id|
|
|
135
|
-
response = request.call(_t: "lang/texts_#{atrea_id}.xml")
|
|
136
|
-
|
|
137
|
-
xml = Nokogiri::XML response.body
|
|
138
|
-
# `eval` JS object (= hash)
|
|
139
|
-
locale = eval xml.xpath("//words/text()")[0].text
|
|
140
|
-
yaml = { name.to_s => JSON.parse(locale.to_json) }.to_yaml
|
|
141
|
-
File.write(File.expand_path("../../../config/locales/#{name}.yml", __dir__), yaml)
|
|
142
|
-
end
|
|
153
|
+
[id, title]
|
|
143
154
|
end
|
|
144
155
|
end
|
|
145
156
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module AtreaControl
|
|
6
|
+
module Duplex
|
|
7
|
+
# One monotonic budget, with an explicitly unknown outcome after command dispatch.
|
|
8
|
+
class WriteVerification
|
|
9
|
+
def self.registers(mapping)
|
|
10
|
+
return nil if mapping.nil?
|
|
11
|
+
|
|
12
|
+
valid = mapping.is_a?(Hash) && !mapping.empty? && mapping.all? { |key, id| valid_register?(key, id) }
|
|
13
|
+
raise ArgumentError, "Expected verification register map with power/mode symbol keys and H plus five digits" unless valid
|
|
14
|
+
|
|
15
|
+
mapping.transform_values { |id| id.dup.freeze }.freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.valid_register?(key, id)
|
|
19
|
+
%i[power mode].include?(key) && id.is_a?(String) && id.match?(/\AH[0-9]{5}\z/)
|
|
20
|
+
end
|
|
21
|
+
private_class_method :valid_register?
|
|
22
|
+
|
|
23
|
+
def initialize(timeout:, poll_interval:)
|
|
24
|
+
{ timeout: timeout, poll_interval: poll_interval }.each do |key, value|
|
|
25
|
+
raise ArgumentError, "#{key} must be positive and finite" unless value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
|
|
26
|
+
end
|
|
27
|
+
@deadline = monotonic_time + timeout
|
|
28
|
+
@poll_interval = poll_interval
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def remaining
|
|
32
|
+
budget = @deadline - monotonic_time
|
|
33
|
+
return budget if budget.positive?
|
|
34
|
+
|
|
35
|
+
timeout!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run(&)
|
|
39
|
+
Timeout.timeout(remaining, AtreaControl::UnitConnectionTimeout, "Write verification timed out", &)
|
|
40
|
+
rescue Timeout::Error, AtreaControl::UnitConnectionTimeout
|
|
41
|
+
timeout!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def dispatch
|
|
45
|
+
remaining
|
|
46
|
+
@attempted = true
|
|
47
|
+
yield
|
|
48
|
+
remaining
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def confirm
|
|
52
|
+
loop do
|
|
53
|
+
remaining
|
|
54
|
+
matched = yield
|
|
55
|
+
remaining
|
|
56
|
+
return if matched
|
|
57
|
+
|
|
58
|
+
Kernel.sleep([@poll_interval, remaining].min)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def timeout!
|
|
65
|
+
raise AtreaControl::WriteNotConfirmed, "Write not confirmed; outcome unknown", cause: nil if @attempted
|
|
66
|
+
|
|
67
|
+
raise AtreaControl::UnitConnectionTimeout, "Write verification baseline timed out", cause: nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def monotonic_time
|
|
71
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/atrea_control/duplex.rb
CHANGED
|
@@ -8,6 +8,8 @@ module AtreaControl
|
|
|
8
8
|
|
|
9
9
|
autoload :Login, "atrea_control/duplex/login"
|
|
10
10
|
autoload :Request, "atrea_control/duplex/request"
|
|
11
|
+
autoload :RD5Configuration, "atrea_control/duplex/rd5_configuration"
|
|
12
|
+
autoload :RD5Command, "atrea_control/duplex/rd5_command"
|
|
11
13
|
autoload :Unit, "atrea_control/duplex/unit"
|
|
12
14
|
autoload :UserCtrl, "atrea_control/duplex/user_ctrl"
|
|
13
15
|
end
|
data/lib/atrea_control/logger.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "nokogiri"
|
|
4
4
|
require "time"
|
|
5
|
+
require "tzinfo"
|
|
5
6
|
|
|
6
7
|
module AtreaControl
|
|
7
8
|
# Call RD5 unit ang get current sensors values
|
|
@@ -10,44 +11,38 @@ module AtreaControl
|
|
|
10
11
|
include AtreaControl::Logger
|
|
11
12
|
|
|
12
13
|
# @param [AtreaControl::Duplex::UserCtrl] user_ctrl
|
|
13
|
-
def initialize(user_ctrl)
|
|
14
|
+
def initialize(user_ctrl, time_zone: nil)
|
|
14
15
|
@user_ctrl = user_ctrl
|
|
16
|
+
@time_zone = TZInfo::Timezone.get(time_zone) unless time_zone.nil?
|
|
17
|
+
rescue TZInfo::InvalidTimezoneIdentifier
|
|
18
|
+
raise AtreaControl::ProtocolError, "Invalid unit time zone", cause: nil
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
def values(xml)
|
|
18
|
-
|
|
22
|
+
snapshot(xml).fetch(:values)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Readings and policy share one validated register map, without mutable caches.
|
|
26
|
+
def snapshot(xml)
|
|
27
|
+
document = validated_document(xml)
|
|
28
|
+
registers = extract_registers(document)
|
|
29
|
+
configuration = Duplex::RD5Configuration.new(registers)
|
|
30
|
+
{ values: format_data(project(document, registers, configuration)), configuration: configuration, registers: registers.freeze }
|
|
19
31
|
end
|
|
20
32
|
|
|
21
33
|
def input(sensor, value)
|
|
22
|
-
|
|
34
|
+
Duplex::RD5Command.encode(sensor, value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Validated register values stay unsigned until mapped to temperature fields.
|
|
38
|
+
def raw_values(xml)
|
|
39
|
+
extract_registers(validated_document(xml))
|
|
23
40
|
end
|
|
24
41
|
|
|
25
|
-
# @see scripts.php -> loadRD5Values(node, init)
|
|
26
|
-
# @note
|
|
27
|
-
# if(values[key]>32767) values[key]-=65536;
|
|
28
|
-
# if(params[key] && params[key].offset)
|
|
29
|
-
# values[key]=values[key]-params[key].offset;
|
|
30
|
-
# if(params[key] && params[key].coef)
|
|
31
|
-
# values[key]=values[key]/params[key].coef;
|
|
32
42
|
def parse(xml)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
node = xml.xpath("//O[@I=\"#{id}\"]").last
|
|
37
|
-
# logger.debug node.to_s
|
|
38
|
-
value = node.attribute("V").value.to_i
|
|
39
|
-
value -= 65_536 if value > 32_767
|
|
40
|
-
# value -= 0 if "offset"
|
|
41
|
-
# value = value / coef if "coef"
|
|
42
|
-
value
|
|
43
|
-
end
|
|
44
|
-
preheating = %w[C10200 C10202 C10215 C10217].any? do |i|
|
|
45
|
-
xml.xpath("//O[@I=\"#{i}\"]/@V").last&.value == "1"
|
|
46
|
-
end
|
|
47
|
-
# @note timestamp seems to be localtime of creation of xml
|
|
48
|
-
parsed["timestamp"] = xml.xpath("//RD5WEB").attribute("t").to_s
|
|
49
|
-
parsed["preheating"] = preheating
|
|
50
|
-
parsed
|
|
43
|
+
document = validated_document(xml)
|
|
44
|
+
registers = extract_registers(document)
|
|
45
|
+
project(document, registers, Duplex::RD5Configuration.new(registers))
|
|
51
46
|
end
|
|
52
47
|
|
|
53
48
|
# @param [Hash] values
|
|
@@ -58,14 +53,83 @@ module AtreaControl
|
|
|
58
53
|
"current_power" => values["current_power"].to_f,
|
|
59
54
|
"outdoor_temperature" => values["outdoor_temperature"].to_f / 10.0,
|
|
60
55
|
"preheat_temperature" => values["preheat_temperature"].to_f / 10.0,
|
|
61
|
-
"
|
|
56
|
+
"required_temperature" => values["preheat_temperature"].to_f / 10.0,
|
|
57
|
+
"input_temperature" => values["input_temperature"]&.fdiv(10),
|
|
62
58
|
"preheating" => values["preheating"],
|
|
63
|
-
"timestamp" =>
|
|
59
|
+
"timestamp" => timestamp_time(values["timestamp"]),
|
|
64
60
|
}
|
|
65
61
|
end
|
|
66
62
|
|
|
67
63
|
private
|
|
68
64
|
|
|
65
|
+
def project(document, registers, configuration)
|
|
66
|
+
parsed = @user_ctrl.sensors.to_h do |field, id|
|
|
67
|
+
value = field == "input_temperature" ? input_temperature(registers, configuration) : mapped_value(registers, field, id)
|
|
68
|
+
value -= 65_536 if %w[outdoor_temperature preheat_temperature input_temperature].include?(field) && value.to_i > 32_767
|
|
69
|
+
[field, value]
|
|
70
|
+
end
|
|
71
|
+
parsed["required_temperature"] = parsed["preheat_temperature"]
|
|
72
|
+
# @note timestamp seems to be localtime of creation of xml
|
|
73
|
+
parsed["timestamp"] = document.root["t"]
|
|
74
|
+
parsed["preheating"] = %w[C10200 C10202 C10215 C10217].any? { |id| registers[id] == 1 }
|
|
75
|
+
parsed
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def input_temperature(registers, configuration)
|
|
79
|
+
source = @user_ctrl.input_temperature_source if @user_ctrl.respond_to?(:input_temperature_source)
|
|
80
|
+
return configuration.input_temperature if source.to_s == "rd5_standard"
|
|
81
|
+
return nil unless source.nil?
|
|
82
|
+
|
|
83
|
+
registers[@user_ctrl.sensors["input_temperature"]]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def mapped_value(registers, field, id)
|
|
87
|
+
raise AtreaControl::UnsupportedConfiguration, "Invalid register mapping for #{field}" unless id.is_a?(String) && id.match?(/\A[A-Z]\d+\z/)
|
|
88
|
+
return registers[id] if %w[power_input power_switch mode_input mode_switch].include?(field)
|
|
89
|
+
|
|
90
|
+
registers.fetch(id) { raise AtreaControl::ProtocolError, "Missing required register #{id}" }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validated_document(xml)
|
|
94
|
+
document = Nokogiri::XML(xml) { |config| config.strict.nonet }
|
|
95
|
+
raise AtreaControl::ProtocolError, "Expected RD5WEB/RD5 unit XML" if document.internal_subset || !document.at_xpath("/RD5WEB/RD5")
|
|
96
|
+
|
|
97
|
+
validate_timestamp(document.root["t"])
|
|
98
|
+
document
|
|
99
|
+
rescue Nokogiri::XML::SyntaxError
|
|
100
|
+
raise AtreaControl::ProtocolError, "Malformed unit XML", cause: nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate_timestamp(timestamp)
|
|
104
|
+
raise AtreaControl::SessionExpired if timestamp.to_s.strip.empty?
|
|
105
|
+
|
|
106
|
+
timestamp_time(timestamp)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def timestamp_time(timestamp)
|
|
110
|
+
timestamp = timestamp.strip if timestamp.is_a?(String)
|
|
111
|
+
format = "%Y-%m-%d %H:%M:%S"
|
|
112
|
+
raise AtreaControl::ProtocolError, "Invalid unit timestamp" unless timestamp.is_a?(String) && timestamp.match?(/\A[0-9]{4}-[0-9]{1,2}-[0-9]{1,2} [0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}\z/)
|
|
113
|
+
|
|
114
|
+
timestamp = timestamp.gsub(/[0-9]+/) { |component| component.rjust(2, "0") }
|
|
115
|
+
local = Time.strptime("#{timestamp} +0000", "#{format} %z")
|
|
116
|
+
raise AtreaControl::ProtocolError, "Invalid unit timestamp" unless local.strftime(format) == timestamp
|
|
117
|
+
|
|
118
|
+
@time_zone ? @time_zone.local_to_utc(local, nil) : Time.strptime(timestamp, format)
|
|
119
|
+
rescue ArgumentError, TZInfo::AmbiguousTime, TZInfo::PeriodNotFound
|
|
120
|
+
raise AtreaControl::ProtocolError, "Invalid unit timestamp", cause: nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def extract_registers(document)
|
|
124
|
+
document.xpath("/RD5WEB/RD5/*/O").each_with_object({}) do |node, registers|
|
|
125
|
+
id = node["I"].to_s
|
|
126
|
+
value = node["V"].to_s
|
|
127
|
+
raise AtreaControl::ProtocolError, "Invalid register #{id.inspect}" unless id.match?(/\A[A-Z]\d+\z/) && value.match?(/\A\d+\z/) && value.to_i <= 65_535
|
|
128
|
+
|
|
129
|
+
registers[id] = Integer(value, 10)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
69
133
|
# `current_mode_switch` = mode trigger by wall switch or something similar
|
|
70
134
|
# `current_mode` = is common "builtin" mode
|
|
71
135
|
def parse_current_mode(values)
|
data/lib/atrea_control.rb
CHANGED
|
@@ -7,6 +7,13 @@ require "yaml"
|
|
|
7
7
|
module AtreaControl
|
|
8
8
|
class Error < StandardError; end
|
|
9
9
|
|
|
10
|
+
class AuthenticationError < Error; end
|
|
11
|
+
class TransportError < Error; end
|
|
12
|
+
class UnitConnectionTimeout < TransportError; end
|
|
13
|
+
class ProtocolError < Error; end
|
|
14
|
+
class UnsupportedConfiguration < Error; end
|
|
15
|
+
class WriteNotConfirmed < Error; end
|
|
16
|
+
|
|
10
17
|
class SessionExpired < Error
|
|
11
18
|
def message
|
|
12
19
|
"Session expired. Please perform login again."
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: atrea_control
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 4.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lukáš Pokorný
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '2.
|
|
18
|
+
version: '2.14'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '2.
|
|
25
|
+
version: '2.14'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: faraday-cookie_jar
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -43,28 +43,42 @@ dependencies:
|
|
|
43
43
|
requirements:
|
|
44
44
|
- - "~>"
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: '1.
|
|
46
|
+
version: '1.15'
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: '1.
|
|
53
|
+
version: '1.15'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
55
|
name: nokogiri
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
57
57
|
requirements:
|
|
58
58
|
- - "~>"
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '1.
|
|
60
|
+
version: '1.16'
|
|
61
61
|
type: :runtime
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '1.
|
|
67
|
+
version: '1.16'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: tzinfo
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '2.0'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '2.0'
|
|
68
82
|
description: Read data from web controller of RD5 duplex by Atrea.
|
|
69
83
|
email:
|
|
70
84
|
- pokorny@luk4s.cz
|
|
@@ -78,6 +92,7 @@ files:
|
|
|
78
92
|
- ".rubocop.yml"
|
|
79
93
|
- ".rubocop_todo.yml"
|
|
80
94
|
- ".ruby-version"
|
|
95
|
+
- AGENTS.md
|
|
81
96
|
- CHANGELOG.md
|
|
82
97
|
- CODE_OF_CONDUCT.md
|
|
83
98
|
- Gemfile
|
|
@@ -90,12 +105,20 @@ files:
|
|
|
90
105
|
- config/locales/cs.yml
|
|
91
106
|
- config/locales/de.yml
|
|
92
107
|
- config/locales/en.yml
|
|
108
|
+
- docs/rd5-cloud-protocol.md
|
|
109
|
+
- docs/superpowers/plans/2026-10-24-rd5-reliability.md
|
|
93
110
|
- lib/atrea_control.rb
|
|
94
111
|
- lib/atrea_control/duplex.rb
|
|
95
112
|
- lib/atrea_control/duplex/login.rb
|
|
113
|
+
- lib/atrea_control/duplex/login_discovery.rb
|
|
114
|
+
- lib/atrea_control/duplex/login_session.rb
|
|
115
|
+
- lib/atrea_control/duplex/rd5_command.rb
|
|
116
|
+
- lib/atrea_control/duplex/rd5_configuration.rb
|
|
96
117
|
- lib/atrea_control/duplex/request.rb
|
|
97
118
|
- lib/atrea_control/duplex/unit.rb
|
|
119
|
+
- lib/atrea_control/duplex/unit_commands.rb
|
|
98
120
|
- lib/atrea_control/duplex/user_ctrl.rb
|
|
121
|
+
- lib/atrea_control/duplex/write_verification.rb
|
|
99
122
|
- lib/atrea_control/logger.rb
|
|
100
123
|
- lib/atrea_control/sensor_parser.rb
|
|
101
124
|
- lib/atrea_control/version.rb
|
|
@@ -112,7 +135,7 @@ require_paths:
|
|
|
112
135
|
- lib
|
|
113
136
|
required_ruby_version: !ruby/object:Gem::Requirement
|
|
114
137
|
requirements:
|
|
115
|
-
- - "
|
|
138
|
+
- - ">="
|
|
116
139
|
- !ruby/object:Gem::Version
|
|
117
140
|
version: '3.2'
|
|
118
141
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
@@ -121,7 +144,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
121
144
|
- !ruby/object:Gem::Version
|
|
122
145
|
version: '0'
|
|
123
146
|
requirements: []
|
|
124
|
-
rubygems_version:
|
|
147
|
+
rubygems_version: 4.0.20
|
|
125
148
|
specification_version: 4
|
|
126
149
|
summary: Get data control.atrea.eu
|
|
127
150
|
test_files: []
|