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/// 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 // After the registration above: the hub only schedules a poll for a registered device.
43 if (this->rain_sensor_poll_interval_ms_ != 0)
45}
46
47cover::CoverTraits IOHomeCover::get_traits() {
48 auto traits = cover::CoverTraits();
49 traits.set_supports_position(true); // Slider in HA UI
50 traits.set_supports_stop(true); // Stop button in HA UI
51 traits.set_supports_tilt(this->supports_tilt());
52 traits.set_is_assumed_state(false); // We hopefully get real feedback from the device
53 return traits;
54}
55
59
61 if (this->invert_explicit_)
62 return this->invert_;
63 if (this->parent_ == nullptr)
64 return false;
65 const auto *dev = this->parent_->get_device(this->device_id_);
66 return dev != nullptr && dev->inverted;
67}
68
69cover::CoverOperation IOHomeCover::infer_operation_from_position_delta_(bool invert, float current_io_position) const {
70 return operation_toward(invert, this->last_io_position_, current_io_position);
71}
72
73void IOHomeCover::control(const cover::CoverCall &call) {
74 // Optimistic state gives immediate HA UI feedback for the queue-dispatch + TX/response gap;
75 // the queued command's own response (update_device_status_()) supersedes the prediction, and a
76 // failed command withdraws it (DeviceRegistry::rollback_optimistic()). No-op when this device
77 // has optimistic_state=false (see
78 // DeviceRegistry::apply_optimistic_target()/apply_optimistic_stop()).
79 if (call.get_stop()) {
82 return;
83 }
84
85 const auto &tilt_opt = call.get_tilt();
86 const auto &position_opt = call.get_position();
87
88 // Combined position+tilt in one atomic command when both are present.
89 /// @todo Monitor https://github.com/home-assistant/core/issues/174533 — if HA adds a combined
90 /// cover.set_cover_position_and_tilt action, this branch would be exercised directly from
91 /// a single CoverCall. The queue coalescing in queue_set_device_position/tilt remains a
92 /// useful optimization for the two-separate-calls path regardless.
93 // Position/tilt fraction -> IO percent uses detail::round_percent() (entity_helpers.h) — rounds
94 // rather than truncates, since HA quantizes call values to 0-255 before they reach us (its
95 // "50%" is 128/255=0.502, not exactly 0.5) and a truncating cast would compound that into a
96 // consistent ~1% bias (caught on hardware; see PlatformCover.ControlRoundsQuantizedPosition-
97 // InsteadOfTruncating).
98 if (position_opt.has_value() && tilt_opt.has_value() && this->supports_tilt()) {
99 float const ha_pos = *position_opt;
100 const bool invert = this->effective_invert_();
101 uint8_t const io_pos = invert ? detail::round_percent(ha_pos) : detail::round_percent(1.0F - ha_pos);
102 auto const tilt = detail::round_percent(*tilt_opt);
103 this->parent_->apply_optimistic_target(this->device_id_, io_pos);
104 this->parent_->apply_optimistic_tilt(this->device_id_, tilt);
105 this->parent_->queue_set_device_position_and_tilt(this->device_id_, io_pos, tilt);
106 return;
107 }
108
109 if (tilt_opt.has_value()) {
110 auto const tilt = detail::round_percent(*tilt_opt);
111 // Position commands get their optimistic feedback from apply_optimistic_target() above; tilt
112 // needs its own because a tilt command's reply carries no usable slat angle, so without this
113 // the slider snaps back to the pre-command angle until the next status poll.
114 this->parent_->apply_optimistic_tilt(this->device_id_, tilt);
115 this->parent_->queue_set_device_tilt(this->device_id_, tilt);
116 return;
117 }
118
119 if (position_opt.has_value()) {
120 float const ha_pos = *position_opt; // HA: 1.0 = fully open, 0.0 = fully closed
121 const bool invert = this->effective_invert_();
122
123 // Convert HA position (0.0-1.0) to IO position (0-100)
124 // Standard: HA 1.0 (open) → IO 0 (open), HA 0.0 (closed) → IO 100 (closed)
125 // Inverted: HA 1.0 (open) → IO 100, HA 0.0 (closed) → IO 0
126 // (used for devices like horizontal awnings where the IO convention is reversed)
127 const uint8_t io_pos = invert ? detail::round_percent(ha_pos) : detail::round_percent(1.0F - ha_pos);
128
129 this->parent_->apply_optimistic_target(this->device_id_, io_pos);
130 this->parent_->queue_set_device_position(this->device_id_, io_pos);
131 }
132}
133
134void IOHomeCover::on_device_update_(const std::string &id, const IoDevice &dev) {
135 if (id != this->device_id_)
136 return;
137
138 const bool invert = this->effective_invert_();
139 const float previous_io_position = this->last_io_position_;
140
141 // Predictions win over the last observation, per axis (see OptimisticState). `position` is
142 // observed-only — the hub never guesses a live position — so it is read directly.
143 const float eff_tilt = effective_tilt(dev);
144 const bool eff_stopped = effective_is_stopped(dev);
145 const float eff_target = effective_target(dev);
146
147 if (dev.position != UNKNOWN_POSITION) {
148 // Convert IO position (0-100) back to HA position (0.0-1.0)
149 float ha_pos;
150 if (invert) {
151 ha_pos = dev.position / 100.0F;
152 } else {
153 ha_pos = 1.0F - (dev.position / 100.0F);
154 }
155
156 this->position = ha_pos;
157 }
158
159 if (this->supports_tilt() && eff_tilt != UNKNOWN_POSITION) {
160 this->tilt = eff_tilt / 100.0F;
161 }
162
163 // Determine movement direction for the HA UI animation
164 if (eff_stopped) {
165 this->current_operation = cover::COVER_OPERATION_IDLE;
166 } else if (eff_target != UNKNOWN_POSITION && dev.position != UNKNOWN_POSITION && eff_target != dev.position) {
167 // Travelling toward a target we can see, from a position we can see.
168 this->current_operation = operation_toward(invert, dev.position, eff_target);
169 } else if (eff_target != UNKNOWN_POSITION && dev.position == UNKNOWN_POSITION) {
170 // Moving, target known, live position withheld. Not every actuator publishes intermediate
171 // positions: some report current = POS_UNKNOWN (0xD4) on every poll mid-travel (flagging
172 // themselves "moving" vs. "at rest" instead), and only report a real value once they settle.
173 // The last position we *did* see is enough to say which way the device is going, and
174 // `this->position` keeps displaying that value meanwhile rather than blanking, because the
175 // assignment above is skipped for an unknown reading.
176 this->current_operation = operation_toward(invert, previous_io_position, eff_target);
177 } else if (dev.position != UNKNOWN_POSITION) {
178 // Either no target at all, or a target equal to the current position while the device says it
179 // is moving. The second case is not a standstill: a device flags itself moving while still
180 // reporting its *pre-command* target for roughly the first second, so equal endpoints here mean
181 // "no information yet", not "closing". Fall through to the delta inference, which reports IDLE
182 // until an actual position change reveals the direction.
183 this->current_operation = this->infer_operation_from_position_delta_(invert, dev.position);
184 }
185
186 if (dev.position != UNKNOWN_POSITION) {
187 this->last_io_position_ = dev.position;
188 } else {
189 this->last_io_position_ = previous_io_position;
190 }
191
192 this->publish_state();
193}
194
196 LOG_COVER("", "IO-Homecontrol Cover", this);
197 ESP_LOGCONFIG(TAG, " Device ID: %s", this->device_id_.c_str());
198 ESP_LOGCONFIG(TAG, " Invert Position Override: %s", this->invert_explicit_ ? YESNO(this->invert_) : "AUTO");
200 if (this->rain_sensor_poll_interval_ms_ != 0)
201 ESP_LOGCONFIG(TAG, " Rain Sensor Poll Interval: %" PRIu32 " ms", this->rain_sensor_poll_interval_ms_);
202 ESP_LOGCONFIG(TAG, " Supports Tilt: %s", YESNO(this->supports_tilt()));
203}
204
205} // namespace home_io_control
206} // namespace esphome
void register_device_binding_(Component *self, bool inverted, std::function< void(const std::string &, const IoDevice &)> on_update)
Perform the shared setup() registration ritual.
void log_poll_interval_config_(const char *tag) const
Emit the shared two-branch poll-interval line for dump_config().
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:350
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:326
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, FORCE_OPEN); returns immediately,...
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 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
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.
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).
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.
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,...
@ UNKNOWN
Unknown/unspecified device.
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?
static const char *const TAG
ESPHome cover entity for IO‑Homecontrol devices.
Runtime state of a paired IO‑Homecontrol device.
bool inverted
True if open/close positions are swapped (e.g., horizontal awning).
float position
Current position: 0=open, 100=closed, or UNKNOWN_POSITION.