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 "entity_helpers.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/// IO position of a fully closed (non-inverted) device and of a fully open one.
17constexpr float IO_POSITION_CLOSED = 100.0F;
18constexpr float IO_POSITION_OPEN = 0.0F;
19
20/// Movement direction implied by travelling from one IO position toward another.
21///
22/// One rule for every direction question this entity asks, so the inversion handling and the
23/// "no information" case cannot drift between call sites. Equal endpoints, or either end unknown,
24/// mean the direction is not known — never a direction picked by whichever way a comparison
25/// happens to fall. Pure position arithmetic, so it lives here rather than on the entity.
26/// @param invert Whether this device's open/close mapping is inverted.
27/// @param from_io_position Starting IO position, or UNKNOWN_POSITION.
28/// @param to_io_position Destination IO position, or UNKNOWN_POSITION.
29cover::CoverOperation operation_toward(bool invert, float from_io_position, float to_io_position) {
30 if (from_io_position == UNKNOWN_POSITION || to_io_position == UNKNOWN_POSITION ||
31 from_io_position == to_io_position) {
32 return cover::COVER_OPERATION_IDLE;
33 }
34 const bool opening = invert ? (to_io_position > from_io_position) : (to_io_position < from_io_position);
35 return opening ? cover::COVER_OPERATION_OPENING : cover::COVER_OPERATION_CLOSING;
36}
37
38/// The direction toward an end position, or IDLE for an intermediate or unknown target.
39/// @param invert True when the cover's IO scale runs the other way (0 = closed).
40/// @param to_io_position Target on the IO scale (0-100).
41cover::CoverOperation operation_toward_end(bool invert, float to_io_position) {
42 const float closed_position = invert ? IO_POSITION_OPEN : IO_POSITION_CLOSED;
43 const float open_position = invert ? IO_POSITION_CLOSED : IO_POSITION_OPEN;
44 if (to_io_position == closed_position)
45 return cover::COVER_OPERATION_CLOSING;
46 if (to_io_position == open_position)
47 return cover::COVER_OPERATION_OPENING;
48 return cover::COVER_OPERATION_IDLE;
49}
50
51/// Direction of a device that is moving with its live position withheld, from the last position
52/// seen to the target.
53///
54/// The last seen position says nothing when it equals the target: a door that opened from closed
55/// reports no position on the way, so a Close sent mid-travel compares 100 with 100 (issue #163). An
56/// end-position target still names the direction, but a device can also report its pre-command
57/// target while starting the other way, so it is trusted only when it is the hub's own command or the
58/// direction already shown. With no position seen at all there is no start point, and the direction
59/// stays unknown.
60/// @param invert True when the cover's IO scale runs the other way (0 = closed).
61/// @param previous_io_position Last position the device reported, or UNKNOWN_POSITION.
62/// @param target Effective target (prediction or observation).
63/// @param own_command True while the target is the hub's own prediction.
64/// @param shown Direction the entity showed before this report.
65cover::CoverOperation operation_with_withheld_position(bool invert, float previous_io_position, float target,
66 bool own_command, cover::CoverOperation shown) {
67 if (previous_io_position != target)
68 return operation_toward(invert, previous_io_position, target);
69 const cover::CoverOperation toward_end = operation_toward_end(invert, target);
70 return own_command || toward_end == shown ? toward_end : cover::COVER_OPERATION_IDLE;
71}
72
73} // namespace
74
76 // A cover passes its explicit YAML inversion; without one the kind's default applies. The rest of
77 // the registration ritual is shared with the other entity types.
79 this, DeviceCapabilityClass::COVER, this->invert_explicit_ ? std::optional<bool>(this->invert_) : std::nullopt,
80 [this](const std::string &id, const IoDevice &dev) { this->on_device_update_(id, dev); });
81 // After the registration above: the hub only schedules a poll for a registered device.
82 if (this->rain_sensor_poll_interval_ms_ != 0)
84 if (this->sun_level_poll_interval_ms_ != 0)
88 if (this->status_refresh_interval_ms_ != 0)
90}
91
92cover::CoverTraits IOHomeCover::get_traits() {
93 auto traits = cover::CoverTraits();
94 // A device without intermediate positions (a garage opener of subtype 58, say) gets open / close /
95 // stop and no slider. It also gets assumed state: Home Assistant disables Open on a position-less
96 // cover that shows open and Close on one that shows closed, and this cover shows open for every
97 // position that is not fully closed, so a door stopped half-way would otherwise offer no Open.
98 const bool levels = this->supports_intermediate_positions();
99 traits.set_supports_position(levels); // Slider in HA UI
100 traits.set_supports_stop(true); // Stop button in HA UI
101 traits.set_supports_tilt(this->supports_tilt());
102 traits.set_is_assumed_state(!levels); // A discrete cover is open or closed; anything between is a guess
103 return traits;
104}
105
109
111 return has_feature(this->device_features_(DeviceCapabilityClass::COVER), device_feature::TILT);
112}
113
115 if (this->invert_explicit_)
116 return this->invert_;
117 if (this->parent_ == nullptr)
118 return false;
119 const auto *dev = this->parent_->get_device(this->device_id_);
120 return dev != nullptr && dev->inverted;
121}
122
123cover::CoverOperation IOHomeCover::infer_operation_from_position_delta_(bool invert, float current_io_position) const {
124 return operation_toward(invert, this->last_io_position_, current_io_position);
125}
126
127void IOHomeCover::control(const cover::CoverCall &call) {
128 // Optimistic state gives immediate HA UI feedback for the queue-dispatch + TX/response gap;
129 // the queued command's own response (update_device_status_()) supersedes the prediction, and a
130 // failed command withdraws it (DeviceRegistry::rollback_optimistic()). No-op when this device
131 // has optimistic_state=false (see
132 // DeviceRegistry::apply_optimistic_target()/apply_optimistic_stop()).
133 if (call.get_stop()) {
136 return;
137 }
138
139 const auto &tilt_opt = call.get_tilt();
140 const auto &position_opt = call.get_position();
141
142 // Combined position+tilt in one atomic command when both are present.
143 /// @todo Monitor https://github.com/home-assistant/core/issues/174533 — if HA adds a combined
144 /// cover.set_cover_position_and_tilt action, this branch would be exercised directly from
145 /// a single CoverCall. The queue coalescing in queue_set_device_position/tilt remains a
146 /// useful optimization for the two-separate-calls path regardless.
147 // Position/tilt fraction -> IO percent uses detail::round_percent() (entity_helpers.h) — rounds
148 // rather than truncates, since HA quantizes call values to 0-255 before they reach us (its
149 // "50%" is 128/255=0.502, not exactly 0.5) and a truncating cast would compound that into a
150 // consistent ~1% bias (caught on hardware; see PlatformCover.ControlRoundsQuantizedPosition-
151 // InsteadOfTruncating).
152 if (position_opt.has_value() && tilt_opt.has_value() && this->supports_tilt()) {
153 float const ha_pos = *position_opt;
154 const bool invert = this->effective_invert_();
155 uint8_t const io_pos = invert ? detail::round_percent(ha_pos) : detail::round_percent(1.0F - ha_pos);
156 auto const tilt = detail::round_percent(*tilt_opt);
157 this->parent_->apply_optimistic_target(this->device_id_, io_pos);
158 this->parent_->apply_optimistic_tilt(this->device_id_, tilt);
159 this->parent_->queue_set_device_position_and_tilt(this->device_id_, io_pos, tilt);
160 return;
161 }
162
163 if (tilt_opt.has_value()) {
164 auto const tilt = detail::round_percent(*tilt_opt);
165 // Position commands get their optimistic feedback from apply_optimistic_target() above; tilt
166 // needs its own because a tilt command's reply carries no usable slat angle, so without this
167 // the slider snaps back to the pre-command angle until the next status poll.
168 this->parent_->apply_optimistic_tilt(this->device_id_, tilt);
169 this->parent_->queue_set_device_tilt(this->device_id_, tilt);
170 return;
171 }
172
173 if (position_opt.has_value()) {
174 float const ha_pos = *position_opt; // HA: 1.0 = fully open, 0.0 = fully closed
175 const bool invert = this->effective_invert_();
176
177 // Convert HA position (0.0-1.0) to IO position (0-100)
178 // Standard: HA 1.0 (open) → IO 0 (open), HA 0.0 (closed) → IO 100 (closed)
179 // Inverted: HA 1.0 (open) → IO 100, HA 0.0 (closed) → IO 0
180 // (used for devices like horizontal awnings where the IO convention is reversed)
181 const uint8_t io_pos = invert ? detail::round_percent(ha_pos) : detail::round_percent(1.0F - ha_pos);
182
183 this->parent_->apply_optimistic_target(this->device_id_, io_pos);
184 this->parent_->queue_set_device_position(this->device_id_, io_pos);
185 }
186}
187
188void IOHomeCover::on_device_update_(const std::string &id, const IoDevice &dev) {
189 if (id != this->device_id_)
190 return;
191
192 const bool invert = this->effective_invert_();
193 const float previous_io_position = this->last_io_position_;
194 const cover::CoverOperation previous_operation = this->current_operation;
195
196 // Predictions win over the last observation, per axis (see OptimisticState). `position` is
197 // observed-only — the hub never guesses a live position — so it is read directly.
198 const float eff_tilt = effective_tilt(dev);
199 const bool eff_stopped = effective_is_stopped(dev);
200 const float eff_target = effective_target(dev);
201
202 if (!this->supports_intermediate_positions()) {
203 // Open or closed, nothing between: closed only when the device reports fully closed (IO 100,
204 // or IO 0 when inverted); every other report, an unknown one included, shows open. A door
205 // stopped mid-travel reports an unknown position (issue #163) and is not closed. Written for
206 // every report, because an unknown one must not leave the last state standing.
207 const float closed_position = invert ? IO_POSITION_OPEN : IO_POSITION_CLOSED;
208 this->position = dev.position == closed_position ? cover::COVER_CLOSED : cover::COVER_OPEN;
209 } else if (dev.position != UNKNOWN_POSITION) {
210 // Convert IO position (0-100) back to HA position (0.0-1.0)
211 float ha_pos;
212 if (invert) {
213 ha_pos = detail::whole_percent(dev.position) / 100.0F;
214 } else {
215 ha_pos = 1.0F - (detail::whole_percent(dev.position) / 100.0F);
216 }
217
218 this->position = ha_pos;
219 }
220
221 if (this->supports_tilt() && eff_tilt != UNKNOWN_POSITION) {
222 this->tilt = detail::whole_percent(eff_tilt) / 100.0F;
223 }
224
225 // Determine movement direction for the HA UI animation
226 if (eff_stopped) {
227 this->current_operation = cover::COVER_OPERATION_IDLE;
228 } else if (eff_target != UNKNOWN_POSITION && dev.position != UNKNOWN_POSITION && eff_target != dev.position) {
229 // Travelling toward a target we can see, from a position we can see.
230 this->current_operation = operation_toward(invert, dev.position, eff_target);
231 } else if (eff_target != UNKNOWN_POSITION && dev.position == UNKNOWN_POSITION) {
232 // Moving, target known, live position withheld. Not every actuator publishes intermediate
233 // positions: some report current = POS_UNKNOWN (0xD4) on every poll mid-travel (flagging
234 // themselves "moving" vs. "at rest" instead), and only report a real value once they settle.
235 // The last position we *did* see is enough to say which way the device is going, and
236 // `this->position` keeps displaying that value meanwhile rather than blanking, because the
237 // assignment above is skipped for an unknown reading.
238 this->current_operation = operation_with_withheld_position(
239 invert, previous_io_position, eff_target, dev.optimistic.target != UNKNOWN_POSITION, previous_operation);
240 } else if (dev.position != UNKNOWN_POSITION) {
241 // Either no target at all, or a target equal to the current position while the device says it
242 // is moving. The second case is not a standstill: a device flags itself moving while still
243 // reporting its *pre-command* target for roughly the first second, so equal endpoints here mean
244 // "no information yet", not "closing". Fall through to the delta inference, which reports IDLE
245 // until an actual position change reveals the direction.
246 this->current_operation = this->infer_operation_from_position_delta_(invert, dev.position);
247 }
248
249 if (dev.position != UNKNOWN_POSITION) {
250 this->last_io_position_ = dev.position;
251 } else {
252 this->last_io_position_ = previous_io_position;
253 }
254
255 this->publish_state();
256}
257
259 LOG_COVER("", "IO-Homecontrol Cover", this);
260 ESP_LOGCONFIG(TAG, " Device ID: %s", this->device_id_.c_str());
261 ESP_LOGCONFIG(TAG, " Invert Position Override: %s", this->invert_explicit_ ? YESNO(this->invert_) : "AUTO");
263 if (this->rain_sensor_poll_interval_ms_ != 0)
264 ESP_LOGCONFIG(TAG, " Rain Sensor Poll Interval: %" PRIu32 " ms", this->rain_sensor_poll_interval_ms_);
265 if (this->sun_level_poll_interval_ms_ != 0)
266 ESP_LOGCONFIG(TAG, " Sun Level Poll Interval: %" PRIu32 " ms", this->sun_level_poll_interval_ms_);
267 if (this->battery_level_poll_interval_ms_ != 0)
268 ESP_LOGCONFIG(TAG, " Battery Level Poll Interval: %" PRIu32 " ms", this->battery_level_poll_interval_ms_);
269 if (this->status_refresh_interval_ms_ != 0)
270 ESP_LOGCONFIG(TAG, " Status Refresh Interval: %" PRIu32 " ms", this->status_refresh_interval_ms_);
271 ESP_LOGCONFIG(TAG, " Supports Tilt: %s", YESNO(this->supports_tilt()));
272}
273
274} // namespace home_io_control
275} // 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, DeviceCapabilityClass entity_class, std::optional< bool > invert_override, std::function< void(const std::string &, const IoDevice &)> on_update, bool dimmable=false)
Perform the shared setup() registration ritual.
DeviceFeatures device_features_(DeviceCapabilityClass entity_class) const
What this entity's device can do: the registered device's features, or, before it is registered,...
virtual bool apply_optimistic_stop(const std::string &device_id)
Predict that a device has stopped (e.g.
Definition hub_core.h:296
virtual void queue_set_device_tilt(const std::string &device_id, uint8_t tilt_percent)
Queue an async tilt update; returns immediately, executed in loop().
virtual IoDevice * get_device(const std::string &device_id)
Retrieve a device by ID; returns nullptr if not found.
Definition hub_core.cpp:402
virtual void set_device_rain_poll_interval(const std::string &device_id, uint32_t interval_ms)
Opt a registered device into the periodic rain sensor poll (a read of its minimum limitation).
Definition hub_core.cpp:349
virtual void set_device_status_refresh_interval(const std::string &device_id, uint32_t interval_ms)
Opt a registered device into the status refresh at rest: a status read whenever the device has not re...
Definition hub_core.cpp:368
virtual void queue_set_device_position_and_tilt(const std::string &device_id, uint8_t position, uint8_t tilt_percent)
Queue an async combined position+tilt update; returns immediately, executed in loop().
virtual bool queue_device_command(const std::string &device_id, CoverCommand cmd)
Queue an async named command (STOP, FAVORITE, VENT, PARTIAL, PEDESTRIAN, FORCE_OPEN); returns immedia...
virtual bool apply_optimistic_tilt(const std::string &device_id, float tilt_percent)
Set an optimistic slat angle ahead of a confirming status poll, and notify.
Definition hub_core.h:310
virtual void set_device_sun_poll_interval(const std::string &device_id, uint32_t interval_ms)
Opt a registered device into the periodic sun level poll (a subscription to EVENT_LOCAL_SENSOR,...
Definition hub_core.cpp:355
virtual void queue_set_device_position(const std::string &device_id, uint8_t position)
Queue an async position update; returns immediately, executed in loop().
virtual bool apply_optimistic_target(const std::string &device_id, float target_io_position)
Set an optimistic target position ahead of a confirming poll/response, and notify.
Definition hub_core.h:285
virtual void set_device_battery_level_poll_interval(const std::string &device_id, uint32_t interval_ms)
Opt a registered device into the periodic battery level poll (a subscription to EVENT_BATTERY_LEVEL_C...
Definition hub_core.cpp:361
uint32_t rain_sensor_poll_interval_ms_
0 = not opted in; no rain poll is scheduled.
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.
uint32_t status_refresh_interval_ms_
0 = not opted in; no status refresh is scheduled.
bool supports_intermediate_positions() const
Query whether this device takes intermediate positions (a slider) or only open / close / stop.
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).
uint32_t battery_level_poll_interval_ms_
0 = not opted in; no battery level poll is scheduled.
uint32_t sun_level_poll_interval_ms_
0 = not opted in; no sun level poll is scheduled.
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).
Conversions and renderers shared by the hub and its Home Assistant entities.
uint8_t round_percent(float fraction)
Convert a 0.0-1.0 HA fraction (position, tilt, or brightness) to a 0-100 IO percent.
float whole_percent(float io_percent)
Round an IO percent (0-100) to a whole number, for publishing to HA.
static constexpr const char * TAG
float effective_target(const IoDevice &dev)
The main-position target a consumer should act on: the prediction when one stands,...
constexpr bool has_feature(DeviceFeatures set, DeviceFeatures bit)
True when every bit of bit is set in set.
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.
@ COVER
Position‑controlled cover (shutter/blind/awning).
static const char *const TAG
ESPHome cover entity for IO‑Homecontrol devices.
Runtime state of a paired IO‑Homecontrol device.
OptimisticState optimistic
Hub-side predictions; see OptimisticState. Never observation.
bool inverted
True if open/close positions are swapped (e.g., horizontal awning).
float position
Current position: 0=open, 100=closed, or UNKNOWN_POSITION.
float target
Predicted main-position target, or UNKNOWN_POSITION.