atrea_control 3.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +12 -1
- data/.rubocop_todo.yml +7 -1
- data/.ruby-version +1 -1
- data/AGENTS.md +51 -0
- data/CHANGELOG.md +63 -1
- data/Gemfile +4 -4
- data/Gemfile.lock +49 -47
- data/README.md +215 -62
- data/atrea_control.gemspec +5 -4
- data/docs/rd5-cloud-protocol.md +253 -0
- data/docs/superpowers/plans/2026-10-24-rd5-reliability.md +238 -0
- data/lib/atrea_control/duplex/login.rb +94 -109
- data/lib/atrea_control/duplex/login_discovery.rb +82 -0
- data/lib/atrea_control/duplex/login_session.rb +53 -0
- data/lib/atrea_control/duplex/rd5_command.rb +72 -0
- data/lib/atrea_control/duplex/rd5_configuration.rb +104 -0
- data/lib/atrea_control/duplex/request.rb +34 -6
- data/lib/atrea_control/duplex/unit.rb +58 -52
- data/lib/atrea_control/duplex/unit_commands.rb +59 -0
- data/lib/atrea_control/duplex/user_ctrl.rb +75 -64
- data/lib/atrea_control/duplex/write_verification.rb +75 -0
- data/lib/atrea_control/duplex.rb +2 -0
- data/lib/atrea_control/logger.rb +3 -1
- data/lib/atrea_control/sensor_parser.rb +94 -30
- data/lib/atrea_control/version.rb +2 -1
- data/lib/atrea_control.rb +7 -0
- metadata +32 -9
|
@@ -0,0 +1,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
|
-
#
|
|
14
|
+
# Authenticate to RD5 within one monotonic budget, returning direct API tokens.
|
|
11
15
|
class Login
|
|
12
16
|
include AtreaControl::Logger
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
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
|
-
#
|
|
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
|
-
@
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
79
|
-
@
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
120
|
+
def monotonic_time
|
|
121
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
122
|
+
end
|
|
134
123
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return
|
|
124
|
+
def remaining_budget
|
|
125
|
+
remaining = @deadline - monotonic_time
|
|
126
|
+
return remaining if remaining.positive?
|
|
138
127
|
|
|
139
|
-
|
|
140
|
-
|
|
128
|
+
fail_request(AtreaControl::UnitConnectionTimeout, "Portal login deadline exceeded", "deadline_exceeded")
|
|
129
|
+
end
|
|
141
130
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|