atrea_control 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,238 @@
1
+ # RD5 Reliability Implementation Plan
2
+
3
+ > **For agentic workers:** Runtime Tasks 1–7 and Task 8 documentation/CI are implemented and reviewed. Independent checks and packaging verification passed. Live login/reads were subsequently verified after correcting the synchronous handshake. Hardware-dependent write acceptance remains open below. Future runtime changes should retain the failing-regression-first workflow used here.
4
+
5
+ **Goal:** Make the existing RD5 cloud client reliable for reading snapshots and writing power/mode requests, with configuration-aware validation and meaningful errors.
6
+
7
+ **Architecture:** Keep `Login` for portal/unit sessions, `Request` for cloud HTTP, `UserCtrl` for configuration discovery, `SensorParser` for decoding/encoding, and `Unit` for validated snapshots and commands. Add one small RD5 configuration policy object rather than putting all register rules into `Unit`. Keep cloud mappings tied to firmware/configuration evidence.
8
+
9
+ **Tech Stack:** Ruby, Faraday with its stock Net::HTTP adapter, standard-library `Timeout`, Nokogiri, I18n, RSpec, WebMock, RuboCop; TZInfo `~> 2.0` for explicit IANA time zones. User-requested simplification removed the extra HTTP transport and all custom socket/resolver machinery. Login is capped at 300 seconds total; each HTTP operation at 60 seconds; verified commands retain their aggregate 30-second default.
10
+
11
+ ## Scope and evidence
12
+
13
+ - RD5-only is intentional. Improve the existing cloud transport; aMotion and a Modbus TCP backend are outside this implementation.
14
+ - Source: user-supplied **Communication MODBUS — RD5 and aMotion**, version 04, cover issue `2025_04_25`, especially sections 2.3.1 and 3.1, pages 7–13. The contents page also gives a different issue date; identify the source by version and cover issue.
15
+ - Public document: https://atrea.eu/_files/documents/modbus-comm-amot-rd5-cz-v04_834.pdf . If that URL changes, retain the supplied document's identity and page references.
16
+ - The PDF defines register semantics for Modbus, not cloud HTTP framing, HTTP acknowledgements, authentication, or cloud rate limits. RD5's five-second Modbus batch interval must not be presented as a documented HTTP constraint.
17
+ - Historical baseline before implementation: 22 specs passed; `bundle exec rake` failed on `Style/DirectiveScope` in `spec/atrea_control/duplex/unit_spec.rb`; parser spec was empty. These gaps are now addressed.
18
+ - Verification after timeout simplification and the live-discovered timestamp fix: 675 examples passed and 34 files were lint-clean on Ruby 4.0.7, 3.3.10 and 3.4.9. Independent review approved the simplification, including explicit timeout exception classes and three TLS-connect cleanup regressions. Transport-specific fake socket/resolver suites were replaced by smaller standard-timeout tests. CI's floating Ruby 3/4 entries were not run as separate environments beyond these installed versions.
19
+ - Follow-up live audit on 2026-10-25: after production-client shutdown, the old handshake still timed out despite a recent unit contact. Comparing current portal source identified missing `Sync=1` on the unit-login POST. Adding it obtained SID and valid readings after about 127 seconds; the corrected public API then logged in in 5.86 seconds and passed configuration/values/getters/JSON plus three refreshes. A further run passed command construction without dispatch and controlled-clock expiry/recovery using a live snapshot. Package `3001022` matches `CONTROL_VERSION=003001022`. Live writes, ACK and requested-value read-back semantics remain unverified. No credentials, names, unit IDs or SID values retained.
20
+ - Faraday-only follow-up: login completed in 1.45 seconds, with valid values and refresh using stock Net::HTTP for both clients; no HTTPX loaded and no control writes dispatched. An earlier read exposed valid unpadded timestamp components (`17:7:32`); normalize those fields before existing strict calendar/timezone validation. Regression coverage includes the actual format, other single-digit fields and invalid dates/times.
21
+ - Preserve existing getters, string-keyed configuration maps and symbol-keyed `Unit#values`. Document deliberate behavior changes: invalid setter inputs become errors; stale getters stop returning data; unavailable optional temperatures become `nil`; manual control no longer gets unconditionally switched to temporary control.
22
+
23
+ ## What the PDF changes in the review
24
+
25
+ | Topic | RD5 evidence | Implementation consequence |
26
+ | --- | --- | --- |
27
+ | Power | `H10704` read, `H10708` write; `H10510` selects interpretation | Do not validate every unit as a simple 0–100 percentage device. |
28
+ | Modes | `H10705` read, `H10709` write, values 0–7 with equipment/season conditions | Validate against discovered modes and supported configuration. Do not confuse a user-input label with a requested numeric mode. |
29
+ | Scheduled control | `H10700` / `H10701`: 0 manual, 1 scheduled, 2 temporary | Add temporary override only for scheduled/temporary control, including power zero. Preserve manual control. |
30
+ | Temperature | `H10706` is requested temperature, 100–400 represents 10–40 °C | Historical `preheat_temperature` is not a measured preheater temperature. Add an accurately named alias, preserving the old key. |
31
+ | Temperature addresses | PDF calls `I10211` T-ODA; old portal fixtures use `I10208` | No blanket address substitution. Verify firmware/profile and preserve configuration-derived mappings. |
32
+ | Heating state | `C10200` is a water-heater pump, `C10215` a heat-pump heating signal | Existing OR of heating registers does not prove preheater operation. Document legacy meaning; do not invent a new preheater mapping. |
33
+ | Confirmation | Examples read the corresponding request value back after writing | Add explicit, bounded read-back verification; HTTP 200 alone is insufficient. |
34
+
35
+ ## Design decisions
36
+
37
+ 1. **Repair the cloud client in place (selected).** Small coherent changes, existing entrypoints retained. A generic multi-transport rewrite adds unnecessary scope; replacing cloud with Modbus would require LAN connectivity and unit setup that are not prerequisites today.
38
+ 2. Keep `AtreaControl::Error` as the common base. Add `AuthenticationError`, `UnitConnectionTimeout`, `ProtocolError`, `TransportError`, `UnsupportedConfiguration` and `WriteNotConfirmed` subclasses. Keep `SessionExpired` for missing/stale snapshot timestamps as the existing recovery contract, while documenting that this is a freshness error rather than proof of failed authentication.
39
+ 3. Make parsing, validity, command construction and transport distinct. A parser/configuration failure must never send a write or publish a partial snapshot.
40
+ 4. No automatic write retries. A connection timeout can occur after the unit accepted a command. Verification timeout means outcome unknown, not proof of rejection.
41
+ 5. Use runtime register data and declarative configuration; never evaluate downloaded JavaScript or Ruby. Existing locale-regeneration `eval` must be removed or replaced with a non-executing parser before that utility is used.
42
+
43
+ ## Task 1 — Credential-safe diagnostics and transport errors
44
+
45
+ **Files:** `lib/atrea_control.rb`, `lib/atrea_control/logger.rb`, `lib/atrea_control/duplex/login.rb`, `lib/atrea_control/duplex/request.rb`; new `spec/atrea_control/duplex/request_spec.rb`, `spec/atrea_control/logger_spec.rb`; extend `spec/atrea_control/duplex/login_spec.rb`.
46
+
47
+ - [x] Add specs capturing a test logger during both successful and failed login. Use synthetic values `secret-password`, `secret-cookie`, `secret-sid`; assert none appears in captured output. Do not merely assert that DEBUG is disabled.
48
+ - [x] Remove SID interpolation and full response-body logging. Log stage, HTTP status, elapsed time and poll counts without user IDs, cookies, passwords, auth-bearing URLs or raw error bodies.
49
+ - [x] Add HTTP specs for successful responses, 401/403, 429/500, connect timeout and read timeout. Test the intentional login POST redirect separately from unit requests; do not globally reject the expected login 302.
50
+ - [x] Define the error subclasses above. For unit requests, map authentication status failures to `SessionExpired`, other failed HTTP responses/network errors to `TransportError`. For portal login, use `AuthenticationError` for positively identified rejected credentials. Timeouts specifically use `UnitConnectionTimeout < TransportError`.
51
+ - [x] Configure Faraday/Net::HTTP with 10-second connect and 60-second read defaults plus a 60-second total HTTP timeout; expose shorter overrides. Explicit standard-library timeout exceptions preserve socket cleanup. Error messages identify stage/status, not credentials. No automatic command retries.
52
+ - [x] Add regressions showing setters do not silently accept HTTP 403 or 500. A successful HTTP response only establishes transport success; Task 6 handles application/read-back confirmation.
53
+
54
+ Example request regression, using the existing `Request#call` API:
55
+
56
+ ```ruby
57
+ request = AtreaControl::Duplex::Request.new(user_id: "1", unit_id: "unit", sid: "secret-sid")
58
+ stub_request(:get, %r{control\.atrea\.eu/+comm/sw/unit\.php})
59
+ .to_return(status: 403, body: "denied")
60
+ expect { request.call(_t: "config/xml.cgi", _w: 1) }
61
+ .to raise_error(AtreaControl::SessionExpired)
62
+ ```
63
+
64
+ **Verify:** `bundle exec rspec spec/atrea_control/duplex/request_spec.rb spec/atrea_control/duplex/login_spec.rb spec/atrea_control/logger_spec.rb`.
65
+
66
+ ## Task 2 — Parse validated snapshots, not incidental XML
67
+
68
+ **Files:** `lib/atrea_control/sensor_parser.rb`, `lib/atrea_control/duplex/user_ctrl.rb`, `lib/atrea_control.rb`; `spec/atrea_control/sensor_parser_spec.rb`, `spec/atrea_control/duplex/user_ctrl_spec.rb`; fixtures under `spec/fixtures/files/`.
69
+
70
+ - [x] Populate the empty parser spec. Derive XML variants in examples with Nokogiri rather than maintaining multiple copies of the large `unit.xml` fixture.
71
+ - [x] Test missing `RD5WEB`, missing/blank/invalid `t`, absent required registers, missing `V`, non-numeric `V`, malformed XML and an HTML login response.
72
+ - [x] Validate XML structure and timestamp before sensor extraction. Missing timestamp raises `SessionExpired`; an invalid timestamp or malformed/non-unit payload raises `ProtocolError`. Never leak accidental `NoMethodError`/`ArgumentError` or silently convert malformed numbers to zero.
73
+ - [x] Introduce `SensorParser#raw_values(xml)`: validated register ID → unsigned integer map. Keep existing `parse`/`values` entrypoints as projections for compatibility; decode signed temperature values only when applying the temperature field definition, not indiscriminately to configuration registers.
74
+ - [x] Test signed temperature boundaries: 65535 → -0.1 °C, 65036 → -50.0 °C, 0 → 0.0 °C, 1300 → 130.0 °C. Test a non-temperature raw register above 32767 stays unsigned.
75
+ - [x] Preserve documented last-occurrence behavior for duplicate register IDs and test it explicitly.
76
+ - [x] Memoize successfully parsed `UserCtrl#user_ctrl` XML per instance. Test one HTTP fetch for configuration discovery and no cached invalid XML; recreating the object refreshes configuration.
77
+ - [x] Remove the unused private `update_locales_files!` executable-evaluation utility; keep checked-in locale files.
78
+
79
+ **Verify:** `bundle exec rspec spec/atrea_control/sensor_parser_spec.rb spec/atrea_control/duplex/user_ctrl_spec.rb`.
80
+
81
+ ## Task 3 — Atomic cache, consistent getters and explicit time zones
82
+
83
+ **Files:** `lib/atrea_control/duplex/unit.rb`, `lib/atrea_control/sensor_parser.rb`, `atrea_control.gemspec`, `Gemfile.lock`; `spec/atrea_control/duplex/unit_spec.rb`, `spec/atrea_control/sensor_parser_spec.rb`.
84
+
85
+ - [x] Add end-to-end Unit specs with the real parser and a controlled clock. Locally override the existing global `valid?` stub with `and_call_original` for these examples.
86
+ - [x] Cover an initially stale response; a valid snapshot that ages; a failed refresh after a valid snapshot; and false/zero values. Exercise `values`, `parsed`, `power`, `mode`, temperature readers, `preheating?`, `timestamp`, `as_json` and `to_json`.
87
+ - [x] Store one validated snapshot. Parse and validate before publication. If refresh fails, invalidate publicly accessible data; no accessor may expose the old snapshot as current. No last-known snapshot is exposed as current.
88
+ - [x] Implement validity against snapshot state directly, avoiding `timestamp → values → valid? → timestamp` recursion. A cached getter checks age without an implicit network refresh; `refresh!` remains the explicit fetch operation. Initial getter access may fetch once, preserving current convenience.
89
+ - [x] Preserve the strict 15-minute boundary: timestamp equal to `now - 900` is invalid. Reject timestamps more than 60 seconds in the future; allow up to 60 seconds of clock skew. Test these boundaries with a frozen/stubbed clock.
90
+ - [x] Add optional `time_zone:` to `Unit` and parser initialization. Use TZInfo `~> 2.0` for an explicit IANA zone; `nil` retains process-local interpretation for compatibility. Do not mutate process-global `ENV["TZ"]` in runtime code or hardcode every device to Czech time.
91
+ - [x] Test explicit `Europe/Prague` timestamps in winter and summer while the process uses UTC. Ambiguous/nonexistent DST-local times raise `ProtocolError`, rather than silently choosing a different instant. Document that an explicit zone is required to remove deployment-zone ambiguity.
92
+
93
+ **Regression acceptance:** after `unit.values` rejects an old XML snapshot, `unit.power` must also raise `SessionExpired`, rather than return the fixture's `88.0`.
94
+
95
+ **Verify:** `bundle exec rspec spec/atrea_control/duplex/unit_spec.rb spec/atrea_control/sensor_parser_spec.rb`.
96
+
97
+ ## Task 4 — RD5 configuration and truthful sensor semantics
98
+
99
+ **Files:** new `lib/atrea_control/duplex/rd5_configuration.rb`, `spec/atrea_control/duplex/rd5_configuration_spec.rb`; update `lib/atrea_control/duplex.rb`, `lib/atrea_control/duplex/user_ctrl.rb`, `lib/atrea_control/sensor_parser.rb`, `lib/atrea_control/duplex/unit.rb`, `spec/support/user_ctrl.rb` and related specs.
100
+
101
+ - [x] Add an autoloaded `RD5Configuration` policy object consuming the decoded raw register map (accepted class-name adjustment from the original `Rd5Configuration` design). It reads configuration only; it never writes service registers.
102
+ - [x] Capture and test fixture-derived `H10510`, `H10501`, `H11300`, `H11700`, `C10508`, `C10509`, `I11401` and the power/mode schedule-control registers. Missing configuration needed for a command raises `UnsupportedConfiguration`; do not guess a safe default. Follow-up live discovery confirmed this unit's available subset and successful direct-speed power/mode command construction without dispatch.
103
+ - [x] Replace the first-match inlet-temperature regex with an explicit resolver for the supported `userCtrl` profile. For the existing fixture: `H11300=0` selects M1/M2 temperature using `H10501`, fan state and the 126.0 sentinel; `H11300=1` uses `I10204`; unsupported/unavailable cases return `nil` for that optional temperature.
104
+ - [x] Test every branch from `userCtrl.xml:448–463`, including stopped fans, missing optional temperature registers and sentinel values. Do not use `eval` to reproduce JavaScript.
105
+ - [x] Keep outdoor-temperature mapping derived from `UserCtrl`. Add a synthetic configuration with a different outdoor ID and assert the parser follows it. PDF page 11 alone does not authorize replacing fixture `I10208` with `I10211`.
106
+ - [x] Add `required_temperature` as an alias for the value historically exposed as `preheat_temperature`; keep the old reader and hash key. Explain the alias in README/CHANGELOG. Adding a hash key is additive but must be noted for consumers matching exact key sets.
107
+ - [x] Keep the legacy `preheating` behavior documented as a heating-related aggregate until a dedicated RD5 preheater mapping is captured. PDF page 11 is insufficient evidence to relabel water-pump/heat-pump status as preheater operation. No fabricated replacement register.
108
+
109
+ **Verify:** `bundle exec rspec spec/atrea_control/duplex/rd5_configuration_spec.rb spec/atrea_control/duplex/user_ctrl_spec.rb spec/atrea_control/sensor_parser_spec.rb spec/atrea_control/duplex/unit_spec.rb`.
110
+
111
+ ## Task 5 — Correctly encoded, configuration-aware commands
112
+
113
+ **Files:** `lib/atrea_control/sensor_parser.rb`, `lib/atrea_control/duplex/rd5_configuration.rb`, `lib/atrea_control/duplex/unit.rb`, `lib/atrea_control/duplex/user_ctrl.rb`; parser/configuration/Unit specs.
114
+
115
+ - [x] Specify accepted setter types: integer, finite integral Float, or an ASCII decimal-digit string. Normalize `88`, `88.0`, `"88"` to integer 88. Reject `nil`, booleans, fractional values, infinities, NaN, negative numbers and strings such as `"88.0"` before HTTP. Never silently truncate or clamp.
116
+ - [x] The low-level encoder checks a valid discovered holding-register ID and a nonnegative 16-bit integer, then emits exactly five decimal digits after the register ID. Command-specific range checks belong in `RD5Configuration`.
117
+ - [x] Test exact encoder output, not only the destination endpoint. Low-level `SensorParser#input` intentionally requires an Integer; normalization of accepted Float/String inputs occurs at the Unit setter boundary:
118
+
119
+ ```ruby
120
+ expect(parser.input("H10708", 88)).to eq("H1070800088")
121
+ expect { parser.input("H10708", 88.0) }.to raise_error(ArgumentError)
122
+ expect { parser.input("H10708", nil) }.to raise_error(ArgumentError)
123
+ expect { parser.input("H10708", 88.5) }.to raise_error(ArgumentError)
124
+ ```
125
+
126
+ - [x] Implement the PDF page 9 power matrix:
127
+
128
+ | `H10510` | Allowed command values |
129
+ | --- | --- |
130
+ | 0, direct speed | 0 or integers 12–100 |
131
+ | 1, constant flow | integers 0–100 |
132
+ | 2, constant pressure | 0, 1, 2 |
133
+ | 3, external input | no power writes; raise `UnsupportedConfiguration` |
134
+ | 4, multiport | 0, 10–12, 20–22, 30–38 |
135
+ | missing/unknown | raise `UnsupportedConfiguration` |
136
+
137
+ - [x] Validate modes against discovered choices and PDF page 10 prerequisites: 0/2 unrestricted; 1/6/7 need `C10508=0`; 3 also needs `C10509=1`; 4 also needs `H11700=1`; 5 needs `I11401=1`. Unknown prerequisites fail explicitly. Preserve numeric mode IDs independently of translated labels.
138
+ - [x] Derive both the power input and its control-switch register from the unit configuration, as already done for mode. Accept only complete matched/tested command forms; remove the unconditional literal `H1070000002` shortcut.
139
+ - [x] Construct one command request: manual control 0 → setpoint only; scheduled/temporary 1/2 → setpoint plus switch=2. Test both power/mode and all three control states. Crucially, scheduled `power=0` still includes `H10700=2`.
140
+ - [x] Never force `H10700..H10703=0` merely because the PDF recommends manual operation for BMS. Changing permanent schedule policy is a separate user action, outside these setters.
141
+ - [x] Assert invalid inputs/configuration generate no write request. Invalidate on every write attempt, including failures, so subsequent reads cannot report the pre-write cache as current.
142
+
143
+ **Verify:** `bundle exec rspec spec/atrea_control/sensor_parser_spec.rb spec/atrea_control/duplex/rd5_configuration_spec.rb spec/atrea_control/duplex/unit_spec.rb`.
144
+
145
+ ## Task 6 — Explicit confirmation without claiming actual airflow
146
+
147
+ **Files:** `lib/atrea_control/duplex/unit.rb`, `lib/atrea_control/duplex/request.rb`, `lib/atrea_control/duplex/user_ctrl.rb`; Unit/request specs; sanitized response fixtures from Task 8.
148
+
149
+ - [x] Add explicit `set_power(value, verify: true, timeout: 30, poll_interval: 5)` and `set_mode` methods returning the confirmed normalized requested value when verification succeeds; `verify: false` returns the unconfirmed HTTP response. Existing `power=`/`mode=` delegate with `verify: false` to retain non-polling setter behavior, but still perform input and HTTP error checks. Document that Ruby assignment expressions themselves evaluate to the assigned value, not a confirmation result.
150
+ - [ ] Use captured cloud responses to identify any documented/observed acknowledgement. Do not invent an `<ok>` shape or treat arbitrary HTTP 200 HTML as success. If no application acknowledgement exists, only read-back can claim confirmation.
151
+ - [x] Implement fresh, newer-than-baseline requested-value verification with numeric comparisons, not translated mode/actual fan speed. Accepted adjustment: `Unit.new(verification_registers: ...)` takes a nonempty Hash with only symbol keys `:power`/`:mode` and `H` plus five-digit String values. Caller must independently prove semantics. No PDF or fixture address is trusted by default; requested-value mapping and live write acceptance remain deferred despite successful live reads.
152
+ - [x] If a cloud profile lacks a proven requested-value mapping, verified methods raise `UnsupportedConfiguration` before writing. Transport-only setters remain documented as unconfirmed.
153
+ - [x] Test delayed propagation, matching stale cache, external-input override, rejected requests and timeout using a fake monotonic clock; no real sleeping. Polling never resends the write. A stale matching snapshot must not satisfy verification.
154
+ - [x] On verification timeout, raise `WriteNotConfirmed` with a redacted message indicating unknown outcome. Document that setpoint confirmation does not prove physical airflow or override safety functions. One standard `Timeout` wrapper covers baseline, lazy configuration discovery, dispatch and read-back; pre-dispatch expiry uses `UnitConnectionTimeout`. No deadline callbacks are threaded through Unit/UserCtrl/Request.
155
+
156
+ **Verify:** `bundle exec rspec spec/atrea_control/duplex/unit_spec.rb spec/atrea_control/duplex/request_spec.rb`.
157
+
158
+ ## Task 7 — Bounded login, explicit unit choice and version evidence
159
+
160
+ **Files:** `lib/atrea_control/duplex/login.rb`, `lib/atrea_control/duplex/request.rb`, `lib/atrea_control/duplex/unit.rb`; login/request specs and sanitized fixtures.
161
+
162
+ - [x] Validate required cookies, nonempty client ID, unit records, `ident`, login/sended nodes and nonempty nonzero SID. Rejected credentials use `AuthenticationError`; invalid response shapes use `ProtocolError`; exhausted unit connection wait uses `UnitConnectionTimeout`.
163
+ - [x] Add optional `unit_id:` selection by unit `ident` to `Login.user_tokens` and `Login.new`. Preserve first-unit behavior when omitted. Use listed unit records to resolve ownership; test no matches and multiple units without leaking identifiers into logs.
164
+ - [x] Replace `time.times` with a monotonic polling deadline and `timeout: 300`, `poll_interval: 1`. Login's entire flow is wrapped in standard `Timeout`; larger-than-300-second budgets are rejected. Each HTTP operation is independently capped at 60 seconds, and the outer flow timer also applies. Server ETA does not control iteration or termination. Test last-permitted-poll success, perpetual zero SID and blocked HTTP using fake clocks/short standard timeouts.
165
+ - [x] Interpret server ETA as a wait estimate, not proof of incorrect credentials or an offline unit. Test successful account login followed by an unavailable unit as a separate failure stage.
166
+ - [x] Compare advertised package with `CONTROL_VERSION` using successful unit-record discovery: `3001022` normalizes to `003001022`. Retain the constant; no dynamic `version:` API was added. This is package evidence only, not successful SID/configuration/read-back compatibility.
167
+ - [x] Do not reinstate `run_rd5_app` based only on its old comment; request remains absent without evidence.
168
+ - [x] Compare the current browser application's source request sequence. Its `sendSync` helper adds `Sync=1`; RD5 `setUnit` uses it for `unitLogin`. Add that parameter and a regression requiring it before SID establishment. Public-API live success confirms the fix; no application-launch request was needed or reinstated. No GUI browser capture was performed.
169
+
170
+ **Verify:** `bundle exec rspec spec/atrea_control/duplex/login_spec.rb spec/atrea_control/duplex/request_spec.rb`.
171
+
172
+ ## Task 8 — Integration evidence, documentation and release readiness
173
+
174
+ **Files:** `README.md`, `CHANGELOG.md`, `.github/workflows/main.yml`, `spec/atrea_control/duplex/unit_spec.rb`; new `docs/rd5-cloud-protocol.md` and sanitized fixtures.
175
+
176
+ - [x] With an available RD5 unit, verify account login, SID acquisition, configuration discovery, `values`, three `refresh!` calls, getters/JSON, unit-name reader and a getter after controlled cache expiry/recovery. Record sanitized package/mapping/configuration metadata, without credentials, names, IDs or SID.
177
+ - [x] Diagnose the failed handshake against current portal source and confirm the minimal `Sync=1` correction with live reads. A GUI browser replay was not required; keep fixture suite independent of hardware.
178
+ - [x] Record declarative profiles and the follow-up live evidence in `docs/rd5-cloud-protocol.md`: discovery forms, unsigned raw registers, inlet resolver, request/effective distinction, schedule policy and temperature/legacy heating limitations. Live reads are confirmed for this profile; ACK/read-back/write acceptance remains unverified. No invented preheater mapping.
179
+ - [ ] For a separately agreed live write test, record original request values and schedule-control states; change one ordinary power/mode setting, verify via the portal and fresh snapshot, then restore the originals and confirm restoration. Restoring the numeric setpoint alone is insufficient if the command also changed manual/schedule/temporary state. Do not change service configuration.
180
+ - [x] Resolve the existing `Style/DirectiveScope` offense (already fixed by earlier runtime work; prior full lint clean, no further spec edit needed). Add a separate RuboCop CI job while retaining the Ruby test matrix and existing action versions.
181
+ - [x] Update README with implemented APIs, explicit zone handling, errors, power domains, aliases, schedule behavior and verified/unconfirmed writes. Include migration notes for `nil` rejection, stale getters and added hash key; update plan statuses.
182
+ - [x] Require a major upgrade from 3.2.x because rejected historical inputs, getter errors and schedule policy change behavior. Preserve the working tree's version choice; publishing requires a separate request.
183
+ - [x] Run `bundle exec rake` on Ruby 4.0.7; full RSpec and RuboCop on Ruby 3.3.10 and 3.4.9. After timeout simplification and the timestamp fix, all three environments: 675 examples passed, 34 files lint-clean. Only these installed versions were tested, not every patch-level Ruby installation or a remote CI run.
184
+ - [x] Verify initial packaging through a temporary `GIT_INDEX_FILE` containing intended runtime/docs files, leaving the real staging index untouched. `bundle exec rake build` produced `pkg/atrea_control-3.2.1.gem`; all 20 runtime/locale files then matched source, including new helpers; no specs or `.env` included. Unpacked gem loaded entrypoint, classes, dependencies and locales outside workspace. Original root archive was backed up/restored byte-for-byte. Sources were subsequently staged for the separately requested gem PR; normal builds must include all new runtime files. No release command run.
185
+ - [x] Rebuild after the `Sync=1` fix and verify the unpacked gem outside the workspace against the live RD5 portal: login in 5.9 seconds, valid eight-field snapshot and successful refresh. No control writes dispatched.
186
+
187
+ **Task 8 verification ownership:** documentation worker validated workflow YAML,
188
+ compiled README Ruby snippets without running them, cross-checked source APIs and
189
+ ran `git diff --check`. Parent completed independent checks on three Ruby versions,
190
+ temporary-index build/content/load inspection and whole-code review. Implementation
191
+ verification preceded the separately requested PR/staging step; no publishing was
192
+ performed. Read-only live login was
193
+ parent-owned; no live device-control writes occurred.
194
+
195
+ **Documentation verification evidence:** Psych safe-load validated workflow
196
+ structure, including YAML 1.1's possible boolean `on` key, retained spec matrix
197
+ and separate lint command; all four README Ruby snippets compiled without
198
+ execution. Focused `bundle exec rubocop --only Style/DirectiveScope
199
+ spec/atrea_control/duplex/unit_spec.rb` inspected one file with no offenses.
200
+ Source APIs/errors and original fixture forms were cross-checked locally.
201
+
202
+ ## Follow-up: persisted inlet-source metadata
203
+
204
+ - Replace the unreleased `input_temperature_profile` accessor with JSON-safe
205
+ `input_temperature_source` (`"rd5_standard"` / `"unsupported"`) and include it
206
+ in `UserCtrl.data`. Keep conditional sensor interpretation entirely in the gem.
207
+ - Verify discovery → JSON → persisted-fields object → Unit/parser, including
208
+ distinct M1/M2/direct readings and unavailable/unsupported cases.
209
+ - In `home_control`, expose the metadata through its existing JSON store, refresh
210
+ older caches on next control access, and discard cached Unit on login refresh.
211
+ No schema migration or hardware-selection rules in the UI are needed.
212
+ - Update the consumer to `atrea_control ~> 4.0`; validate with a locally built and
213
+ installed 4.0.0 gem. Publish/provide that gem before deploying the consumer.
214
+ Render unavailable readings as an em dash, omit them from InfluxDB fields, and
215
+ preserve valid zero-degree readings.
216
+ - Verification: gem 689 examples passed on Ruby 4.0.7, 3.3.10 and 3.4.9; full gem
217
+ lint clean across 35 files. Home 44 Rails examples and three Node rendering
218
+ tests passed; all seven changed Ruby files are lint-clean. Full Home lint still
219
+ reports 15 existing offenses in untouched files. No cloud/control calls made
220
+ during this integration work; staging and commits were deferred until the
221
+ separately requested gem PR.
222
+
223
+ ## Execution order and acceptance
224
+
225
+ Tasks 1 → 2 → 3 establish trustworthy errors and snapshots. Task 4 → 5 makes writes configuration-aware. Task 7 follows transport work. Task 6 implementation is complete with explicit caller-proven `verification_registers:`; default verified operations deliberately fail closed. Current live login/read acceptance passed after the synchronous-handshake fix; requested-value mapping, ACK and actual control writes still require separate evidence.
226
+
227
+ Completion requires:
228
+
229
+ - No secrets in successful or failed login logs.
230
+ - No invalid/old snapshot accessible through current-value readers or serializers.
231
+ - Parser errors belong to the library's error hierarchy, with no fabricated zero readings.
232
+ - Exact five-digit command encoding and all five RD5 power configurations tested.
233
+ - Manual schedule policy preserved; temporary overrides correct for zero and nonzero power.
234
+ - Request acceptance distinguished from confirmed setpoint and from physical operation.
235
+ - Current RD5 end-to-end read verified, or the remaining hardware/protocol blocker stated explicitly.
236
+ - Full specs and lint pass; newly introduced runtime files included in the build.
237
+
238
+ Future extensions such as temperature/zone setters, alarm APIs, flow telemetry, external temperature injection and Modbus transport are separate work. In particular, external temperature injection's 90-second refresh requirement must not be confused with ordinary power/mode writes, which the PDF says need not be repeated periodically.
@@ -1,158 +1,143 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "nokogiri"
3
4
  require "faraday"
4
5
  require "http/cookie"
5
6
  require "faraday-cookie_jar"
6
7
  require "securerandom"
8
+ require "timeout"
9
+ require_relative "login_session"
7
10
 
8
11
  module AtreaControl
12
+ # RD5 portal authentication and direct unit communication.
9
13
  module Duplex
10
- # Process login into RD5 to get `sid` ( auth_token ) for direct API communication
14
+ # Authenticate to RD5 within one monotonic budget, returning direct API tokens.
11
15
  class Login
12
16
  include AtreaControl::Logger
13
17
 
14
- # @return [Hash] - user_id, unit_id, sid
15
- def self.user_tokens(login:, password:)
16
- instance = new(login: login, password: password)
17
- instance.call
18
+ HTTP_TIMEOUT = 60
19
+
20
+ def self.user_tokens(login:, password:, **)
21
+ new(login: login, password: password, **).call
18
22
  end
19
23
 
20
- # @param [String] login
21
- # @param [String] password
22
- def initialize(login:, password:)
24
+ # Explicit keywords keep authentication, selection and independent budgets discoverable.
25
+ def initialize(login:, password:, unit_id: nil, timeout: 300, poll_interval: 1, open_timeout: 10, read_timeout: 60) # rubocop:disable Metrics/ParameterLists
26
+ { timeout:, poll_interval:, open_timeout:, read_timeout: }.each do |key, value|
27
+ raise ArgumentError, "#{key} must be positive and finite" unless value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
28
+ end
29
+ raise ArgumentError, "timeout must not exceed 300 seconds" if timeout > 300
30
+
23
31
  @login = login
24
32
  @password = password
33
+ @unit_id = unit_id
34
+ @timeout = timeout
35
+ @poll_interval = poll_interval
36
+ @open_timeout = open_timeout
37
+ @read_timeout = read_timeout
25
38
  @jar = HTTP::CookieJar.new
26
39
  end
27
40
 
28
- # Perform login procedure for retrieve `sid` (auth_token)
29
- # @return [Hash] - user_id, unit_id, sid
30
- # @raise [AtreaControl::Error] if login failed
41
+ # @return [Hash] user_id, unit_id (ident), sid
31
42
  def call
32
- @sid = sid
33
- if @sid == "0"
34
- login_url = "#{AtreaControl::Duplex::CONTROL_URI}/apps/rd5Control/handle.php?" \
35
- "action=unitLogin&user=#{user_id}&unit=#{unit_id}&table=userUnits&" \
36
- "idPwd=#{unit[:iid]}&#{SecureRandom.hex(2)}&_ts=#{SecureRandom.hex(4)}"
37
- re_login = connection.post(login_url) do |req|
38
- req.body = { comm: "config/login.cgi?magic=" }
39
- req.headers["Cookie"] = "PHPSESSID=#{php_session_id}"
40
- req.headers["App-name"] = "rd5Control"
41
- end
42
- time = Nokogiri::XML(re_login.body).at_xpath("//sended")["time"].to_i
43
- logger.debug "Login in #{time} seconds..."
44
- time.times do
45
- @sid = sid
46
- break if @sid != "0"
47
-
48
- sleep 1
49
- end
50
- raise AtreaControl::Error, "Login failed" if @sid == "0"
51
-
52
- logger.debug "Login complete !"
53
- else
54
- logger.debug "Login is not necessary ! SID: #{@sid}"
43
+ @started_at = monotonic_time
44
+ @deadline = @started_at + @timeout
45
+ @poll_count = 0
46
+ Timeout.timeout(@timeout, Timeout::Error) do
47
+ authenticate
48
+ tokens = LoginSession.new(unit_id: @unit_id, request: method(:portal_request), wait: method(:wait_for_poll), diagnostic: method(:diagnostic)).call
49
+ remaining_budget
50
+ tokens
55
51
  end
56
- { user_id:, unit_id:, sid: @sid }
52
+ rescue Timeout::Error
53
+ fail_request(AtreaControl::UnitConnectionTimeout, "Portal login deadline exceeded", "deadline_exceeded")
57
54
  end
58
55
 
59
56
  private
60
57
 
61
- # @!group Login steps, order is important
58
+ def authenticate
59
+ @stage = "authentication"
60
+ response = http_request(:post, "#{CONTROL_URI}?action=login", { username: @login, password: @password }, allow_redirect: true)
61
+ fail_request(AtreaControl::AuthenticationError, "Portal authentication rejected", response.status) if Nokogiri::HTML(response.body).at_css("form#loginFrm")
62
62
 
63
- # Retrieve user details from RD5 core.php?action=init
64
- # @return [Hash] - user_id, name
65
- def user
66
- response = connection.get("#{AtreaControl::Duplex::CONTROL_URI}/core/core.php?action=init&_ts=#{SecureRandom.hex(4)}") do |req|
67
- req.headers["Cookie"] = "PHPSESSID=#{php_session_id}"
68
- req.headers["App-name"] = "rd5Control"
69
- end
70
- client = Nokogiri::XML(response.body).at_xpath("//client")
71
- user_id = client["id"]
72
- name = client["name"]
73
- logger.debug "User ID: #{user_id}, User Name: #{name}"
74
-
75
- { user_id:, name: }
63
+ cookies = @jar.cookies(URI.parse(CONTROL_URI))
64
+ @php_session_id = cookies.find { |cookie| cookie.name == "PHPSESSID" }&.value
65
+ LoginXML.value(@php_session_id, "session cookie")
76
66
  end
77
67
 
78
- def user_id
79
- @user_id ||= user[:user_id]
68
+ def portal_request(stage, method, path, params, body = nil)
69
+ @stage = stage
70
+ @poll_count += 1 if stage == "session_query"
71
+ url = "#{CONTROL_URI}#{path}?#{URI.encode_www_form(params.merge(_ts: SecureRandom.hex(4)))}"
72
+ http_request(method, url, body) do |request|
73
+ request.headers["Cookie"] = "PHPSESSID=#{@php_session_id}"
74
+ request.headers["App-name"] = "rd5Control"
75
+ end.body
80
76
  end
81
77
 
82
- # For some reason, this requests must be done before `unit_id` requested
83
- def run_rd5_app
84
- connection.post("#{AtreaControl::Duplex::CONTROL_URI}/core/core.php?Sync=1&action=run&object=app&lng=28&rVer=1&_ts=#{SecureRandom.hex(4)}") do |req|
85
- req.body = { name: "rd5Control", path: "apps/rd5Control/" }
86
- req.headers["Cookie"] = "PHPSESSID=#{php_session_id}"
87
- req.headers["App-name"] = "rd5Control"
88
- end
89
- connection.post("#{AtreaControl::Duplex::CONTROL_URI}/core/core.php?Sync=1&action=load&object=setting&_ts=#{SecureRandom.hex(4)}") do |req|
90
- req.body = { path: "apps/rd5Control" }
91
- req.headers["Cookie"] = "PHPSESSID=#{php_session_id}"
92
- req.headers["App-name"] = "rd5Control"
78
+ def connection
79
+ @connection ||= Faraday.new do |faraday|
80
+ faraday.options.open_timeout = @open_timeout
81
+ faraday.options.timeout = @read_timeout
82
+ faraday.request :url_encoded
83
+ faraday.use :cookie_jar, jar: @jar
84
+ faraday.adapter(:net_http) { |http| http.max_retries = 0 }
93
85
  end
94
86
  end
95
87
 
96
- # Retrieve overview of RD5 unit
97
- # @return [Hash] - unit_number (digit code/ID from list) and iid (unit salt?)
98
- def unit
99
- return @unit if @unit
100
-
101
- # run_rd5_app
102
- response = connection.get("#{AtreaControl::Duplex::CONTROL_URI}/_data/data.php?Sync=1&action=getdata&rH&rE&table=userUnits&ds=rd5&_ts=#{SecureRandom.hex(4)}") do |req|
103
- req.headers["Cookie"] = "PHPSESSID=#{php_session_id}"
104
- req.headers["App-name"] = "rd5Control"
105
- end
106
- item = Nokogiri::XML(response.body).at_xpath("//i")
107
- unit_number = item["unit"]
108
- iid = item["id"]
109
- @unit ||= { unit_number:, iid: }
88
+ def http_request(method, url, payload = nil, allow_redirect: false, &)
89
+ budget = [HTTP_TIMEOUT, remaining_budget].min
90
+ connection.options.open_timeout = [@open_timeout, budget].min
91
+ connection.options.timeout = [@read_timeout, budget].min
92
+ # The outer flow timeout already enforces the remaining login budget.
93
+ response = Timeout.timeout(HTTP_TIMEOUT, Timeout::Error) { connection.public_send(method, url, payload, &) }
94
+ remaining_budget
95
+ diagnostic(@stage, response.status)
96
+ fail_request(AtreaControl::AuthenticationError, "Portal authentication rejected", response.status) if [401, 403].include?(response.status)
97
+
98
+ accepted = (200...300).cover?(response.status) || (allow_redirect && response.status == 302)
99
+ fail_request(AtreaControl::TransportError, "Portal request failed (HTTP #{response.status})", response.status) unless accepted
100
+
101
+ response
102
+ rescue Faraday::Error, Timeout::Error, IOError, SystemCallError, SocketError => e
103
+ transport_failure(e)
110
104
  end
111
105
 
112
- # With `unit_number` from `unit` method, get `unit_id` from RD5 unit records
113
- # @return [String] - unit_id
114
- def unit_id
115
- return @unit_id if @unit_id
106
+ def transport_failure(error)
107
+ remaining_budget
108
+ wrapped = error.wrapped_exception if error.is_a?(Faraday::Error)
109
+ timed_out = [Faraday::TimeoutError, Timeout::Error].any? { |type| error.is_a?(type) || wrapped.is_a?(type) }
110
+ fail_request(AtreaControl::UnitConnectionTimeout, "Portal request timed out", "timeout") if timed_out
116
111
 
117
- response = connection.get("#{AtreaControl::Duplex::CONTROL_URI}/_data/data.php?Sync=1&action=getrecord&id=#{unit[:unit_number]}&table=units&ds=rd5&_ts=#{SecureRandom.hex(4)}") do |req|
118
- req.headers["Cookie"] = "PHPSESSID=#{php_session_id}"
119
- req.headers["App-name"] = "rd5Control"
120
- end
121
- @unit_id ||= Nokogiri::XML(response.body).at_xpath("//table/i")["ident"]
112
+ fail_request(AtreaControl::TransportError, "Portal connection failed", "connection_failed")
122
113
  end
123
114
 
124
- def sid
125
- response = connection.get("#{AtreaControl::Duplex::CONTROL_URI}/apps/rd5Control/handle.php?Sync=1&action=unitQuery&query=loged&user=#{user_id}&unit=#{unit_id}&#{SecureRandom.hex(2)}&_ts=#{SecureRandom.hex(4)}") do |req|
126
- req.headers["Cookie"] = "PHPSESSID=#{php_session_id}"
127
- req.headers["App-name"] = "rd5Control"
128
- end
129
- logger.debug response.body
130
- Nokogiri::XML(response.body).at_xpath("//login")["sid"]
115
+ def fail_request(error_class, message, status)
116
+ diagnostic(@stage, status, level: :warn)
117
+ raise error_class, "#{message} (stage=#{@stage} status=#{status})", cause: nil
131
118
  end
132
119
 
133
- # @!group Private methods
120
+ def monotonic_time
121
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
122
+ end
134
123
 
135
- # @return [String] session ID from PHP BE
136
- def php_session_id
137
- return @php_session_id if @php_session_id
124
+ def remaining_budget
125
+ remaining = @deadline - monotonic_time
126
+ return remaining if remaining.positive?
138
127
 
139
- payload = { username: @login, password: @password }
140
- connection.post("#{AtreaControl::Duplex::CONTROL_URI}?action=login", payload)
128
+ fail_request(AtreaControl::UnitConnectionTimeout, "Portal login deadline exceeded", "deadline_exceeded")
129
+ end
141
130
 
142
- # Extract PHPSESSID from cookie jar
143
- cookies = @jar.cookies(URI.parse(AtreaControl::Duplex::CONTROL_URI))
144
- @php_session_id = cookies.find { |c| c.name == "PHPSESSID" }&.value
145
- @php_session_id
131
+ def wait_for_poll
132
+ Kernel.sleep([@poll_interval, remaining_budget].min)
133
+ remaining_budget
146
134
  end
147
135
 
148
- # Create Faraday connection with cookie jar
149
- def connection
150
- @connection ||= Faraday.new do |f|
151
- f.request :url_encoded
152
- f.use :cookie_jar, jar: @jar
153
- f.adapter Faraday.default_adapter
154
- end
136
+ def diagnostic(stage, status = nil, level: :debug)
137
+ logger.public_send(level, "stage=#{stage} status=#{status || "none"} elapsed=#{(monotonic_time - @started_at).round(3)} pollcount=#{@poll_count}")
155
138
  end
156
139
  end
140
+
141
+ private_constant :LoginXML, :LoginDiscovery, :LoginSession
157
142
  end
158
143
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtreaControl
4
+ module Duplex
5
+ # Strict portal XML validation. Never include response data or parser causes in errors.
6
+ module LoginXML
7
+ def self.document(body)
8
+ document = Nokogiri::XML(body) { |config| config.strict.nonet }
9
+ raise AtreaControl::ProtocolError, "Unexpected portal XML document", cause: nil if document.internal_subset || document.root&.name != "root"
10
+
11
+ document
12
+ rescue Nokogiri::XML::SyntaxError
13
+ raise AtreaControl::ProtocolError, "Malformed portal XML", cause: nil
14
+ end
15
+
16
+ def self.node(body, path)
17
+ nodes = document(body).xpath(path)
18
+ raise AtreaControl::ProtocolError, "Missing or ambiguous portal response node", cause: nil unless nodes.size == 1
19
+
20
+ nodes.first
21
+ end
22
+
23
+ def self.value(value, label)
24
+ raise AtreaControl::ProtocolError, "Missing or invalid #{label}", cause: nil if value.nil? || value.empty? || value.match?(/\s/)
25
+
26
+ value
27
+ end
28
+ end
29
+
30
+ # Resolve account list references to unit identifiers, caching each listed record.
31
+ class LoginDiscovery
32
+ def initialize(request:, unit_id:)
33
+ @request = request
34
+ @requested_ident = unit_id&.to_s
35
+ @records = {}
36
+ end
37
+
38
+ def user_id
39
+ @user_id ||= LoginXML.value(LoginXML.node(@request.call("client", :get, "/core/core.php", { action: "init" }), "/root/client")["id"], "client ID")
40
+ end
41
+
42
+ def unit
43
+ @unit ||= select_unit
44
+ end
45
+
46
+ private
47
+
48
+ def select_unit
49
+ entries = listed_units
50
+ return resolve(entries.first) unless @requested_ident
51
+
52
+ matches = entries.map { |entry| resolve(entry) }.select { |record| record[:ident] == @requested_ident }
53
+ raise AtreaControl::ProtocolError, "No matching unit in account list", cause: nil if matches.empty?
54
+ raise AtreaControl::ProtocolError, "Multiple matching units in account list", cause: nil if matches.size > 1
55
+
56
+ matches.first
57
+ end
58
+
59
+ def listed_units
60
+ body = @request.call("unit_list", :get, "/_data/data.php", { Sync: 1, action: "getdata", rH: "", rE: "", table: "userUnits", ds: "rd5" })
61
+ table = LoginXML.node(body, "/root/data/table")
62
+ entries = table.xpath("./i")
63
+ raise AtreaControl::ProtocolError, "No units in account list", cause: nil if entries.empty?
64
+
65
+ entries.each do |entry|
66
+ LoginXML.value(entry["unit"], "unit record reference")
67
+ LoginXML.value(entry["id"], "account unit row")
68
+ end
69
+ entries
70
+ end
71
+
72
+ def resolve(entry)
73
+ reference = entry["unit"]
74
+ ident = @records[reference] ||= begin
75
+ body = @request.call("unit_record", :get, "/_data/data.php", { Sync: 1, action: "getrecord", id: reference, table: "units", ds: "rd5" })
76
+ LoginXML.value(LoginXML.node(body, "/root/datarecord/table/i")["ident"], "unit ident")
77
+ end
78
+ { ident: ident, iid: entry["id"] }
79
+ end
80
+ end
81
+ end
82
+ end