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.
data/README.md CHANGED
@@ -1,89 +1,242 @@
1
1
  # atrea_control
2
- Ventilation systems by https://www.atrea.eu are build with web UI portal - but this portal did not provide any API interface...
3
2
 
4
- This gem provide way how to connect to portal and obtain data from unit as API.
3
+ Ruby client for ATREA DUPLEX ventilation units with **RD5 regulation**, using
4
+ the [RD5 cloud portal](https://control.atrea.eu/). RD5-only support is intentional:
5
+ this unofficial gem uses cloud HTTP/XML, not aMotion Cloud or Modbus TCP.
5
6
 
6
- ## Highlights
7
-
8
- * connect to portal, wait for login
9
- * provide basic data
10
- * temperature
11
- * fan power
12
- * power mode
13
- * allow change
14
- * power
15
- * mode
7
+ **Login and end-to-end reads were verified against a live RD5 unit on 2026-10-25:**
8
+ configuration discovery, fresh snapshots, getters/JSON and repeated refreshes
9
+ succeeded. A missing `Sync=1` parameter in the unit-login request was corrected
10
+ after comparison with the current portal. Live writes and requested-value
11
+ read-back semantics remain unverified. See [protocol evidence and limitations](docs/rd5-cloud-protocol.md).
16
12
 
17
13
  ## Installation
18
14
 
19
- Add this line to your application's Gemfile:
20
-
21
15
  ```ruby
22
16
  gem "atrea_control"
23
17
  ```
24
18
 
25
- And then execute:
26
- ```bash
27
- bundle install
28
- ```
29
-
30
- Or
31
- ```bash
32
- bundle add atrea_control
33
- ```
34
-
35
- Or install it yourself as:
36
-
37
- ```bash
38
- gem install atrea_control
39
- ```
19
+ Run `bundle install`, or install directly with `gem install atrea_control`.
40
20
 
41
- ## Usage
21
+ ## Login and snapshots
42
22
 
43
- At the begin you need obtain `user_id`, `unit_id` and `sid` (auth token). For this use "Login"
44
-
45
- * `user_id` is atrea internal ID of user account
46
- * `unit_id` is atrea `ident` - identification number of airflow unit (ventilation system) - 10 digits
47
- * `sid` is session ID - auth token, valid for logged session. Its validity is unknown
23
+ Supply credentials through your application's environment. The library does not
24
+ load `.env` files automatically. Never log credentials or returned tokens.
48
25
 
49
26
  ```ruby
50
- tokens = AtreaControl::Duplex::Login.user_tokens login: "myhome", password: "sup3r-S3CR3T-kocicka"
51
- tokens # => { user_id: "1234", unit_id: "85425324672", sid: 4012 }
27
+ require "atrea_control"
28
+
29
+ tokens = AtreaControl::Duplex::Login.user_tokens(
30
+ login: ENV.fetch("ATREA_USERNAME"),
31
+ password: ENV.fetch("ATREA_PASSWORD"),
32
+ unit_id: ENV.fetch("ATREA_UNIT_IDENT"),
33
+ timeout: 300,
34
+ poll_interval: 1,
35
+ open_timeout: 10,
36
+ read_timeout: 60
37
+ )
38
+
39
+ control = AtreaControl::Duplex::Unit.new(**tokens, time_zone: "Europe/Prague")
40
+ values = control.values
41
+ values[:current_power] # Numeric configured power value, not universally percent.
42
+ values[:current_mode] # Translated label; may describe a user input such as CO2.
43
+ control.refresh! # Explicitly fetch and validate a new snapshot.
52
44
  ```
53
- I recommend to store then somewhere...
54
- Then you can call Unit for data...
55
45
 
56
- Example usage:
46
+ `Login.new(...).call` accepts the same keywords. `unit_id:` selects the unit's
47
+ `ident`, **not** its portal row ID. Omit it to select the first listed unit.
48
+ Returned tokens contain `user_id`, `unit_id` and secret `sid`.
49
+
50
+ Account authentication and unit-session acquisition are separate stages. Defaults
51
+ are `timeout: 300`, `poll_interval: 1`, `open_timeout: 10`, `read_timeout: 60`, in
52
+ seconds. The entire login, including authentication, discovery and polling, must
53
+ finish within five minutes; shorter `timeout:` values are allowed, larger ones
54
+ are rejected. Each HTTP operation has a separate 60-second total limit covering
55
+ connection, writing and reading, also bounded by the outer login limit. Both use
56
+ Faraday's Net::HTTP adapter and standard-library `Timeout`, without a custom
57
+ transport. Server ETA is advisory, not a loop count. Timeout does not establish
58
+ that credentials were rejected or the unit is offline.
59
+
60
+ Ordinary unit requests also have a 60-second total limit, not just a per-read
61
+ timeout. `Duplex::Request.new(..., timeout: ...)` accepts shorter total limits;
62
+ `open_timeout:` and `read_timeout:` can independently shorten connection/read
63
+ waits. The adapter never automatically retries requests.
64
+
65
+ ### Snapshot lifetime and temperature semantics
66
+
67
+ * First reading fetches one complete snapshot; later getters reuse it without
68
+ implicit network refresh. `refresh!` explicitly replaces it.
69
+ * Every current-value getter and serializer checks freshness: timestamp must be
70
+ newer than `now - 900` seconds and no later than `now + 60` seconds. Exactly
71
+ 15 minutes old is expired. `valid?` inspects cached state without fetching.
72
+ * Failed refresh invalidates current data and preserves the failure until another
73
+ `refresh!`. A write attempt also discards the pre-write snapshot, even when its
74
+ response fails; next reading fetches again. No stale fallback is exposed.
75
+ * `values`/`as_json` use symbol keys; `parsed` uses string keys. Readers include
76
+ `power`/`current_power`, `mode`/`current_mode`, `outdoor_temperature`,
77
+ `required_temperature`, `preheat_temperature`, `input_temperature`, `preheating`
78
+ / `preheating?`, and `timestamp`. Temperatures are degrees Celsius.
79
+ * `required_temperature` is the requested temperature. Historical
80
+ `preheat_temperature` reader and hash key remain aliases in meaning; both keys
81
+ are present. This is **not** a measured preheater temperature.
82
+ * Optional `input_temperature` is `nil` when unavailable or its discovered logic
83
+ is unsupported. It is not fabricated as zero.
84
+ * Legacy `preheating` is an aggregate of heating-related signals, not a verified
85
+ dedicated preheater state.
86
+ * Optional `time_zone:` accepts an IANA identifier (also on `SensorParser.new`).
87
+ `nil` preserves process-local timestamp interpretation. Set the actual unit's
88
+ zone explicitly to remove deployment-zone ambiguity. Invalid zones and
89
+ ambiguous/nonexistent DST-local timestamps raise `ProtocolError`.
90
+ * Device timestamps may omit leading zeroes (for example, `17:7:32`). These are
91
+ normalized while invalid calendar dates and times are still rejected.
92
+
93
+ Loading the gem registers translations and sets global `I18n.default_locale` to
94
+ `:cs`. Sensor/mode maps use string keys. `user_modes` keys are numeric strings,
95
+ not input names such as `"D1"`.
96
+
97
+ ### Configuration discovery
98
+
99
+ `Unit.new` accepts `user_ctrl:` to reuse a `Duplex::UserCtrl` for the **same unit**.
100
+ It caches successfully validated layout/text configuration per instance; recreate
101
+ it after configuration changes. `UserCtrl.data` exports JSON-safe configuration,
102
+ including `input_temperature_source`: `"rd5_standard"` selects the recognized
103
+ device source-selection rule; `"unsupported"` keeps inlet temperature unavailable.
104
+ Persist this field alongside the sensor/mode maps and expose it when reconstructing
105
+ a custom `user_ctrl:` object, such as an ActiveRecord model. Without it, only the
106
+ legacy direct sensor mapping can be used. Missing/nil metadata retains that legacy
107
+ behavior; explicit unknown metadata returns `nil`, without guessing a sensor.
108
+
109
+ Custom objects supply `name`, `sensors`, `modes`, `user_modes` and the persisted
110
+ `input_temperature_source`. Sensor interpretation stays inside the gem, not the
111
+ application UI. Command mappings must include discovered `power_input`, `power_switch`,
112
+ `mode_input` and `mode_switch` fields. Derive mappings from the unit's `UserCtrl`,
113
+ not fixture addresses. `control.configuration` returns the fresh snapshot's
114
+ `Duplex::RD5Configuration` policy (`[]`, `fetch`, `to_h`).
115
+
116
+ ## Power and mode commands
117
+
118
+ Setter values must be nonnegative integers, finite integral Floats, or ASCII
119
+ decimal-digit strings: `88`, `88.0`, `"88"` normalize to 88. `nil`, booleans,
120
+ negative/fractional numbers, NaN/infinity, whitespace, signs and `"88.0"` raise
121
+ `ArgumentError`. Values are never silently truncated or clamped. Commands also
122
+ require a supported configuration and discovered register mapping.
123
+
124
+ Power semantics follow RD5 `H10510` in the vendor's
125
+ [Communication MODBUS — RD5 and aMotion, version 04](https://atrea.eu/_files/documents/modbus-comm-amot-rd5-cz-v04_834.pdf),
126
+ section 3.1, page 9:
127
+
128
+ | `H10510` | Configuration | Accepted power values |
129
+ | --- | --- | --- |
130
+ | 0 | Direct speed | 0 or integers 12–100 |
131
+ | 1 | Constant flow | Integers 0–100 (configured flow scale) |
132
+ | 2 | Constant pressure | 0, 1, 2 (levels) |
133
+ | 3 | External input | No power writes; `UnsupportedConfiguration` |
134
+ | 4 | Multiport | 0, 10–12, 20–22, 30–38 (enumerated settings) |
135
+ | Missing/unknown | Unsupported | `UnsupportedConfiguration` |
136
+
137
+ These are not universal fan-speed percentages or measured airflow. Modes must be
138
+ discovered numeric IDs 0–7 and satisfy equipment prerequisites: 0/2 unrestricted;
139
+ 1/6/7 require `C10508=0`; 3 additionally requires `C10509=1`; 4 additionally
140
+ requires `H11700=1`; 5 requires `I11401=1`. Missing prerequisites fail explicitly.
141
+ Translated current-mode labels are not command values.
142
+
143
+ Schedule policy uses the discovered control register: manual 0 sends only the
144
+ setpoint; scheduled 1 or temporary 2 sends the setpoint plus temporary control 2,
145
+ including for power zero. It does not permanently disable schedules. A temporary
146
+ request lasts until the next scheduled interval under the documented RD5 policy.
147
+
148
+ ### Unconfirmed writes
149
+
57
150
  ```ruby
58
- control = AtreaControl::Duplex::Unit.new user_id: "1234", unit_id: "85425324672", sid: 4012
59
- control.values # => { current_power: 88.0, current_mode: "CO2" }
60
- control.power # => 88.0
151
+ response = control.set_power(88, verify: false)
152
+ response = control.set_mode(2, verify: false)
153
+ control.power = 88
154
+ control.mode = 2
61
155
  ```
62
156
 
63
- ### Dig deeper
64
- `AtreaControl::Duplex::Unit` expect optional argument `user_ctrl` which should be object respond to
157
+ `verify: false` returns the HTTP response, without application/read-back
158
+ confirmation. `power=` and `mode=` delegate with `verify: false`. Ruby assignment
159
+ expressions evaluate to their right-hand value, **not** confirmation. HTTP success
160
+ alone does not establish that the unit accepted or applied the request. Writes
161
+ are never automatically retried.
65
162
 
66
- `name` (String) = Name of unit
67
- `sensors` (Hash) = Map of sensors, for example: `{ outdoor_temperature: "HI10208", current_power: "H10704" }`
68
- `modes` (Hash) = Is a map of "changable" modes - in unit its something like "builtin?" modes. They are translated by unit lang - `{ "0" => "Vypnuto", "1" => "Automat" }`
69
- `user_modes` (Hash) = Is a map user specific modes, based on home switches / devices (D1, D2, D3, IN1, IN2 ...). They are translated by user texts - `{ "D1" => "Koupelna", "D2" => "CO2", "IN1" => "ovladač" }`
163
+ ### Verified writes require an independently proven mapping
70
164
 
71
- __Please check [lib/atrea_control/duplex/user_ctrl.rb](./lib/atrea_control/duplex/user_ctrl.rb) for more details !__
165
+ `set_power(value, verify: true, timeout: 30, poll_interval: 5)` and `set_mode`
166
+ default to verification. **The default configuration has no proven requested-value
167
+ read-back profile:** verified calls raise `UnsupportedConfiguration` before writing.
168
+ Neither the PDF's current-value fields nor old fixture `idw` fields are selected
169
+ automatically.
72
170
 
73
- This object is used to translate internal unit modes to user-friendly texts & translations.
171
+ Only supply `verification_registers:` after independently verifying requested-value
172
+ semantics for that exact unit/profile. It accepts a nonempty Hash with symbol keys
173
+ `:power` and/or `:mode`, and String values matching `H` plus exactly five digits.
174
+ Syntax validation does not establish register semantics. This example deliberately
175
+ supplies no literal register address:
74
176
 
75
- I strong recommend to use `AtreaControl::Duplex::UserCtrl` object from "cache",
76
- for optimize network traffic - because they are static data.
77
-
78
- ## Development
79
- 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.
177
+ ```ruby
178
+ # These environment values must come from your independently verified profile.
179
+ verified_control = AtreaControl::Duplex::Unit.new(
180
+ **tokens,
181
+ time_zone: "Europe/Prague",
182
+ verification_registers: {
183
+ power: ENV.fetch("ATREA_VERIFIED_POWER_REQUEST_REGISTER"),
184
+ mode: ENV.fetch("ATREA_VERIFIED_MODE_REQUEST_REGISTER")
185
+ }
186
+ )
187
+ confirmed_power = verified_control.set_power(88, timeout: 30, poll_interval: 5)
188
+ confirmed_mode = verified_control.set_mode(2, timeout: 30, poll_interval: 5)
189
+ ```
80
190
 
81
- 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).
191
+ Verification fetches a fresh baseline, writes once, then polls for the normalized
192
+ request in a snapshot **newer than the baseline**. One absolute budget includes
193
+ baseline, lazy discovery, dispatch, read-back and sleeps. It confirms a requested
194
+ setpoint, not physical airflow, effective mode or override of safety functions.
195
+ `WriteNotConfirmed` means outcome unknown after dispatch; do not blindly retry.
196
+
197
+ ## Errors and migration
198
+
199
+ All library errors inherit `AtreaControl::Error`; invalid caller command inputs
200
+ raise standard `ArgumentError`.
201
+
202
+ | Error | Meaning |
203
+ | --- | --- |
204
+ | `AuthenticationError` | Portal rejected authentication. |
205
+ | `SessionExpired` | Unit HTTP 401/403, or missing/stale/future snapshot timestamp. Freshness failure alone does not prove authentication expired. |
206
+ | `ProtocolError` | Malformed/unexpected XML, invalid register/timestamp or DST ambiguity. |
207
+ | `TransportError` | Failed HTTP response or connection. |
208
+ | `UnitConnectionTimeout` | Transport timeout or exhausted login/verification-baseline budget; subclass of `TransportError`. |
209
+ | `UnsupportedConfiguration` | Missing/unsupported configuration, command profile or proven verification mapping. |
210
+ | `WriteNotConfirmed` | Verification budget exhausted after dispatch; outcome unknown. |
211
+
212
+ Migration from 3.2.x: `nil` formerly could become zero; it now raises
213
+ `ArgumentError`. Other malformed inputs no longer silently coerce. All cached
214
+ getters/serializers can now raise freshness or refresh errors. Handle optional
215
+ inlet temperature `nil`, the additional `required_temperature` hash key, strict
216
+ configuration/mode validation, and preserved manual schedule control. Explicit
217
+ `set_power`/`set_mode` default to verification; existing assignment setters remain
218
+ unconfirmed. These changes require a **major-version upgrade** from 3.2.x.
219
+
220
+ ## Protocol reference and development
221
+
222
+ See [RD5 cloud protocol evidence](docs/rd5-cloud-protocol.md) for supported
223
+ declarative discovery forms, fixture/PDF differences and current integration blocker.
224
+ The PDF describes **Modbus TCP**, not cloud framing, acknowledgements or rate
225
+ limits. Port 502, enabling Modbus and its five-second batch interval are not
226
+ prerequisites or established HTTP constraints for this gem.
227
+
228
+ Run `bin/setup` to install dependencies. `bundle exec rake` runs specs then
229
+ RuboCop. CI retains the Ruby spec matrix and runs a separate RuboCop job. Focused
230
+ test: `bundle exec rspec spec/atrea_control/duplex/unit_spec.rb`, optionally with
231
+ `:LINE`. WebMock/local fixtures need no portal credentials or physical unit.
232
+ Use `bin/console` for an interactive prompt.
233
+
234
+ Gem contents come from `git ls-files`; new runtime files must be tracked before
235
+ packaging. `bundle exec rake build` builds locally. Publishing requires a separate
236
+ release decision: `bundle exec rake release` pushes commits/tags and publishes to
237
+ RubyGems; it is not a verification command.
82
238
 
83
239
  ## Contributing
84
240
 
85
- Bug reports and pull requests are welcome on GitHub at https://github.com/luk4s/atrea_control. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/luk4s/atrea_control/blob/master/CODE_OF_CONDUCT.md).
86
-
87
- ## Code of Conduct
88
-
89
- Everyone interacting in the AtreaControl project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/luk4s/atrea_control/blob/master/CODE_OF_CONDUCT.md).
241
+ Bug reports and pull requests are welcome at https://github.com/luk4s/atrea_control.
242
+ Participants must follow the [code of conduct](CODE_OF_CONDUCT.md).
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = "Get data control.atrea.eu"
12
12
  spec.description = "Read data from web controller of RD5 duplex by Atrea."
13
13
  spec.homepage = "https://github.com/luk4s/atrea_control"
14
- spec.required_ruby_version = Gem::Requirement.new("~> 3.2")
14
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.2")
15
15
 
16
16
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
17
17
 
@@ -35,8 +35,9 @@ Gem::Specification.new do |spec|
35
35
  # For more information and examples about making a new gem, checkout our
36
36
  # guide at: https://bundler.io/guides/creating_gem.html
37
37
 
38
- spec.add_dependency "faraday", "~> 2.7"
38
+ spec.add_dependency "faraday", "~> 2.14"
39
39
  spec.add_dependency "faraday-cookie_jar", "~> 0.0"
40
- spec.add_dependency "i18n", "~> 1.14"
41
- spec.add_dependency "nokogiri", "~> 1.15"
40
+ spec.add_dependency "i18n", "~> 1.15"
41
+ spec.add_dependency "nokogiri", "~> 1.16"
42
+ spec.add_dependency "tzinfo", "~> 2.0"
42
43
  end
@@ -0,0 +1,253 @@
1
+ # RD5 cloud protocol: evidence and supported profiles
2
+
3
+ This client intentionally targets RD5 cloud HTTP/XML only. It is neither aMotion
4
+ Cloud nor a Modbus TCP implementation. Local fixture coverage establishes parser
5
+ and policy behavior, not current firmware compatibility.
6
+
7
+ ## Evidence ledger
8
+
9
+ 1. **Older portal fixtures:** `spec/fixtures/files/userCtrl.xml` and `unit.xml`,
10
+ plus login response fixtures. These support declarative layout discovery and
11
+ register parsing. The unit fixture timestamp is historical, not a live sample.
12
+ 2. **Vendor PDF:** *Communication MODBUS — RD5 and aMotion*, version 04, cover
13
+ issue `2025_04_25`, sections 2.3.1 and 3.1 (especially pages 9–13),
14
+ [public source](https://atrea.eu/_files/documents/modbus-comm-amot-rd5-cz-v04_834.pdf).
15
+ The contents page has a different issue date; use version and cover issue as
16
+ identity. It establishes RD5 register semantics, not cloud framing, login,
17
+ acknowledgements or HTTP rate limits. Modbus's five-second batch interval is
18
+ not evidence of a cloud HTTP limit.
19
+ 3. **Initial read-only audits:** account authentication and unit-record discovery
20
+ succeeded. Advertised package
21
+ `3001022`, normalized with leading zeroes, matches `CONTROL_VERSION`
22
+ `003001022`. SID remained zero. The full 360-second budget ended exactly with
23
+ `AtreaControl::UnitConnectionTimeout`, `stage=session_query`.
24
+
25
+ 4. **Successful live audit on 2026-10-25, after production client shutdown:** the
26
+ unmodified handshake still timed out. Unit metadata reported a server contact
27
+ about 101 seconds earlier, so zero SID alone did not establish disconnection.
28
+ Inspection of current portal source identified a missing `Sync=1` on the
29
+ unit-login POST. Adding only this parameter obtained a SID in about 127 seconds
30
+ and allowed valid reads. After applying the fix, a separate public-API run
31
+ logged in in 5.86 seconds, read values/configuration, serialized getters/JSON,
32
+ and completed three refreshes with advancing timestamps and data roughly five
33
+ seconds old. Another run checked the unit-name reader, constructed power/mode
34
+ commands from the actual configuration without sending them, rejected an aged
35
+ live snapshot under a process-local controlled clock, and recovered via refresh.
36
+ The rebuilt `pkg/atrea_control-3.2.1.gem` was then unpacked outside the workspace
37
+ and used for another real login (5.9 seconds), valid read and successful refresh;
38
+ this checked the packaged runtime, not only the working-tree source.
39
+ 5. **Faraday-only follow-up:** after removing the extra transport, live login
40
+ completed in 1.45 seconds. Both Login and Request used `Faraday::Adapter::NetHttp`;
41
+ HTTPX was not loaded. Values and refresh passed, with refreshed data roughly
42
+ five seconds old. This audit also exposed unpadded timestamp components
43
+ (`2026-10-25 17:7:32 `); the parser now accepts those valid fields after
44
+ normalization. No control writes were dispatched.
45
+
46
+ **Remaining integration boundary:** current login, configuration discovery and
47
+ reads are proven for this unit/package. Cloud acknowledgement shape, actual
48
+ writes and requested-value read-back semantics are not. No live control writes
49
+ were run; those need separate agreement. No credentials, account names, unit
50
+ identifiers or SID values are retained here.
51
+
52
+ ## Cloud sequence implemented
53
+
54
+ `Login` posts credentials to the portal (`?action=login`), accepts its expected
55
+ 302, requires the session cookie, obtains a client ID from `core/core.php`, lists
56
+ `userUnits`, and resolves the `units` record to its `ident`. Explicit `unit_id:`
57
+ matches that ident through the account list; otherwise the first unit is selected.
58
+
59
+ `apps/rd5Control/handle.php` with `action=unitQuery`, `query=loged` returns a
60
+ `/root/login` SID. A zero SID triggers `Sync=1&action=unitLogin` with the unit's
61
+ account row reference and `comm=config/login.cgi?magic=`, requiring `/root/sended`, then
62
+ polling the session query. Server wait estimates do not shorten or extend the
63
+ absolute budget. A nonempty, nonzero SID is required for success. No browser
64
+ application-launch request is needed in the verified library sequence.
65
+
66
+ The current portal's `core/start.js` shared `sendSync` implementation prepends
67
+ `Sync=1` to its requests. The RD5 app's `setUnit` function, returned by
68
+ `core/core.php?action=run&object=app`, invokes that synchronous helper for
69
+ `unitLogin`. This source comparison, followed by live success, supports the
70
+ parameter fix without executing downloaded application code in the gem. A
71
+ `/root/sended` response alone had not proved successful session establishment.
72
+
73
+ `Request` sends unit requests through `comm/sw/unit.php` with authentication and
74
+ version parameters. These parameters are secret-bearing: do not retain full URLs.
75
+ Targets include:
76
+
77
+ | Target | Purpose |
78
+ | --- | --- |
79
+ | `lang/userCtrl.xml`, `_async=1` | Layout and declarative discovery |
80
+ | `config/texts.xml` | User-defined labels |
81
+ | `config/xml.xml` | Register snapshot |
82
+ | `config/xml.cgi`, `_w=1` | Command request |
83
+
84
+ No cloud application ACK shape has been proven. A successful HTTP response is
85
+ transport success only, including on an unconfirmed write. The client never
86
+ automatically retries a write whose outcome may already be unknown.
87
+
88
+ Login's default and maximum total limit is 300 seconds; polling is one second.
89
+ Every HTTP operation has a separate 60-second total limit, including connection,
90
+ write and response read. The outer login timer can stop a request earlier.
91
+ Default connect/read waits are 10/60 seconds; shorter overrides are supported.
92
+ Faraday's stock Net::HTTP adapter is the sole transport and disables retries.
93
+ Standard-library `Timeout.timeout` wrappers use explicit `StandardError`-derived
94
+ exception classes to preserve Net::HTTP's TLS-connect cleanup. No custom socket,
95
+ resolver or transport plugin is needed. Verification keeps its own aggregate
96
+ 30-second default limit, including lazy configuration discovery and read-back.
97
+ The earlier 360-second audit timings above predate this simplification.
98
+
99
+ ## Snapshot and raw register interpretation
100
+
101
+ Snapshots require strict XML with `/RD5WEB/RD5`, no DTD and a valid root `t`
102
+ timestamp: a four-digit year and one- or two-digit month/day/hour/minute/second
103
+ fields (`YYYY-M-D H:M:S` or zero-padded equivalents), allowing surrounding
104
+ whitespace. Fields are normalized before strict calendar/timezone validation.
105
+ Registers are `/RD5WEB/RD5/*/O` elements with
106
+ `I` identifiers and decimal `V` values in 0–65535. Last occurrence wins for a
107
+ duplicate identifier. Missing required mapped readings, malformed numbers and
108
+ unexpected XML fail explicitly; no partial snapshot is published.
109
+
110
+ `SensorParser#raw_values` preserves unsigned integers. Temperature projection
111
+ alone applies signed 16-bit conversion then tenths-of-degree scaling: 65535 is
112
+ −0.1 °C, 65036 is −50.0 °C. A high configuration value stays unsigned.
113
+ `RD5Configuration` consumes the same raw snapshot without changing service
114
+ configuration. `Unit` checks freshness at every getter/serialization, publishes
115
+ atomically and invalidates on failed refresh or any attempted write.
116
+
117
+ ## Declarative discovery: fixture profile confirmed on the live unit
118
+
119
+ `UserCtrl` looks up `/root/layout/body/content/i` by title, rather than treating
120
+ fixture IDs as universal:
121
+
122
+ | Title | Discovery |
123
+ | --- | --- |
124
+ | `$outdoorTemp` | `id` for outdoor temperature |
125
+ | `$requiredTemp` | `id` for requested temperature, both public alias names |
126
+ | `$inletTemp` | Optional `id` and recognized `displayval` logic |
127
+ | `$currentPower` | `id` for current power; `onchange` for input/control fields |
128
+ | `$currentMode` | `id` for current mode; `displayval` reference for user-input selector; `onchange` for input/control fields |
129
+
130
+ `Mode` and `ModeText` option lists define numeric-string-keyed labels; translated
131
+ labels never replace numeric command IDs. Successful layout/text discovery is
132
+ cached per `UserCtrl` instance. Unknown command scripts raise
133
+ `UnsupportedConfiguration`; downloaded JavaScript is never evaluated.
134
+
135
+ `RD5Command.discover` accepts this **complete** function shape, with whitespace
136
+ between tokens and register IDs matching `H` plus five decimal digits:
137
+
138
+ ```javascript
139
+ function(val) {
140
+ saved=true;
141
+ send2Unit(getUrlPar('INPUT',val)+(values.SWITCH==1?'&'+getUrlPar('SWITCH',2):''));
142
+ }
143
+ ```
144
+
145
+ `INPUT` and `SWITCH` above are notation, not literal accepted IDs. Both `SWITCH`
146
+ references must be identical. Actual older fixture forms are:
147
+
148
+ ```javascript
149
+ function(val) {
150
+ saved=true;
151
+ send2Unit(getUrlPar('H10708',val)+(values.H10700==1?'&'+getUrlPar('H10700',2):''));
152
+ if(val>0 && values.H10715==0) {
153
+ flyDown=false;
154
+ this.nextSibling.nextSibling.onmousedown();
155
+ }
156
+ }
157
+ function(val) {
158
+ saved=true;
159
+ send2Unit(getUrlPar('H10709',val)+(values.H10701==1?'&'+getUrlPar('H10701',2):''));
160
+ }
161
+ ```
162
+
163
+ The power-only UI tail is optional, but if present must match that specific
164
+ recognized form; arbitrary extra statements are unsupported. Input/control IDs
165
+ can differ when the complete supported form matches. The resulting string-keyed
166
+ maps contain `power_input`, `power_switch`, `mode_input`, `mode_switch`.
167
+
168
+ The successful live run discovered outdoor `I10208`, requested temperature
169
+ `H10706`, current power/mode `H10704`/`H10705`, selector `H10712`, power
170
+ input/switch `H10708`/`H10700`, and mode input/switch `H10709`/`H10701`.
171
+ Observed configuration included direct-speed control `H10510=0`, orientation
172
+ `H10501=1`, inlet profile `H11300=0`, `C10508=0`, `I11401=0`, and scheduled
173
+ power/mode control states of 1. These observations establish this unit's profile,
174
+ not universal addresses or behavior for every RD5 firmware.
175
+
176
+ Commands concatenate a discovered holding-register ID and exactly five decimal
177
+ digits (e.g. the older fixture's `H10708` plus 88 becomes `H1070800088`). One
178
+ write request carries the setpoint and, when needed, its control switch. Unlike
179
+ the UI's conditional, library policy treats **both** scheduled 1 and temporary 2
180
+ as temporary-control writes; manual 0 stays manual. Zero power follows the same
181
+ policy. Unknown/missing control state fails before dispatch. Permanent schedule
182
+ disable is outside these setters.
183
+
184
+ ## Temperature profile and heating limitations
185
+
186
+ Supported inlet logic is `RD5Configuration::INLET_SOURCE_RULE`, matched as a complete
187
+ declarative form after whitespace removal:
188
+
189
+ * `H11300=0`, `H10501=1`: use `I10200` only when `H10200>0` and temperature is
190
+ not 126.0 °C (unsigned raw sentinel 1260).
191
+ * `H11300=0`, `H10501=2`: use `I10201` only when `H10201>0` and not sentinel.
192
+ * `H11300=1`: use `I10204` if present.
193
+ * Missing required optional sensor, stopped fan, other configuration or unknown
194
+ inlet script: return `nil`, without inventing a reading.
195
+
196
+ `UserCtrl.data` exports `input_temperature_source` as a JSON-safe string:
197
+ `"rd5_standard"` for the recognized rule, `"unsupported"` otherwise. Applications
198
+ that persist this data must also expose that field on reconstructed `user_ctrl:`
199
+ objects. The field selects device interpretation, not UI presentation; M1/M2/direct
200
+ selection continues to use the current raw snapshot inside the gem.
201
+
202
+ Legacy custom objects without `input_temperature_source` (or with nil metadata)
203
+ can supply a direct inlet mapping; callers own its correctness. Explicit unknown
204
+ metadata keeps the optional reading unavailable. Refresh previously cached metadata
205
+ on upgrade rather than interpreting the new map as a legacy direct source.
206
+ Outdoor mapping always comes
207
+ from `UserCtrl`: old fixture uses `I10208`, whereas PDF page 11 calls `I10211`
208
+ T-ODA. No blanket substitution is justified.
209
+
210
+ PDF `H10706` means requested temperature, not measured preheater temperature.
211
+ `required_temperature` and retained `preheat_temperature` reader/hash key expose
212
+ that same reading. Legacy `preheating` ORs `C10200`, `C10202`, `C10215`, `C10217`
213
+ when equal to 1. PDF descriptions include water-heater pump/heat-pump signals;
214
+ this aggregate does not establish dedicated preheater operation. No replacement
215
+ mapping has been fabricated.
216
+
217
+ ## Requested versus effective values and verification
218
+
219
+ The PDF lists power/mode read fields `H10704`/`H10705` and write inputs
220
+ `H10708`/`H10709`. Older layout fixtures separately label `idw=H10714`/`H10715`
221
+ as requested power/mode while `id` fields are current readings. This difference
222
+ is unresolved for current firmware. Neither pair is automatically chosen as a
223
+ proven cloud requested-value read-back profile.
224
+
225
+ Verified `Unit#set_power` / `set_mode` default to `verify: true`, timeout 30
226
+ seconds and polling every five seconds. Without an explicit proven mapping they
227
+ raise `UnsupportedConfiguration` **before writing**. Trusted callers may provide
228
+ `Unit.new(..., verification_registers: mapping)` after independently establishing
229
+ semantics for the exact unit/profile. Mapping is a nonempty Hash with only symbol
230
+ keys `:power` and/or `:mode`, each mapped to a String matching `\AH[0-9]{5}\z`.
231
+ Validation checks syntax, not truth of the caller's profile.
232
+
233
+ Verification loads a fresh baseline containing the mapped raw register, builds
234
+ and sends one command, and polls fresh snapshots until the timestamp is strictly
235
+ newer than baseline and the raw requested value equals the normalized command.
236
+ A stale matching value cannot confirm. Budget includes all baseline/discovery,
237
+ HTTP, dispatch and sleep work. Baseline deadline failure raises
238
+ `UnitConnectionTimeout`; verification timeout after dispatch raises
239
+ `WriteNotConfirmed` with unknown outcome. Confirmation would establish a requested
240
+ setpoint only, not effective operation or physical airflow. No current live ACK
241
+ or read-back profile has been captured.
242
+
243
+ ## Remaining integration acceptance
244
+
245
+ * Establish requested-value mappings and any application ACK from actual evidence.
246
+ * Only with separate agreement, test one ordinary write and restore both original
247
+ setpoint and original manual/scheduled/temporary control state. Do not alter
248
+ service configuration.
249
+
250
+ These remain hardware/protocol acceptance items, independent of fixture-suite
251
+ success. Temperature/zone setters, alarms, flow telemetry, external temperature
252
+ injection and Modbus transport remain deferred. External temperature injection's
253
+ 90-second refresh rule must not be applied to ordinary power/mode requests.