ESPHome 2026.11.0-dev
Loading...
Searching...
No Matches
midea_portasplit.cpp
Go to the documentation of this file.
1#include "midea_portasplit.h"
3#include "esphome/core/hal.h"
5#include "esphome/core/log.h"
6
8
9ESPHOME_LOG_TAG(TAG, "midea_portasplit");
10
11static constexpr uint32_t FOLLOWUP_TIMEOUT_ID = 0;
12
13static constexpr uint32_t STATUS_INTERVAL_MS = 30000;
14static constexpr uint32_t NETWORK_INTERVAL_MS = 10000;
15static constexpr uint32_t FOLLOWUP_DELAY_MS = 1500;
16// At 9600 baud bytes within a frame arrive about 1 ms apart.
17static constexpr uint32_t RX_TIMEOUT_MS = 100;
18
19// Ten header bytes plus CRC8 and checksum.
20static constexpr size_t FRAME_OVERHEAD = 12;
21static constexpr size_t MAX_TX_PAYLOAD = 24;
22
23static constexpr float MIN_TARGET_TEMP = 16.0f;
24static constexpr float MAX_TARGET_TEMP = 30.0f;
25
26// The AC expects its Wi-Fi module to answer the 0x63 keepalive poll and to send periodic 0x0D network status
27// frames. These frames use the XOR sync byte (0x1E ^ 0xAC = 0xB2). The AC does not use the IP address; the
28// fixed frame below was captured from the original module.
29static constexpr uint8_t MODULE_FRAME[31] PROGMEM = {0xAA, 0x1E, 0xAC, 0xB2, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01,
30 0x01, 0x04, 0xC0, 0xA8, 0xB2, 0x72, 0x00, 0x00, 0x00, 0x01, 0x01,
31 0x01, 0x28, 0x09, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00};
32static constexpr uint8_t STATUS_QUERY[12] PROGMEM = {0x41, 0x21, 0x00, 0xFF, 0x03, 0xFF,
33 0x00, 0x02, 0x00, 0x00, 0x00, 0x00};
34// Same capability queries the original module sends at boot.
35static constexpr uint8_t CAPS_0[] PROGMEM = {0xB5, 0x01, 0x00};
36static constexpr uint8_t CAPS_1[] PROGMEM = {0xB5, 0x01, 0x01, 0x01};
37
38// Two's complement of the sum of all bytes after the 0xAA start marker.
39static uint8_t checksum(const uint8_t *frame, size_t len) { return (uint8_t) -sum_bytes(frame + 1, len - 1); }
40
41static uint8_t climate_mode_to_midea(climate::ClimateMode mode) {
42 switch (mode) {
44 return 2;
46 return 3;
48 return 4;
50 return 5;
52 default:
53 return 1;
54 }
55}
56
57static climate::ClimateMode midea_mode_to_climate(uint8_t mode) {
58 switch (mode) {
59 case 2:
61 case 3:
63 case 4:
65 case 5:
67 case 1:
68 default:
70 }
71}
72
73static uint8_t fan_mode_to_midea(climate::ClimateFanMode mode) {
74 switch (mode) {
76 return 20;
78 return 40;
80 return 60;
82 return 80;
83 default:
84 return FAN_AUTO;
85 }
86}
87
88// The AC reports its own fan steps (30 for low, 50 for medium), so classify by range.
89static climate::ClimateFanMode midea_fan_to_climate(uint8_t fan) {
90 if (fan > 100)
92 if (fan <= 30)
94 if (fan <= 50)
96 if (fan <= 70)
99}
100
102 // The status interval starts with a random offset of up to 5 s, which also serves as the boot query.
103 this->set_timeout(10000, [this]() { this->send_capability_queries_(); });
104 this->set_interval(STATUS_INTERVAL_MS, [this]() { this->send_status_query_(); });
105 this->set_interval(NETWORK_INTERVAL_MS, [this]() { this->send_module_frame_(0x0D, this->net_counter_++, 0x08); });
106}
107
110 uint8_t buf[32];
111 size_t n;
112 while ((n = std::min((size_t) this->available(), sizeof(buf))) != 0 && this->read_array(buf, n)) {
113 for (size_t i = 0; i < n; i++)
114 this->rx_feed_(buf[i]);
115 this->rx_last_byte_ = now;
116 }
117 // The line went quiet mid-frame; drop it so its bytes cannot swallow the next one.
118 if (this->rx_len_ != 0 && now - this->rx_last_byte_ > RX_TIMEOUT_MS)
119 this->rx_len_ = 0;
120}
121
123 ESP_LOGCONFIG(TAG, "Midea PortaSplit:");
124 this->dump_traits_(TAG);
125}
126
159
161 if (call.get_mode().has_value()) {
162 const climate::ClimateMode mode = *call.get_mode();
163 this->st_power_ = mode != climate::CLIMATE_MODE_OFF;
164 if (this->st_power_)
165 this->st_mode_ = climate_mode_to_midea(mode);
166 // Frost protection only runs while heating.
168 this->st_frost_ = false;
169 }
170 if (call.get_target_temperature().has_value()) {
171 const float target = clamp(*call.get_target_temperature(), MIN_TARGET_TEMP, MAX_TARGET_TEMP);
172 this->st_target_half_ = (uint8_t) lroundf(target * 2.0f);
173 }
174 if (call.get_fan_mode().has_value())
175 this->st_fan_ = fan_mode_to_midea(*call.get_fan_mode());
176 if (call.get_swing_mode().has_value())
178 if (call.get_preset().has_value()) {
179 const climate::ClimatePreset preset = *call.get_preset();
182 this->st_eco_ = preset == climate::CLIMATE_PRESET_ECO;
183 this->st_frost_ = preset == climate::CLIMATE_PRESET_AWAY;
184 if (this->st_frost_) {
185 this->st_mode_ = climate_mode_to_midea(climate::CLIMATE_MODE_HEAT);
186 this->st_power_ = true;
187 }
188 }
189 this->send_set_command_();
190}
191
192void PortaSplitClimate::send_frame_(uint8_t proto, uint8_t type, const uint8_t *payload, size_t len) {
193 uint8_t f[FRAME_OVERHEAD + MAX_TX_PAYLOAD] = {0};
194 const size_t total = FRAME_OVERHEAD + len;
195 f[0] = 0xAA;
196 f[1] = (uint8_t) (total - 1);
197 f[2] = 0xAC;
198 // f[3] is the sync byte and must stay 0x00 on the PortaSplit; f[4..7] are reserved.
199 f[8] = proto;
200 f[9] = type;
201 progmem_memcpy(&f[10], payload, len);
202 f[10 + len] = crc8(&f[10], (uint8_t) len);
203 f[11 + len] = checksum(f, 11 + len);
204 this->write_array(f, total);
205#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
206 char hex_buf[format_hex_pretty_size(sizeof(f))];
207 ESP_LOGV(TAG, "TX: %s", format_hex_pretty_to(hex_buf, f, total));
208#endif
209}
210
212 uint8_t p[MAX_TX_PAYLOAD] = {0};
213 const uint8_t whole = this->st_target_half_ >> 1;
214 const bool half = this->st_target_half_ & 1;
215 p[0] = 0x40;
216 p[1] = 0x42; // 0x02 is mandatory, 0x40 enables the beeper
217 if (this->st_power_)
218 p[1] |= 0x01;
219 p[2] = (uint8_t) ((this->st_mode_ << 5) | (half ? 0x10 : 0x00) | ((whole - 16) & 0x0F));
220 p[3] = this->st_fan_;
221 p[4] = 0x7F;
222 p[5] = 0x7F;
223 p[6] = 0xF0;
224 p[7] = this->st_swing_ ? 0x3C : 0x30;
225 p[8] = this->st_turbo_ ? 0x20 : 0x00;
226 p[9] = (uint8_t) ((this->st_ion_ ? 0x20 : 0x00) | (this->st_eco_ ? 0x80 : 0x00));
227 p[10] = this->st_sleep_ ? 0x01 : 0x00;
228 p[18] = (uint8_t) ((whole - 12) & 0x1F);
229 p[19] = 0x80;
230 p[21] = 0x0A;
231 p[22] = this->st_frost_ ? 0x80 : 0x00;
232 p[23] = this->msg_id_++;
233 this->send_frame_(0x02, 0x02, p, sizeof(p));
234 // Read back the state the AC actually applied.
235 this->set_timeout(FOLLOWUP_TIMEOUT_ID, FOLLOWUP_DELAY_MS, [this]() { this->send_status_query_(); });
236}
237
239 uint8_t p[sizeof(STATUS_QUERY)];
240 progmem_memcpy(p, STATUS_QUERY, sizeof(p));
241 p[11] = this->msg_id_++;
242 this->send_frame_(0x00, 0x03, p, sizeof(p));
243}
244
246 this->send_frame_(0x03, 0x03, CAPS_0, sizeof(CAPS_0));
247 this->set_timeout(1000, [this]() { this->send_frame_(0x03, 0x03, CAPS_1, sizeof(CAPS_1)); });
248}
249
250void PortaSplitClimate::send_module_frame_(uint8_t type, uint8_t counter, uint8_t wifi_flags) {
251 uint8_t f[sizeof(MODULE_FRAME)];
252 progmem_memcpy(f, MODULE_FRAME, sizeof(f));
253 f[6] = counter;
254 f[9] = type;
255 f[25] = wifi_flags;
256 f[30] = checksum(f, 30);
257 this->write_array(f, sizeof(f));
258}
259
261 if (this->rx_len_ == 0 && b != 0xAA)
262 return;
263 this->rx_[this->rx_len_++] = b;
264 if (this->rx_len_ < 3)
265 return;
266 const size_t total = (size_t) this->rx_[1] + 1;
267 // Every frame carries the appliance type 0xAC; anything else is a false start.
268 if (this->rx_len_ == 3 && (total < FRAME_OVERHEAD || total > MAX_FRAME || this->rx_[2] != 0xAC)) {
269 this->rx_len_ = 0;
270 return;
271 }
272 if (this->rx_len_ < total)
273 return;
274 this->rx_len_ = 0;
275 if (checksum(this->rx_.data(), total - 1) != this->rx_[total - 1]) {
276 ESP_LOGW(TAG, "RX checksum mismatch, dropping frame");
277 return;
278 }
279#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
280 char hex_buf[format_hex_pretty_size(MAX_FRAME)];
281 ESP_LOGV(TAG, "RX: %s", format_hex_pretty_to(hex_buf, this->rx_.data(), total));
282#endif
283 this->handle_frame_(this->rx_.data(), total);
284}
285
286void PortaSplitClimate::handle_frame_(const uint8_t *f, size_t len) {
287 if (f[9] == 0x63) {
288 this->send_module_frame_(0x63, 0, 0x00);
289 } else if (f[9] == 0x05 && f[10] == 0xB5) {
290 // The original module acknowledges telemetry pushes by echoing them back.
291 this->write_array(f, len);
292 } else if (f[10] == 0xC0) {
293 this->handle_c0_(f, len);
294 }
295}
296
297void PortaSplitClimate::handle_c0_(const uint8_t *f, size_t len) {
298 if (len < 26)
299 return;
300 const uint8_t mode = (f[12] >> 5) & 0x07;
301 const uint8_t target_half = (uint8_t) ((((f[12] & 0x0F) + 16) << 1) | ((f[12] & 0x10) ? 1 : 0));
302 const climate::ClimateFanMode fan = midea_fan_to_climate(f[13]);
303
304 this->st_power_ = f[11] & 0x01;
305 if (mode >= 1 && mode <= 5)
306 this->st_mode_ = mode;
307 this->st_target_half_ = target_half;
308 this->st_fan_ = fan_mode_to_midea(fan);
309 this->st_swing_ = (f[17] & 0x0F) == 0x0C;
310 this->st_turbo_ = f[18] & 0x20;
311 this->st_ion_ = f[19] & 0x20;
312 this->st_eco_ = f[19] & 0x10; // Set command uses bit 7, readback uses bit 4.
313 this->st_sleep_ = f[20] & 0x01;
314 this->st_frost_ = len > 31 && (f[31] & 0x80);
315
316 this->mode = this->st_power_ ? midea_mode_to_climate(this->st_mode_) : climate::CLIMATE_MODE_OFF;
317 this->target_temperature = target_half / 2.0f;
318 // 0x00 and 0xFF mean the indoor probe gave no reading.
319 const uint8_t raw_temp = f[21];
320 this->current_temperature = (raw_temp == 0x00 || raw_temp == 0xFF) ? NAN : ((float) raw_temp - 50.0f) / 2.0f;
321 this->fan_mode = fan;
323 if (this->st_frost_) {
325 } else if (this->st_turbo_) {
327 } else if (this->st_sleep_) {
329 } else if (this->st_eco_) {
331 } else {
333 }
334 this->publish_state();
335}
336
337} // namespace esphome::midea_portasplit
BedjetMode mode
BedJet operating mode.
uint8_t checksum
Definition bl0906.h:3
uint32_t IRAM_ATTR HOT get_loop_component_start_time() const
Get the cached time in milliseconds from when the current component started its loop execution.
void set_timeout(const char *name, uint32_t timeout, std::function< void()> &&f)
Set a timeout function with a const char* name.
Definition component.cpp:96
void set_interval(const char *name, uint32_t interval, std::function< void()> &&f)
Set an interval function with a const char* name.
Definition component.cpp:88
This class is used to encode all control actions on a climate device.
Definition climate.h:34
const optional< ClimateSwingMode > & get_swing_mode() const
Definition climate.cpp:314
const optional< float > & get_target_temperature() const
Definition climate.cpp:307
ClimateMode mode
The active mode of the climate device.
Definition climate.h:304
optional< ClimateFanMode > fan_mode
The active fan mode of the climate device.
Definition climate.h:298
float target_temperature
The target temperature of the climate device.
Definition climate.h:285
void dump_traits_(const char *tag)
Definition climate.cpp:706
ClimateSwingMode swing_mode
The active swing mode of the climate device.
Definition climate.h:310
float current_temperature
The current temperature of the climate device, as reported from the integration.
Definition climate.h:278
void publish_state()
Publish the state of the climate device, to be called from integrations.
Definition climate.cpp:429
optional< ClimatePreset > preset
The active preset of the climate device.
Definition climate.h:301
void set_visual_max_temperature(float visual_max_temperature)
void add_feature_flags(uint32_t feature_flags)
void set_visual_temperature_step(float temperature_step)
void set_supported_presets(ClimatePresetMask presets)
void set_supported_swing_modes(ClimateSwingModeMask modes)
void set_visual_min_temperature(float visual_min_temperature)
void set_supported_modes(ClimateModeMask modes)
void set_supported_fan_modes(ClimateFanModeMask modes)
void handle_frame_(const uint8_t *f, size_t len)
void handle_c0_(const uint8_t *f, size_t len)
climate::ClimateTraits traits() override
void control(const climate::ClimateCall &call) override
std::array< uint8_t, MAX_FRAME > rx_
void send_module_frame_(uint8_t type, uint8_t counter, uint8_t wifi_flags)
void send_frame_(uint8_t proto, uint8_t type, const uint8_t *payload, size_t len)
optional< std::array< uint8_t, N > > read_array()
Definition uart.h:40
void write_array(const uint8_t *data, size_t len)
Definition uart.h:27
uint8_t type
u_int8_t raw_temp
constexpr float BME680_GAS_LOOKUP_TABLE_1[16] PROGMEM
Definition bme680.cpp:26
@ CLIMATE_SUPPORTS_CURRENT_TEMPERATURE
ClimatePreset
Enum for all preset modes NOTE: If adding values, update ClimatePresetMask in climate_traits....
@ CLIMATE_PRESET_NONE
No preset is active.
@ CLIMATE_PRESET_AWAY
Device is in away preset.
@ CLIMATE_PRESET_BOOST
Device is in boost preset.
@ CLIMATE_PRESET_SLEEP
Device is prepared for sleep.
@ CLIMATE_PRESET_ECO
Device is running an energy-saving preset.
@ CLIMATE_SWING_OFF
The swing mode is set to Off.
@ CLIMATE_SWING_VERTICAL
The fan mode is set to Vertical.
ClimateMode
Enum for all modes a climate device can be in.
@ CLIMATE_MODE_DRY
The climate device is set to dry/humidity mode.
@ CLIMATE_MODE_FAN_ONLY
The climate device only has the fan enabled, no heating or cooling is taking place.
@ CLIMATE_MODE_HEAT
The climate device is set to heat to reach the target temperature.
@ CLIMATE_MODE_COOL
The climate device is set to cool to reach the target temperature.
@ CLIMATE_MODE_HEAT_COOL
The climate device is set to heat/cool to reach the target temperature.
@ CLIMATE_MODE_OFF
The climate device is off.
ClimateFanMode
NOTE: If adding values, update ClimateFanModeMask in climate_traits.h to use the new last value.
@ CLIMATE_FAN_MEDIUM
The fan mode is set to Medium.
@ CLIMATE_FAN_AUTO
The fan mode is set to Auto.
@ CLIMATE_FAN_LOW
The fan mode is set to Low.
@ CLIMATE_FAN_QUIET
The fan mode is set to Quiet.
@ CLIMATE_FAN_HIGH
The fan mode is set to High.
ESPHOME_LOG_TAG(TAG, "midea_portasplit")
constexpr uint8_t sum_bytes(const uint8_t *data, size_t len, uint8_t sum=0)
Add up the bytes of data with size len, truncated to 8 bits: the checksum many serial and IR protocol...
Definition helpers.h:886
void progmem_memcpy(void *dst, const void *src, size_t len)
Definition hal.h:56
const void size_t len
Definition hal.h:68
char * format_hex_pretty_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator)
Format byte array as uppercase hex to buffer (base implementation).
Definition helpers.cpp:498
constexpr size_t format_hex_pretty_size(size_t byte_count)
Calculate buffer size needed for format_hex_pretty_to with separator: "XX:XX:...:XX\0".
Definition helpers.h:1533
uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc, uint8_t poly, bool msb_first)
Calculate a CRC-8 checksum of data with size len.
Definition helpers.cpp:68
Application App
Global storage of Application pointer - only one Application can exist.
static void uint32_t