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:
Francesco Zanin
2026-06-30 15:50:26 +02:00
commit 1a9eb82d0f
15 changed files with 1813 additions and 0 deletions

View 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"]

View 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)

View 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

View 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

96
esphome/hitachi-ir.yaml Normal file
View File

@@ -0,0 +1,96 @@
# Hitachi RAR-6NE1 -> climate ESPHome (protocollo HITACHI_AC, 28 byte).
#
# Da importare nell'add-on ESPHome di Home Assistant:
# - questo file e la cartella components/ vanno nella STESSA directory di
# config ESPHome (da te: /config/hitachi-ir.yaml + /config/components/)
# - quindi external_components -> path: components risolve a components/hitachi_rar6ne1/
# - compila secrets.yaml (wifi) come da template accanto a questo file
#
# Hardware:
# - Ricevitore IR (TSOP/VS1838B) su GPIO27 (gia' presente)
# - Emettitore IR (LED IR + driver a transistor) su GPIO25 (DA MONTARE)
# GPIO25 --[1k]--> base NPN ; LED IR: 3V3 --[100R]--> A->K --> collector ; emitter -> GND
esphome:
name: hitachi-ir
friendly_name: Condizionatore Hitachi
esp32:
board: esp32dev
framework:
type: arduino
# --- Connettivita' -----------------------------------------------------------
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
# Hotspot di fallback se il WiFi non e' raggiungibile
ap:
ssid: "Hitachi-IR Fallback"
captive_portal:
logger:
level: DEBUG
api:
encryption:
key: !secret api_encryption_key
ota:
- platform: esphome
password: !secret ota_password
# --- Component custom --------------------------------------------------------
# path = cartella CHE CONTIENE la cartella del componente (components/), non il
# componente stesso. ESPHome cerchera' components/hitachi_rar6ne1/ = componente
# "hitachi_rar6ne1". Mettere components/hitachi_rar6ne1 qui causa un import
# circolare perche' climate.py verrebbe scambiato per il core 'climate'.
external_components:
- source:
type: local
path: components
# --- IR: trasmettitore (invio comandi) ---------------------------------------
remote_transmitter:
id: tx
pin: GPIO25
carrier_duty_percent: 50%
# --- IR: ricevitore (sync col telecomando fisico) ----------------------------
remote_receiver:
id: rx
pin:
number: GPIO27
inverted: true
mode:
input: true
pullup: true
tolerance: 25%
# Il frame Hitachi e' 224 bit (~226 coppie = simboli RMT). Sull'ESP32 classico
# il default e' ~192 simboli -> il frame veniva TRONCATO e il decoder falliva.
# 1 simbolo RMT = 1 coppia mark/space. 384 da margine ampio e lascia memoria
# RMT al trasmettitore (totale 512 simboli su ESP32 classico).
rmt_symbols: 384
receive_symbols: 384
# DEBUG: riattiva solo per ispezionare i raw. Intasa i log e blocca il loop
# ~200ms per frame (stampa pronto) -> RX gia' validato, tenuto spento.
# dump: all
# --- Entita' climate ---------------------------------------------------------
climate:
- platform: hitachi_rar6ne1
id: hitachi_ac
name: "Condizionatore"
transmitter_id: tx
receiver_id: rx
# --- DEBUG: pulsante per test self-loopback ----------------------------------
# Premendolo, l'ESP trasmette un frame OFF di riferimento; se il LED IR illumina
# (anche di riflesso) il ricevitore, nei log compare "RX ok" + il dump dei byte.
# Rimuovi questa sezione a validazione conclusa.
button:
- platform: template
name: "Test IR loopback"
on_press:
- lambda: 'id(hitachi_ac).send_test_frame();'

View File

@@ -0,0 +1,12 @@
# Template segreti ESPHome. Copia in `esphome/secrets.yaml` (ignorato da git) e
# compila con i valori reali. Nell'add-on di Home Assistant il file vive in
# /config/esphome/secrets.yaml ed e' condiviso da tutte le config ESPHome:
# aggiungi LA' le stesse chiavi.
wifi_ssid: "TUA_SSID"
wifi_password: "TUA_PASSWORD"
# Chiave di cifratura API (32 byte base64) e password OTA per hitachi-ir.yaml.
# Genera la api key da ESPHome (dashboard) o con un generatore base64 a 32 byte.
api_encryption_key: "GENERA_UNA_CHIAVE_BASE64_32_BYTE="
ota_password: "GENERA_UNA_PASSWORD_OTA"