atrea_control 3.2.1 → 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.
@@ -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
- response = request.call(_t: "lang/userCtrl.xml", _async: 1)
37
- Nokogiri::XML response.body
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 = user_ctrl.xpath("//body/content/i[@title='$currentPower']")
51
- mode = user_ctrl.xpath("//body/content/i[@title='$currentMode']")
52
- outdoor_temp = user_ctrl.xpath("//body/content/i[@title='$outdoorTemp']")
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" => outdoor_temp.attribute("id").value, # "I10208"
65
- "preheat_temperature" => preheat_temperature.attribute("id").value, # "H10706"
66
- "input_temperature" => input_temperature,
67
- "current_power" => power.attribute("id").value, # "H10704"
68
- "current_mode" => mode.attribute("id").value, # "H10705"
69
- "current_mode_switch" => switch_mode, # "H10712"
70
- "power_input" => power_input, # "H10708"
71
- "mode_input" => m[1], # "H10709"
72
- "mode_switch" => m[2], # "H10701"
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
- return @modes if @modes
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
- return @user_modes if @user_modes
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
- @user_modes = {}
95
- user_ctrl.xpath("//op[@id='ModeText']/i").each do |mode|
96
- m = translate_mode(mode)
97
- @user_modes[m[:id]] = m[:value]
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
- @user_modes
104
+ mappings
100
105
  end
101
106
 
102
- private
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 = request.call(_t: "config/texts.xml")
110
- xml = Nokogiri::XML response.body
111
- @user_texts = xml.xpath("//i").to_h do |node|
112
- value = node.attributes["value"].value
113
- id = node.attributes["id"].value
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 [Hash{Symbol->String}]
142
+ # @return [Array(String, String)]
120
143
  def translate_mode(mode)
121
- id = mode.attributes["id"].value
122
- title = mode.attributes["title"].value
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
- { id: id, value: title }
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
@@ -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
@@ -4,8 +4,10 @@ require "logger"
4
4
 
5
5
  module AtreaControl
6
6
  module Logger
7
+ attr_writer :logger
8
+
7
9
  def logger
8
- @logger ||= ::Logger.new($stdout)
10
+ @logger ||= ::Logger.new($stdout, level: ::Logger::INFO)
9
11
  end
10
12
  end
11
13
  end
@@ -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
- format_data(parse(xml))
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
- sensor + value.to_s.rjust(5, "0")
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
- xml = Nokogiri::XML xml
34
- parsed = @user_ctrl.sensors.transform_values do |id|
35
- # node = xml.xpath("//O[@I=\"#{id}\"]/@V").last
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
- "input_temperature" => values["input_temperature"].to_f / 10.0,
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" => Time.strptime(values["timestamp"], "%Y-%m-%d %H:%M:%S"),
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)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  # :nocov:
3
4
 
4
5
  module AtreaControl
5
- VERSION = "3.2.1"
6
+ VERSION = "4.0.0"
6
7
  end
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: 3.2.1
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.7'
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.7'
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.14'
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.14'
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.15'
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.15'
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
@@ -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: 4.0.6
147
+ rubygems_version: 4.0.20
125
148
  specification_version: 4
126
149
  summary: Get data control.atrea.eu
127
150
  test_files: []