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>
This commit is contained in:
5
esphome/components/hitachi_rar6ne1/__init__.py
Normal file
5
esphome/components/hitachi_rar6ne1/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# External component ESPHome per il telecomando Hitachi RAR-6NE1
|
||||
# (protocollo HITACHI_AC a 28 byte / 224 bit).
|
||||
#
|
||||
# La piattaforma vera e' definita in climate.py (platform: hitachi_rar6ne1).
|
||||
CODEOWNERS = ["@francescozanin"]
|
||||
20
esphome/components/hitachi_rar6ne1/climate.py
Normal file
20
esphome/components/hitachi_rar6ne1/climate.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate_ir
|
||||
|
||||
# API ESPHome recente (>= 2025.x): helper a funzione
|
||||
# climate_ir_with_receiver_schema(class) + new_climate_ir(config).
|
||||
# NB: non disponibile su 2024.12.x (li' era CLIMATE_IR_WITH_RECEIVER_SCHEMA
|
||||
# costante + register_climate_ir). Aggiornare ESPHome.
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
CODEOWNERS = ["@francescozanin"]
|
||||
|
||||
hitachi_rar6ne1_ns = cg.esphome_ns.namespace("hitachi_rar6ne1")
|
||||
HitachiRar6ne1Climate = hitachi_rar6ne1_ns.class_(
|
||||
"HitachiRar6ne1Climate", climate_ir.ClimateIR
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HitachiRar6ne1Climate)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
await climate_ir.new_climate_ir(config)
|
||||
256
esphome/components/hitachi_rar6ne1/hitachi_rar6ne1.cpp
Normal file
256
esphome/components/hitachi_rar6ne1/hitachi_rar6ne1.cpp
Normal file
@@ -0,0 +1,256 @@
|
||||
#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
|
||||
73
esphome/components/hitachi_rar6ne1/hitachi_rar6ne1.h
Normal file
73
esphome/components/hitachi_rar6ne1/hitachi_rar6ne1.h
Normal file
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/climate_ir/climate_ir.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace hitachi_rar6ne1 {
|
||||
|
||||
// Timing del protocollo HITACHI_AC (28 byte), verificati su IRremoteESP8266
|
||||
// (ir_Hitachi.cpp) e contro le catture reali del RAR-6NE1.
|
||||
static const uint16_t kHeaderMark = 3300;
|
||||
static const uint16_t kHeaderSpace = 1700;
|
||||
static const uint16_t kBitMark = 400;
|
||||
static const uint16_t kOneSpace = 1250;
|
||||
static const uint16_t kZeroSpace = 500;
|
||||
static const uint32_t kCarrierFrequency = 38000;
|
||||
|
||||
static const uint8_t kStateLength = 28;
|
||||
|
||||
// byte[9] = "codice tasto premuto" sul telecomando reale (0xE0 mode/idle,
|
||||
// 0xC0 power, ...). L'unita' molto probabilmente lo ignora quando riceve uno
|
||||
// stato completo, ma e' la PRIMA variabile da provare se i comandi sintetizzati
|
||||
// non vengono accettati. Vedi README / nota Obsidian.
|
||||
static const uint8_t kButtonCode = 0xE0;
|
||||
|
||||
// Limiti dell'entita' climate. RANGE CONSERVATIVO 18-30 scelto per sicurezza.
|
||||
// FLAG: DA VERIFICARE contro il range realmente supportato dal RAR-6NE1 (alcuni
|
||||
// Hitachi arrivano a 16 in Cool e/o 32, altri si fermano prima). Vedi "Blocchi
|
||||
// attivi" nella nota Obsidian projects/hitachi-ir-esphome.
|
||||
static const float kMinTempC = 18.0f;
|
||||
static const float kMaxTempC = 30.0f;
|
||||
static const float kTempStep = 1.0f;
|
||||
|
||||
class HitachiRar6ne1Climate : public climate_ir::ClimateIR {
|
||||
public:
|
||||
HitachiRar6ne1Climate()
|
||||
: climate_ir::ClimateIR(
|
||||
kMinTempC, kMaxTempC, kTempStep,
|
||||
/*supports_dry=*/true, /*supports_fan_only=*/true,
|
||||
// 4 velocita' + auto, mappate su modi standard HA:
|
||||
// QUIET=1/4 LOW=2/4 MEDIUM=3/4 HIGH=4/4 AUTO
|
||||
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_QUIET,
|
||||
climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM,
|
||||
climate::CLIMATE_FAN_HIGH},
|
||||
{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
|
||||
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH},
|
||||
// Eco = Silent, Boost = Powerful
|
||||
{climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO,
|
||||
climate::CLIMATE_PRESET_BOOST}) {}
|
||||
|
||||
// Invia un frame OFF realmente catturato (deterministico) per il test
|
||||
// self-loopback: richiamabile da un button template nello YAML.
|
||||
void send_test_frame();
|
||||
|
||||
// Accoppia preset e ventola come il telecomando fisico (Silent=ventola minima,
|
||||
// Powerful=massima, mutuamente esclusivi) prima di applicare il comando.
|
||||
void control(const climate::ClimateCall &call) override;
|
||||
|
||||
protected:
|
||||
// Costruisce il frame IR dallo stato climate corrente e lo trasmette.
|
||||
void transmit_state() override;
|
||||
// Decodifica un frame ricevuto (telecomando fisico) e aggiorna lo stato.
|
||||
bool on_receive(remote_base::RemoteReceiveData data) override;
|
||||
|
||||
// Riempie i 28 byte a partire dallo stato climate corrente (checksum incluso).
|
||||
void build_state_(uint8_t state[kStateLength]);
|
||||
// Emette i 28 byte sul trasmettitore IR (header + bit MSB-first + footer).
|
||||
void transmit_frame_(const uint8_t state[kStateLength]);
|
||||
// Checksum identico a IRHitachiAc::calcChecksum().
|
||||
static uint8_t calc_checksum_(const uint8_t state[kStateLength]);
|
||||
};
|
||||
|
||||
} // namespace hitachi_rar6ne1
|
||||
} // namespace esphome
|
||||
Reference in New Issue
Block a user