Files
Hitachi-IR-Climate-ESPHome-…/esphome/components/hitachi_rar6ne1/hitachi_rar6ne1.cpp
Francesco Zanin 1a9eb82d0f Initial commit: Hitachi RAR-6NE1 climate via ESP32/ESPHome
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>
2026-06-30 15:50:26 +02:00

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