Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
entity_helpers.h
Go to the documentation of this file.
1#pragma once
2
3/// @file entity_helpers.h
4/// @brief Conversions and renderers shared by the hub and its Home Assistant entities.
5/// @ingroup hioc_hub
6///
7/// Carries no dependency on the hub itself, so an entity can include this header instead of the
8/// hub's private helpers (make include-graph enforces that split).
9
10#include "log_helpers.h"
11#include "proto_codecs.h"
12#include "proto_constants.h"
13#include "proto_device_model.h"
14#include "proto_frame.h"
15#include "proto_sizes.h"
16
17#include <array>
18#include <cinttypes>
19#include <cmath>
20#include <cstdint>
21#include <cstdio>
22#include <cstring>
23#include <string>
24
25namespace esphome {
26namespace home_io_control {
27namespace detail {
28
29// ============================================================================
30// Shared constants
31// ============================================================================
32
34 50.0F; ///< Shared 0-100 cutoff: values below this mean binary "on".
35
36// ============================================================================
37// Percent conversion helpers
38// ============================================================================
39
40/// @brief Convert a 0.0-1.0 HA fraction (position, tilt, or brightness) to a 0-100 IO percent.
41///
42/// Rounds rather than truncates: HA quantizes call values to 0-255 before they ever reach us, so
43/// its "50%" is 128/255=0.50196, not exactly 0.5 — a truncating cast compounds that quantization
44/// into a consistent ~1% bias, caught on real hardware in both platform_cover.cpp (position and
45/// tilt) and platform_light.cpp (brightness). Callers apply their own invert/complement logic
46/// (e.g. `1.0F - fraction`) before calling this; it only owns the rounding.
47/// @param fraction Value in [0.0, 1.0].
48/// @return Rounded 0-100 percent.
49inline uint8_t round_percent(float fraction) { return static_cast<uint8_t>(std::lround(fraction * 100.0F)); }
50
51/// @brief Round an IO percent (0-100) to a whole number, for publishing to HA.
52///
53/// HA shows `round(position * 100)` but calls a cover closed only at exactly 0.0, so a cover resting
54/// at 99.68 % closed would show "0 %" and "open" at once (issue #151). Publishing whole percent
55/// keeps the displayed number and the open/closed state in agreement.
56/// @param io_percent IO percent in [0.0, 100.0].
57/// @return The same percent rounded to the nearest whole number.
58inline float whole_percent(float io_percent) { return std::round(io_percent); }
59
60// ============================================================================
61// Last-command record rendering
62// ============================================================================
63
64/// @brief Render a Command Originator byte as "name(0xXX)", e.g. "user_remote(0x01)".
65///
66/// The one rendering every originator display shares (the "Last Command Source" sensor and the 0x71
67/// status-update log line), so a byte with no ORIGINATOR_* case reads "unknown(0x0A)" everywhere
68/// rather than being silently dropped or mislabelled.
69/// @param originator Command Originator byte (ORIGINATOR_*).
70/// @return "name(0xXX)".
71inline std::string format_originator(uint8_t originator) {
72 return format_name_and_hex(originator_name(originator), originator);
73}
74
75/// @brief Render the "Last Commanded By" sensor string.
76///
77/// Always leads with the raw node ID — that is the diagnostic value, and the only thing a user can
78/// match against a remote they own. The qualifier is additive, never a substitute: a device naming
79/// its own ID is NOT reliably "the button on the motor" (the one non-shutter this project has data
80/// on, a mains gate, names its own ID with an undefined originator), so the cause belongs to the
81/// separate originator sensor, not to this one's wording.
82/// @param dev Device record to read.
83/// @param hub_node_id This hub's own 3-byte node ID.
84/// @return e.g. "3B74DC", "C0FFEE (this hub)", "2FE2D2 (this device)"; empty before the first record.
85inline std::string describe_last_commander(const IoDevice &dev, const uint8_t *hub_node_id) {
86 if (!dev.has_last_command)
87 return {};
88 std::string out = node_id_to_string(dev.last_commander);
89 if (memcmp(dev.last_commander, hub_node_id, NODE_ID_SIZE) == 0) {
90 out += " (this hub)";
91 } else if (memcmp(dev.last_commander, dev.node_id, NODE_ID_SIZE) == 0) {
92 out += " (this device)";
93 }
94 return out;
95}
96
97/// @brief Render the "Last Command Source" sensor string.
98///
99/// Rendered by format_originator(), so an undecoded byte keeps its raw hex. The decode is
100/// field-validated for roller shutters (a clean 0x00/0x01 split, remote vs. motor
101/// button); gates, lights and multi-channel units are not validated and are expected to surface
102/// undecoded values here — which is the point of keeping the raw hex in the string.
103/// @param dev Device record to read.
104/// @return e.g. "user_remote(0x01)"; empty before the first record.
105inline std::string describe_last_command_source(const IoDevice &dev) {
106 if (!dev.has_last_command)
107 return {};
109}
110
111// ============================================================================
112// Limitation read rendering
113// ============================================================================
114
115/// Buffer size for the limitation fragments below: the longest is "selector (D8 01)" or "0xFD (unlimited)".
116inline constexpr size_t LIMITATION_TEXT_BUFFER_SIZE = 32;
117
118/// @brief Render a limitation parameter id: "MP" for the main parameter, "FP<n>" for 1-16, else hex.
119inline std::string format_limitation_param(uint8_t parameter_id) {
120 if (parameter_id == LIMITATION_PARAM_MP) {
121 return "MP";
122 }
123 std::array<char, LIMITATION_TEXT_BUFFER_SIZE> buffer{};
124 if (parameter_id >= LIMITATION_PARAM_FP_FIRST && parameter_id <= LIMITATION_PARAM_FP_LAST) {
125 std::snprintf(buffer.data(), buffer.size(), "FP%u", parameter_id);
126 } else {
127 std::snprintf(buffer.data(), buffer.size(), "0x%02X", parameter_id);
128 }
129 return std::string(buffer.data());
130}
131
132/// @brief Render a limitation value: a percentage, or the raw selector when it is not a position.
133///
134/// Values above STATUS_POS_MAX are position selectors (stop, unknown, ...), never percentages, so
135/// they print as `selector (D8 01)`. A percentage always keeps its raw bytes beside it, so a wrong
136/// layout assumption stays visible in the log.
137/// @param value_raw Big-endian raw value (LimitationStatus::value_raw).
138/// @return e.g. "93% (BA 00)", "46.5% (5D 00)" or "selector (D8 01)".
139inline std::string format_limitation_value(uint16_t value_raw) {
140 std::array<char, LIMITATION_TEXT_BUFFER_SIZE> buffer{};
141 const unsigned hi = value_raw >> BITS_PER_BYTE;
142 const unsigned lo = value_raw & 0xFFU;
143 if (value_raw > STATUS_POS_MAX) {
144 std::snprintf(buffer.data(), buffer.size(), "selector (%02X %02X)", hi, lo);
145 return std::string(buffer.data());
146 }
147 const double percent = value_raw * 100.0 / STATUS_POS_MAX;
148 const bool whole = (value_raw * 100U) % STATUS_POS_MAX == 0;
149 std::snprintf(buffer.data(), buffer.size(), whole ? "%.0f%% (%02X %02X)" : "%.1f%% (%02X %02X)", percent, hi, lo);
150 return std::string(buffer.data());
151}
152
153/// @brief Render a limitation time code with its raw byte: "0x1D (900 s)", "0xFD (unlimited)", "0xFE (code 254)".
154inline std::string format_limitation_time(uint8_t time_raw) {
155 std::array<char, LIMITATION_TEXT_BUFFER_SIZE> buffer{};
156 if (time_raw == LIMITATION_TIME_UNLIMITED) {
157 std::snprintf(buffer.data(), buffer.size(), "0x%02X (unlimited)", time_raw);
158 } else if (time_raw > LIMITATION_TIME_MAX_COUNTED) {
159 std::snprintf(buffer.data(), buffer.size(), "0x%02X (code %u)", time_raw, time_raw);
160 } else {
161 std::snprintf(buffer.data(), buffer.size(), "0x%02X (%" PRIu32 " s)", time_raw, limitation_time_seconds(time_raw));
162 }
163 return std::string(buffer.data());
164}
165
166/// @brief Render a decoded limitation reply on one line, raw bytes beside every decoded value.
167/// @return e.g. "limitation (assumed layout): param=MP value=93% (BA 00) originator=rain_sensor(0x02) time=0x1D (900
168/// s)".
169inline std::string format_limitation_status(const LimitationStatus &status) {
170 return "limitation (assumed layout): param=" + format_limitation_param(status.parameter_id) +
171 " value=" + format_limitation_value(status.value_raw) + " originator=" + format_originator(status.originator) +
172 " time=" + format_limitation_time(status.time_raw);
173}
174
175/// @brief Render a CMD_LIMITATION_STATUS_RESP frame for a log line or probe report.
176/// @param frame A parsed CMD_LIMITATION_STATUS_RESP frame.
177/// @return format_limitation_status() of the decode, or "limitation (assumed layout): unexpected
178/// length N" when the payload is not the assumed five bytes.
179inline std::string describe_limitation_reply(const IoFrame &frame) {
180 LimitationStatus status;
181 if (!decode_limitation_status(frame, status))
182 return "limitation (assumed layout): unexpected length " + std::to_string(frame.data_len);
183 return format_limitation_status(status);
184}
185
186/// @brief Render a CMD_LIMITATION_STATUS_REQ frame for a log line.
187/// @param frame A parsed CMD_LIMITATION_STATUS_REQ frame.
188/// @return e.g. "limitation request (assumed layout): type=minimum param=MP", or "... unexpected payload"
189/// when the length or selector is not one of the known shapes.
190inline std::string describe_limitation_request(const IoFrame &frame) {
191 LimitationType type;
192 uint8_t parameter_id = 0;
193 if (!decode_limitation_request(frame, type, parameter_id))
194 return "limitation request (assumed layout): unexpected payload";
195 return std::string("limitation request (assumed layout): type=") +
196 (type == LimitationType::MINIMUM ? "minimum" : "maximum") + " param=" + format_limitation_param(parameter_id);
197}
198
199} // namespace detail
200} // namespace home_io_control
201} // namespace esphome
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
constexpr size_t LIMITATION_TEXT_BUFFER_SIZE
Buffer size for the limitation fragments below: the longest is "selector (D8 01)" or "0xFD (unlimited...
std::string describe_limitation_reply(const IoFrame &frame)
Render a CMD_LIMITATION_STATUS_RESP frame for a log line or probe report.
std::string format_name_and_hex(const char *name, uint8_t value)
Format a name/value pair as "name(0xXX)", e.g. "execute(0x00)".
Definition log_helpers.h:45
std::string format_limitation_param(uint8_t parameter_id)
Render a limitation parameter id: "MP" for the main parameter, "FP<n>" for 1-16, else hex.
constexpr float BINARY_ENTITY_ON_POSITION_THRESHOLD
Shared 0-100 cutoff: values below this mean binary "on".
std::string describe_last_command_source(const IoDevice &dev)
Render the "Last Command Source" sensor string.
std::string format_limitation_value(uint16_t value_raw)
Render a limitation value: a percentage, or the raw selector when it is not a position.
std::string describe_limitation_request(const IoFrame &frame)
Render a CMD_LIMITATION_STATUS_REQ frame for a log line.
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.
std::string format_limitation_status(const LimitationStatus &status)
Render a decoded limitation reply on one line, raw bytes beside every decoded value.
std::string format_limitation_time(uint8_t time_raw)
Render a limitation time code with its raw byte: "0x1D (900 s)", "0xFD (unlimited)",...
std::string describe_last_commander(const IoDevice &dev, const uint8_t *hub_node_id)
Render the "Last Commanded By" sensor string.
std::string format_originator(uint8_t originator)
Render a Command Originator byte as "name(0xXX)", e.g.
bool decode_limitation_status(const IoFrame &f, LimitationStatus &out)
Decode a limitation reply (assumed layout, see LimitationStatus).
uint32_t limitation_time_seconds(uint8_t time_raw)
Remaining time of a limitation in seconds.
bool decode_limitation_request(const IoFrame &f, LimitationType &type, uint8_t &parameter_id)
Decode a limitation request (assumed layout [selector] [parameter id] [00]).
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
LimitationType
On-air selector byte (request data[0]) choosing which resulting limit to read.
@ MINIMUM
Resulting minimum limit; rain protection raises it (a floor on the position value).
const char * originator_name(uint8_t originator)
Get a human-readable name for a command originator byte.
Device-name, address-classification and 1W-frame codecs.
IO-Homecontrol command IDs, result codes and protocol enumerations.
IO-Homecontrol device-type model, capabilities and runtime device state.
IO-Homecontrol 2W frame container: control bytes, IoFrame and (de)serialization.
Fundamental IO-Homecontrol frame and crypto size constants.
Runtime state of a paired IO‑Homecontrol device.
uint8_t last_commander[NODE_ID_SIZE]
Node ID of the controller that last commanded this device, as reported verbatim by the device in its ...
uint8_t last_command_originator
That command's Command Originator byte (ORIGINATOR_* in proto_constants.h).
uint8_t node_id[NODE_ID_SIZE]
Device's 3‑byte radio address.
bool has_last_command
True once a status reply carried a well-formed last-command record.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t data_len
Actual length of data.
One decoded CMD_LIMITATION_STATUS_RESP payload.
uint8_t parameter_id
data[0]; LIMITATION_PARAM_MP for the main parameter.
uint8_t originator
data[3], Command Originator that imposed the limit (ORIGINATOR_*).
uint16_t value_raw
data[1..2], big-endian, position coding (STATUS_POS_MAX = 100 %).
uint8_t time_raw
data[4], remaining-time code; see limitation_time_seconds().