Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
platform_cover.cpp
Go to the documentation of this file.
1/// @file platform_cover.cpp
2/// @brief ESPHome cover entity for IO-Homecontrol devices.
3/// @ingroup hioc_platforms
4
5#include "platform_cover.h"
6#include "hub_internal.h"
7#include "esphome/core/log.h"
8
9namespace esphome {
10namespace home_io_control {
11
12static const char *const TAG = "home_io_control.cover";
13
14namespace {
15
16/// Movement direction implied by travelling from one IO position toward another.
17///
18/// One rule for every direction question this entity asks, so the inversion handling and the
19/// "no information" case cannot drift between call sites. Equal endpoints, or either end unknown,
20/// mean the direction is not known — never a direction picked by whichever way a comparison
21/// happens to fall. Pure position arithmetic, so it lives here rather than on the entity.
22/// @param invert Whether this device's open/close mapping is inverted.
23/// @param from_io_position Starting IO position, or UNKNOWN_POSITION.
24/// @param to_io_position Destination IO position, or UNKNOWN_POSITION.
25cover::CoverOperation operation_toward(bool invert, float from_io_position, float to_io_position) {
26 if (from_io_position == UNKNOWN_POSITION || to_io_position == UNKNOWN_POSITION ||
27 from_io_position == to_io_position) {
28 return cover::COVER_OPERATION_IDLE;
29 }
30 const bool opening = invert ? (to_io_position > from_io_position) : (to_io_position < from_io_position);
31 return opening ? cover::COVER_OPERATION_OPENING : cover::COVER_OPERATION_CLOSING;
32}
33
34} // namespace
35
37 // Covers compute their initial inversion from the explicit YAML override or the device-type
38 // default; the rest of the registration ritual is shared with the other entity types.
39 const bool initial_invert = this->invert_explicit_ ? this->invert_ : default_inverted_for_type(this->device_type_);
41 this, initial_invert, [this](const std::string &id, const IoDevice &dev) { this->on_device_update_(id, dev); });
42}
43
44cover::CoverTraits IOHomeCover::get_traits() {
45 auto traits = cover::CoverTraits();
46 traits.set_supports_position(true); // Slider in HA UI
47 traits.set_supports_stop(true); // Stop button in HA UI
48 traits.set_supports_tilt(this->supports_tilt());
49 traits.set_is_assumed_state(false); // We hopefully get real feedback from the device
50 return traits;
51}
52
56
58 if (this->invert_explicit_)
59 return this->invert_;
60 if (this->parent_ == nullptr)
61 return false;
62 const auto *dev = this->parent_->get_device(this->device_id_);
63 return dev != nullptr && dev->inverted;
64}
65
66cover::CoverOperation IOHomeCover::infer_operation_from_position_delta_(bool invert, float current_io_position) const {
67 return operation_toward(invert, this->last_io_position_, current_io_position);
68}
69
70void IOHomeCover::control(const cover::CoverCall &call) {
71 // Optimistic state gives immediate HA UI feedback for the queue-dispatch + TX/response gap;
72 // the queued command's own response (update_device_status_()) supersedes the prediction, and a
73 // failed command withdraws it (DeviceRegistry::rollback_optimistic()). No-op when this device
74 // has optimistic_state=false (see
75 // DeviceRegistry::apply_optimistic_target()/apply_optimistic_stop()).
76 if (call.get_stop()) {
77 this->parent_->apply_optimistic_stop(this->device_id_);
78 this->parent_->queue_device_command(this->device_id_, CoverCommand::STOP);
79 return;
80 }
81
82 const auto &tilt_opt = call.get_tilt();
83 const auto &position_opt = call.get_position();
84
85 // Combined position+tilt in one atomic command when both are present.
86 /// @todo Monitor https://github.com/home-assistant/core/issues/174533 — if HA adds a combined
87 /// cover.set_cover_position_and_tilt action, this branch would be exercised directly from
88 /// a single CoverCall. The queue coalescing in queue_set_device_position/tilt remains a
89 /// useful optimization for the two-separate-calls path regardless.
90 // Position/tilt fraction -> IO percent uses detail::round_percent() (hub_internal.h) — rounds
91 // rather than truncates, since HA quantizes call values to 0-255 before they reach us (its
92 // "50%" is 128/255=0.502, not exactly 0.5) and a truncating cast would compound that into a
93 // consistent ~1% bias (caught on hardware; see PlatformCover.ControlRoundsQuantizedPosition-
94 // InsteadOfTruncating).
95 if (position_opt.has_value() && tilt_opt.has_value() && this->supports_tilt()) {
96 float const ha_pos = *position_opt;
97 const bool invert = this->effective_invert_();
98 uint8_t const io_pos = invert ? detail::round_percent(ha_pos) : detail::round_percent(1.0F - ha_pos);
99 auto const tilt = detail::round_percent(*tilt_opt);
100 this->parent_->apply_optimistic_target(this->device_id_, io_pos);
101 this->parent_->apply_optimistic_tilt(this->device_id_, tilt);
102 this->parent_->queue_set_device_position_and_tilt(this->device_id_, io_pos, tilt);
103 return;
104 }
105
106 if (tilt_opt.has_value()) {
107 auto const tilt = detail::round_percent(*tilt_opt);
108 // Position commands get their optimistic feedback from apply_optimistic_target() above; tilt
109 // needs its own because a tilt command's reply carries no usable slat angle, so without this
110 // the slider snaps back to the pre-command angle until the next status poll.
111 this->parent_->apply_optimistic_tilt(this->device_id_, tilt);
112 this->parent_->queue_set_device_tilt(this->device_id_, tilt);
113 return;
114 }
115
116 if (position_opt.has_value()) {
117 float const ha_pos = *position_opt; // HA: 1.0 = fully open, 0.0 = fully closed
118 const bool invert = this->effective_invert_();
119
120 // Convert HA position (0.0-1.0) to IO position (0-100)
121 // Standard: HA 1.0 (open) → IO 0 (open), HA 0.0 (closed) → IO 100 (closed)
122 // Inverted: HA 1.0 (open) → IO 100, HA 0.0 (closed) → IO 0
123 // (used for devices like horizontal awnings where the IO convention is reversed)
124 const uint8_t io_pos = invert ? detail::round_percent(ha_pos) : detail::round_percent(1.0F - ha_pos);
125
126 this->parent_->apply_optimistic_target(this->device_id_, io_pos);
127 this->parent_->queue_set_device_position(this->device_id_, io_pos);
128 }
129}
130
131void IOHomeCover::on_device_update_(const std::string &id, const IoDevice &dev) {
132 if (id != this->device_id_)
133 return;
134
135 const bool invert = this->effective_invert_();
136 const float previous_io_position = this->last_io_position_;
137
138 // Predictions win over the last observation, per axis (see OptimisticState). `position` is
139 // observed-only — the hub never guesses a live position — so it is read directly.
140 const float eff_tilt = effective_tilt(dev);
141 const bool eff_stopped = effective_is_stopped(dev);
142 const float eff_target = effective_target(dev);
143
144 if (dev.position != UNKNOWN_POSITION) {
145 // Convert IO position (0-100) back to HA position (0.0-1.0)
146 float ha_pos;
147 if (invert) {
148 ha_pos = dev.position / 100.0F;
149 } else {
150 ha_pos = 1.0F - (dev.position / 100.0F);
151 }
152
153 this->position = ha_pos;
154 }
155
156 if (this->supports_tilt() && eff_tilt != UNKNOWN_POSITION) {
157 this->tilt = eff_tilt / 100.0F;
158 }
159
160 // Determine movement direction for the HA UI animation
161 if (eff_stopped) {
162 this->current_operation = cover::COVER_OPERATION_IDLE;
163 } else if (eff_target != UNKNOWN_POSITION && dev.position != UNKNOWN_POSITION && eff_target != dev.position) {
164 // Travelling toward a target we can see, from a position we can see.
165 this->current_operation = operation_toward(invert, dev.position, eff_target);
166 } else if (eff_target != UNKNOWN_POSITION && dev.position == UNKNOWN_POSITION) {
167 // Moving, target known, live position withheld. Not every actuator publishes intermediate
168 // positions: some report current = POS_UNKNOWN (0xD4) on every poll mid-travel (flagging
169 // themselves "moving" vs. "at rest" instead), and only report a real value once they settle.
170 // The last position we *did* see is enough to say which way the device is going, and
171 // `this->position` keeps displaying that value meanwhile rather than blanking, because the
172 // assignment above is skipped for an unknown reading.
173 this->current_operation = operation_toward(invert, previous_io_position, eff_target);
174 } else if (dev.position != UNKNOWN_POSITION) {
175 // Either no target at all, or a target equal to the current position while the device says it
176 // is moving. The second case is not a standstill: a device flags itself moving while still
177 // reporting its *pre-command* target for roughly the first second, so equal endpoints here mean
178 // "no information yet", not "closing". Fall through to the delta inference, which reports IDLE
179 // until an actual position change reveals the direction.
180 this->current_operation = this->infer_operation_from_position_delta_(invert, dev.position);
181 }
182
183 if (dev.position != UNKNOWN_POSITION) {
184 this->last_io_position_ = dev.position;
185 } else {
186 this->last_io_position_ = previous_io_position;
187 }
188
189 this->publish_state();
190}
191
193 LOG_COVER("", "IO-Homecontrol Cover", this);
194 ESP_LOGCONFIG(TAG, " Device ID: %s", this->device_id_.c_str());
195 ESP_LOGCONFIG(TAG, " Invert Position Override: %s", this->invert_explicit_ ? YESNO(this->invert_) : "AUTO");
196 this->log_poll_interval_config_(TAG);
197 ESP_LOGCONFIG(TAG, " Supports Tilt: %s", YESNO(this->supports_tilt()));
198}
199
200} // namespace home_io_control
201} // namespace esphome
void log_poll_interval_config_(const char *tag) const
Emit the shared two-branch poll-interval line for dump_config().
void register_device_binding_(Component *self, bool inverted, std::function< void(const std::string &, const IoDevice &)> on_update, bool schedule_initial_poll=true)
Perform the shared setup() registration ritual.
cover::CoverOperation infer_operation_from_position_delta_(bool invert, float current_io_position) const
Infer HA movement direction from successive IO positions when the protocol target is unknown.
bool supports_tilt() const
Query whether this device supports tilt (slat angle) control.
void on_device_update_(const std::string &id, const IoDevice &dev)
Callback invoked when the underlying device state changes.
bool effective_invert_() const
Resolve the current inversion mode.
cover::CoverTraits get_traits() override
Return the traits object describing this cover's capabilities.
void setup() override
Initialize the cover entity (register device, subscribe to updates, schedule initial status poll).
void dump_config() override
Dump configuration to log.
void control(const cover::CoverCall &call) override
Handle cover commands from Home Assistant (open/close/stop/set_position).
Internal helpers shared by the hub implementation .cpp files.
uint8_t round_percent(float fraction)
Convert a 0.0-1.0 HA fraction (position, tilt, or brightness) to a 0-100 IO percent.
static constexpr float UNKNOWN_POSITION
Sentinel value meaning "position is not known yet".
@ UNKNOWN
Unknown/unspecified device.
static constexpr const char * TAG
bool default_inverted_for_type(DeviceType type)
Determine whether a device type has inverted position mapping by default.
float effective_target(const IoDevice &dev)
The main-position target a consumer should act on: the prediction when one stands,...
bool effective_is_stopped(const IoDevice &dev)
Whether a consumer should treat the device as at rest, prediction first.
float effective_tilt(const IoDevice &dev)
The slat angle a consumer should act on, prediction first.
bool device_supports_tilt(DeviceType type)
Does this device type support tilt (slat angle) control?
ESPHome cover entity for IO‑Homecontrol devices.
Runtime state of a paired IO‑Homecontrol device.
float position
Current position: 0=open, 100=closed, or UNKNOWN_POSITION.