Reverse engineering del protocollo IR (HITACHI_AC 28 byte) del telecomando RAR-6NE1 e custom component ESPHome bidirezionale (TX + RX) per Home Assistant. - esphome/: custom component hitachi_rar6ne1 (climate_ir::ClimateIR) + config - src/: firmware Arduino di cattura IR con web UI (strumento di diagnostica) - README.md: documentazione completa (protocollo, decode, checksum, gotcha) - Segreti esclusi dal versionamento (vedi *.example e .gitignore) - Licenza GPL-3.0 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
257 lines
8.8 KiB
C++
257 lines
8.8 KiB
C++
#include "hitachi_rar6ne1.h"
|
|
#include "esphome/core/log.h"
|
|
#include "esphome/core/helpers.h"
|
|
#include <cstring>
|
|
#include <cmath>
|
|
|
|
namespace esphome {
|
|
namespace hitachi_rar6ne1 {
|
|
|
|
static const char *const TAG = "hitachi_rar6ne1.climate";
|
|
|
|
// Stato di riferimento reale del RAR-6NE1 (ON 26C Cool). Fornisce tutti i byte
|
|
// "fissi" (intestazione/costanti); i campi variabili vengono sovrascritti in
|
|
// build_state_(). Indici: 0..27.
|
|
static const uint8_t BASE_STATE[kStateLength] = {
|
|
0x80, 0x08, 0x0C, 0x02, 0xFD, 0x80, 0x7F, 0x88,
|
|
0x48, 0xC0, 0x20, 0x2C, 0x00, 0x20, 0xE0, 0xE0,
|
|
0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
|
0x80, 0x30, 0x00, 0x41};
|
|
|
|
// Frame OFF realmente catturato dal RAR-6NE1 (checksum 0x40 verificato): usato
|
|
// dal test self-loopback per avere un riferimento deterministico e sicuro.
|
|
static const uint8_t REF_OFF[kStateLength] = {
|
|
0x80, 0x08, 0x0C, 0x02, 0xFD, 0x80, 0x7F, 0x88,
|
|
0x48, 0xC0, 0x20, 0x2C, 0x00, 0x20, 0xE0, 0xE0,
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
|
0x80, 0x30, 0x00, 0x40};
|
|
|
|
// Inverte l'ordine dei bit di un byte (MSB<->LSB).
|
|
static inline uint8_t reverse_bits8(uint8_t x) {
|
|
uint8_t r = 0;
|
|
for (uint8_t i = 0; i < 8; i++)
|
|
r |= static_cast<uint8_t>(((x >> i) & 0x01) << (7 - i));
|
|
return r;
|
|
}
|
|
|
|
uint8_t HitachiRar6ne1Climate::calc_checksum_(const uint8_t state[kStateLength]) {
|
|
uint8_t sum = 62;
|
|
for (uint8_t i = 0; i < kStateLength - 1; i++)
|
|
sum -= reverse_bits8(state[i]);
|
|
return reverse_bits8(sum);
|
|
}
|
|
|
|
void HitachiRar6ne1Climate::build_state_(uint8_t state[kStateLength]) {
|
|
std::memcpy(state, BASE_STATE, kStateLength);
|
|
|
|
// byte[9]: codice tasto (vedi nota in .h). Variabile #1 da testare.
|
|
state[9] = kButtonCode;
|
|
|
|
const bool power_on = this->mode != climate::CLIMATE_MODE_OFF;
|
|
|
|
// Mode (byte[10]). In OFF teniamo Cool come modalita' di base nel frame.
|
|
switch (this->mode) {
|
|
case climate::CLIMATE_MODE_COOL: state[10] = 0x20; break;
|
|
case climate::CLIMATE_MODE_HEAT: state[10] = 0xC0; break;
|
|
case climate::CLIMATE_MODE_DRY: state[10] = 0xA0; break;
|
|
case climate::CLIMATE_MODE_FAN_ONLY: state[10] = 0x30; break;
|
|
case climate::CLIMATE_MODE_HEAT_COOL: state[10] = 0x40; break;
|
|
case climate::CLIMATE_MODE_OFF:
|
|
default: state[10] = 0x20; break;
|
|
}
|
|
|
|
// Temp (byte[11]) = reverseBits(C*2). In Fan-only non si applica -> 0x01.
|
|
if (this->mode == climate::CLIMATE_MODE_FAN_ONLY) {
|
|
state[11] = 0x01;
|
|
} else {
|
|
float tc = clamp(this->target_temperature, kMinTempC, kMaxTempC);
|
|
uint8_t t = static_cast<uint8_t>(lroundf(tc));
|
|
state[11] = reverse_bits8(static_cast<uint8_t>(t * 2));
|
|
}
|
|
|
|
// Fan speed (byte[13]). In Dry l'unita' forza 2/4 (0xC0).
|
|
if (this->mode == climate::CLIMATE_MODE_DRY) {
|
|
state[13] = 0xC0;
|
|
} else {
|
|
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) {
|
|
case climate::CLIMATE_FAN_QUIET: state[13] = 0x40; break; // 1/4
|
|
case climate::CLIMATE_FAN_LOW: state[13] = 0xC0; break; // 2/4
|
|
case climate::CLIMATE_FAN_MEDIUM: state[13] = 0x20; break; // 3/4
|
|
case climate::CLIMATE_FAN_HIGH: state[13] = 0xA0; break; // 4/4
|
|
case climate::CLIMATE_FAN_AUTO:
|
|
default: state[13] = 0x80; break;
|
|
}
|
|
}
|
|
|
|
// Swing V (byte[14]) / H (byte[15]): 0xE0 = on, 0x60 = off.
|
|
const bool swing_v = this->swing_mode == climate::CLIMATE_SWING_VERTICAL ||
|
|
this->swing_mode == climate::CLIMATE_SWING_BOTH;
|
|
const bool swing_h = this->swing_mode == climate::CLIMATE_SWING_HORIZONTAL ||
|
|
this->swing_mode == climate::CLIMATE_SWING_BOTH;
|
|
state[14] = swing_v ? 0xE0 : 0x60;
|
|
state[15] = swing_h ? 0xE0 : 0x60;
|
|
|
|
// Power (byte[17] bit0).
|
|
if (power_on)
|
|
state[17] |= 0x01;
|
|
else
|
|
state[17] &= ~0x01;
|
|
|
|
// Preset: Eco -> Silent (byte[26] bit4), Boost -> Powerful (byte[25] bit2).
|
|
const climate::ClimatePreset preset =
|
|
this->preset.value_or(climate::CLIMATE_PRESET_NONE);
|
|
if (preset == climate::CLIMATE_PRESET_ECO)
|
|
state[26] |= 0x10;
|
|
else
|
|
state[26] &= ~0x10;
|
|
if (preset == climate::CLIMATE_PRESET_BOOST)
|
|
state[25] |= 0x04;
|
|
else
|
|
state[25] &= ~0x04;
|
|
|
|
// Checksum (byte[27]).
|
|
state[27] = calc_checksum_(state);
|
|
}
|
|
|
|
void HitachiRar6ne1Climate::transmit_frame_(const uint8_t state[kStateLength]) {
|
|
ESP_LOGD(TAG,
|
|
"TX: mode[10]=0x%02X temp[11]=0x%02X fan[13]=0x%02X "
|
|
"pwr[17]=0x%02X tasto[9]=0x%02X ck[27]=0x%02X",
|
|
state[10], state[11], state[13], state[17], state[9], state[27]);
|
|
|
|
auto transmit = this->transmitter_->transmit();
|
|
auto *data = transmit.get_data();
|
|
data->set_carrier_frequency(kCarrierFrequency);
|
|
data->reserve(2 + kStateLength * 8 * 2 + 2);
|
|
|
|
data->mark(kHeaderMark);
|
|
data->space(kHeaderSpace);
|
|
for (uint8_t i = 0; i < kStateLength; i++) {
|
|
for (int8_t bit = 7; bit >= 0; bit--) { // MSB-first
|
|
data->mark(kBitMark);
|
|
data->space((state[i] & (1 << bit)) ? kOneSpace : kZeroSpace);
|
|
}
|
|
}
|
|
data->mark(kBitMark); // footer mark
|
|
data->space(0);
|
|
|
|
transmit.perform();
|
|
}
|
|
|
|
void HitachiRar6ne1Climate::transmit_state() {
|
|
uint8_t state[kStateLength];
|
|
this->build_state_(state);
|
|
this->transmit_frame_(state);
|
|
}
|
|
|
|
void HitachiRar6ne1Climate::send_test_frame() {
|
|
ESP_LOGI(TAG, "Test self-loopback: invio frame OFF di riferimento");
|
|
this->transmit_frame_(REF_OFF);
|
|
}
|
|
|
|
void HitachiRar6ne1Climate::control(const climate::ClimateCall &call) {
|
|
// Sul telecomando reale Silent e Powerful sono accoppiati alla ventola
|
|
// (Silent = minima, Powerful = massima) e mutuamente esclusivi. Teniamo lo
|
|
// stato coerente: attivando un preset forziamo la ventola corrispondente;
|
|
// se l'utente cambia la ventola mentre un preset e' attivo, usciamo dal preset.
|
|
climate::ClimateCall modified = call;
|
|
if (call.get_preset().has_value()) {
|
|
switch (*call.get_preset()) {
|
|
case climate::CLIMATE_PRESET_ECO:
|
|
modified.set_fan_mode(climate::CLIMATE_FAN_QUIET);
|
|
break;
|
|
case climate::CLIMATE_PRESET_BOOST:
|
|
modified.set_fan_mode(climate::CLIMATE_FAN_HIGH);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
} else if (call.get_fan_mode().has_value() &&
|
|
this->preset.value_or(climate::CLIMATE_PRESET_NONE) !=
|
|
climate::CLIMATE_PRESET_NONE) {
|
|
modified.set_preset(climate::CLIMATE_PRESET_NONE);
|
|
}
|
|
climate_ir::ClimateIR::control(modified);
|
|
}
|
|
|
|
bool HitachiRar6ne1Climate::on_receive(remote_base::RemoteReceiveData data) {
|
|
if (!data.expect_item(kHeaderMark, kHeaderSpace))
|
|
return false;
|
|
|
|
uint8_t state[kStateLength] = {0};
|
|
for (uint8_t i = 0; i < kStateLength; i++) {
|
|
for (int8_t bit = 7; bit >= 0; bit--) { // MSB-first
|
|
if (!data.expect_mark(kBitMark))
|
|
return false;
|
|
if (data.expect_space(kOneSpace)) {
|
|
state[i] |= static_cast<uint8_t>(1 << bit);
|
|
} else if (data.expect_space(kZeroSpace)) {
|
|
// bit a 0
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (state[27] != calc_checksum_(state)) {
|
|
ESP_LOGW(TAG, "RX: checksum errato, frame ignorato");
|
|
return false;
|
|
}
|
|
|
|
// Power / Mode (byte[17] bit0, byte[10]).
|
|
if ((state[17] & 0x01) == 0) {
|
|
this->mode = climate::CLIMATE_MODE_OFF;
|
|
} else {
|
|
switch (state[10]) {
|
|
case 0x20: this->mode = climate::CLIMATE_MODE_COOL; break;
|
|
case 0xC0: this->mode = climate::CLIMATE_MODE_HEAT; break;
|
|
case 0xA0: this->mode = climate::CLIMATE_MODE_DRY; break;
|
|
case 0x30: this->mode = climate::CLIMATE_MODE_FAN_ONLY; break;
|
|
case 0x40: this->mode = climate::CLIMATE_MODE_HEAT_COOL; break;
|
|
default: break;
|
|
}
|
|
}
|
|
|
|
// Temp (byte[11]); non applicabile in Fan-only.
|
|
if (state[10] != 0x30) {
|
|
this->target_temperature = reverse_bits8(state[11]) / 2.0f;
|
|
}
|
|
|
|
// Fan (byte[13]).
|
|
switch (state[13]) {
|
|
case 0x40: this->fan_mode = climate::CLIMATE_FAN_QUIET; break;
|
|
case 0xC0: this->fan_mode = climate::CLIMATE_FAN_LOW; break;
|
|
case 0x20: this->fan_mode = climate::CLIMATE_FAN_MEDIUM; break;
|
|
case 0xA0: this->fan_mode = climate::CLIMATE_FAN_HIGH; break;
|
|
case 0x80: this->fan_mode = climate::CLIMATE_FAN_AUTO; break;
|
|
default: break;
|
|
}
|
|
|
|
// Swing (byte[14]/[15]).
|
|
const bool swing_v = state[14] == 0xE0;
|
|
const bool swing_h = state[15] == 0xE0;
|
|
if (swing_v && swing_h)
|
|
this->swing_mode = climate::CLIMATE_SWING_BOTH;
|
|
else if (swing_v)
|
|
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
|
|
else if (swing_h)
|
|
this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
|
|
else
|
|
this->swing_mode = climate::CLIMATE_SWING_OFF;
|
|
|
|
// Preset (byte[26] Silent / byte[25] Powerful).
|
|
if (state[26] & 0x10)
|
|
this->preset = climate::CLIMATE_PRESET_ECO;
|
|
else if (state[25] & 0x04)
|
|
this->preset = climate::CLIMATE_PRESET_BOOST;
|
|
else
|
|
this->preset = climate::CLIMATE_PRESET_NONE;
|
|
|
|
ESP_LOGD(TAG, "RX ok (tasto byte[9]=0x%02X)", state[9]);
|
|
this->publish_state();
|
|
return true;
|
|
}
|
|
|
|
} // namespace hitachi_rar6ne1
|
|
} // namespace esphome
|