Ruby client for ATREA DUPLEX ventilation units with RD5 regulation, using the RD5 cloud portal. RD5-only support is intentional: this unofficial gem uses cloud HTTP/XML, not aMotion Cloud or Modbus TCP.
Login and end-to-end reads were verified against a live RD5 unit on 2026-10-25:
configuration discovery, fresh snapshots, getters/JSON and repeated refreshes
succeeded. A missing Sync=1 parameter in the unit-login request was corrected
after comparison with the current portal. Live writes and requested-value
read-back semantics remain unverified. See protocol evidence and limitations.
gem "atrea_control"Run bundle install, or install directly with gem install atrea_control.
Supply credentials through your application's environment. The library does not
load .env files automatically. Never log credentials or returned tokens.
require "atrea_control"
tokens = AtreaControl::Duplex::Login.user_tokens(
login: ENV.fetch("ATREA_USERNAME"),
password: ENV.fetch("ATREA_PASSWORD"),
unit_id: ENV.fetch("ATREA_UNIT_IDENT"),
timeout: 300,
poll_interval: 1,
open_timeout: 10,
read_timeout: 60
)
control = AtreaControl::Duplex::Unit.new(**tokens, time_zone: "Europe/Prague")
values = control.values
values[:current_power] # Numeric configured power value, not universally percent.
values[:current_mode] # Translated label; may describe a user input such as CO2.
control.refresh! # Explicitly fetch and validate a new snapshot.Login.new(...).call accepts the same keywords. unit_id: selects the unit's
ident, not its portal row ID. Omit it to select the first listed unit.
Returned tokens contain user_id, unit_id and secret sid.
Account authentication and unit-session acquisition are separate stages. Defaults
are timeout: 300, poll_interval: 1, open_timeout: 10, read_timeout: 60, in
seconds. The entire login, including authentication, discovery and polling, must
finish within five minutes; shorter timeout: values are allowed, larger ones
are rejected. Each HTTP operation has a separate 60-second total limit covering
connection, writing and reading, also bounded by the outer login limit. Both use
Faraday's Net::HTTP adapter and standard-library Timeout, without a custom
transport. Server ETA is advisory, not a loop count. Timeout does not establish
that credentials were rejected or the unit is offline.
Ordinary unit requests also have a 60-second total limit, not just a per-read
timeout. Duplex::Request.new(..., timeout: ...) accepts shorter total limits;
open_timeout: and read_timeout: can independently shorten connection/read
waits. The adapter never automatically retries requests.
- First reading fetches one complete snapshot; later getters reuse it without
implicit network refresh.
refresh!explicitly replaces it. - Every current-value getter and serializer checks freshness: timestamp must be
newer than
now - 900seconds and no later thannow + 60seconds. Exactly 15 minutes old is expired.valid?inspects cached state without fetching. - Failed refresh invalidates current data and preserves the failure until another
refresh!. A write attempt also discards the pre-write snapshot, even when its response fails; next reading fetches again. No stale fallback is exposed. values/as_jsonuse symbol keys;parseduses string keys. Readers includepower/current_power,mode/current_mode,outdoor_temperature,required_temperature,preheat_temperature,input_temperature,preheating/preheating?, andtimestamp. Temperatures are degrees Celsius.required_temperatureis the requested temperature. Historicalpreheat_temperaturereader and hash key remain aliases in meaning; both keys are present. This is not a measured preheater temperature.- Optional
input_temperatureisnilwhen unavailable or its discovered logic is unsupported. It is not fabricated as zero. - Legacy
preheatingis an aggregate of heating-related signals, not a verified dedicated preheater state. - Optional
time_zone:accepts an IANA identifier (also onSensorParser.new).nilpreserves process-local timestamp interpretation. Set the actual unit's zone explicitly to remove deployment-zone ambiguity. Invalid zones and ambiguous/nonexistent DST-local timestamps raiseProtocolError. - Device timestamps may omit leading zeroes (for example,
17:7:32). These are normalized while invalid calendar dates and times are still rejected.
Loading the gem registers translations and sets global I18n.default_locale to
:cs. Sensor/mode maps use string keys. user_modes keys are numeric strings,
not input names such as "D1".
Unit.new accepts user_ctrl: to reuse a Duplex::UserCtrl for the same unit.
It caches successfully validated layout/text configuration per instance; recreate
it after configuration changes. UserCtrl.data exports JSON-safe configuration,
including input_temperature_source: "rd5_standard" selects the recognized
device source-selection rule; "unsupported" keeps inlet temperature unavailable.
Persist this field alongside the sensor/mode maps and expose it when reconstructing
a custom user_ctrl: object, such as an ActiveRecord model. Without it, only the
legacy direct sensor mapping can be used. Missing/nil metadata retains that legacy
behavior; explicit unknown metadata returns nil, without guessing a sensor.
Custom objects supply name, sensors, modes, user_modes and the persisted
input_temperature_source. Sensor interpretation stays inside the gem, not the
application UI. Command mappings must include discovered power_input, power_switch,
mode_input and mode_switch fields. Derive mappings from the unit's UserCtrl,
not fixture addresses. control.configuration returns the fresh snapshot's
Duplex::RD5Configuration policy ([], fetch, to_h).
Setter values must be nonnegative integers, finite integral Floats, or ASCII
decimal-digit strings: 88, 88.0, "88" normalize to 88. nil, booleans,
negative/fractional numbers, NaN/infinity, whitespace, signs and "88.0" raise
ArgumentError. Values are never silently truncated or clamped. Commands also
require a supported configuration and discovered register mapping.
Power semantics follow RD5 H10510 in the vendor's
Communication MODBUS — RD5 and aMotion, version 04,
section 3.1, page 9:
H10510 |
Configuration | Accepted power values |
|---|---|---|
| 0 | Direct speed | 0 or integers 12–100 |
| 1 | Constant flow | Integers 0–100 (configured flow scale) |
| 2 | Constant pressure | 0, 1, 2 (levels) |
| 3 | External input | No power writes; UnsupportedConfiguration |
| 4 | Multiport | 0, 10–12, 20–22, 30–38 (enumerated settings) |
| Missing/unknown | Unsupported | UnsupportedConfiguration |
These are not universal fan-speed percentages or measured airflow. Modes must be
discovered numeric IDs 0–7 and satisfy equipment prerequisites: 0/2 unrestricted;
1/6/7 require C10508=0; 3 additionally requires C10509=1; 4 additionally
requires H11700=1; 5 requires I11401=1. Missing prerequisites fail explicitly.
Translated current-mode labels are not command values.
Schedule policy uses the discovered control register: manual 0 sends only the setpoint; scheduled 1 or temporary 2 sends the setpoint plus temporary control 2, including for power zero. It does not permanently disable schedules. A temporary request lasts until the next scheduled interval under the documented RD5 policy.
response = control.set_power(88, verify: false)
response = control.set_mode(2, verify: false)
control.power = 88
control.mode = 2verify: false returns the HTTP response, without application/read-back
confirmation. power= and mode= delegate with verify: false. Ruby assignment
expressions evaluate to their right-hand value, not confirmation. HTTP success
alone does not establish that the unit accepted or applied the request. Writes
are never automatically retried.
set_power(value, verify: true, timeout: 30, poll_interval: 5) and set_mode
default to verification. The default configuration has no proven requested-value
read-back profile: verified calls raise UnsupportedConfiguration before writing.
Neither the PDF's current-value fields nor old fixture idw fields are selected
automatically.
Only supply verification_registers: after independently verifying requested-value
semantics for that exact unit/profile. It accepts a nonempty Hash with symbol keys
:power and/or :mode, and String values matching H plus exactly five digits.
Syntax validation does not establish register semantics. This example deliberately
supplies no literal register address:
# These environment values must come from your independently verified profile.
verified_control = AtreaControl::Duplex::Unit.new(
**tokens,
time_zone: "Europe/Prague",
verification_registers: {
power: ENV.fetch("ATREA_VERIFIED_POWER_REQUEST_REGISTER"),
mode: ENV.fetch("ATREA_VERIFIED_MODE_REQUEST_REGISTER")
}
)
confirmed_power = verified_control.set_power(88, timeout: 30, poll_interval: 5)
confirmed_mode = verified_control.set_mode(2, timeout: 30, poll_interval: 5)Verification fetches a fresh baseline, writes once, then polls for the normalized
request in a snapshot newer than the baseline. One absolute budget includes
baseline, lazy discovery, dispatch, read-back and sleeps. It confirms a requested
setpoint, not physical airflow, effective mode or override of safety functions.
WriteNotConfirmed means outcome unknown after dispatch; do not blindly retry.
All library errors inherit AtreaControl::Error; invalid caller command inputs
raise standard ArgumentError.
Migration from 3.2.x: nil formerly could become zero; it now raises
ArgumentError. Other malformed inputs no longer silently coerce. All cached
getters/serializers can now raise freshness or refresh errors. Handle optional
inlet temperature nil, the additional required_temperature hash key, strict
configuration/mode validation, and preserved manual schedule control. Explicit
set_power/set_mode default to verification; existing assignment setters remain
unconfirmed. These changes require a major-version upgrade from 3.2.x.
See RD5 cloud protocol evidence for supported declarative discovery forms, fixture/PDF differences and current integration blocker. The PDF describes Modbus TCP, not cloud framing, acknowledgements or rate limits. Port 502, enabling Modbus and its five-second batch interval are not prerequisites or established HTTP constraints for this gem.
Run bin/setup to install dependencies. bundle exec rake runs specs then
RuboCop. CI retains the Ruby spec matrix and runs a separate RuboCop job. Focused
test: bundle exec rspec spec/atrea_control/duplex/unit_spec.rb, optionally with
:LINE. WebMock/local fixtures need no portal credentials or physical unit.
Use bin/console for an interactive prompt.
Gem contents come from git ls-files; new runtime files must be tracked before
packaging. bundle exec rake build builds locally. Publishing requires a separate
release decision: bundle exec rake release pushes commits/tags and publishes to
RubyGems; it is not a verification command.
Bug reports and pull requests are welcome at https://github.com/luk4s/atrea_control. Participants must follow the code of conduct.
