diff --git a/CODEOWNERS b/CODEOWNERS index 1236c8d842..bfd689abff 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -253,6 +253,7 @@ esphome/components/mics_4514/* @jesserockz esphome/components/midea/* @dudanov esphome/components/midea_ir/* @dudanov esphome/components/mitsubishi/* @RubyBailey +esphome/components/mitsubishi_itp/* @KazWolfe @Sammy1Am esphome/components/mlx90393/* @functionpointer esphome/components/mlx90614/* @jesserockz esphome/components/mmc5603/* @benhoff diff --git a/esphome/components/mitsubishi_itp/__init__.py b/esphome/components/mitsubishi_itp/__init__.py new file mode 100644 index 0000000000..feabb1fb78 --- /dev/null +++ b/esphome/components/mitsubishi_itp/__init__.py @@ -0,0 +1,39 @@ +import esphome.codegen as cg +from esphome.components import climate +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@Sammy1Am", "@KazWolfe"] + +mitsubishi_itp_ns = cg.esphome_ns.namespace("mitsubishi_itp") +MitsubishiUART = mitsubishi_itp_ns.class_( + "MitsubishiUART", cg.PollingComponent, climate.Climate +) +CONF_MITSUBISHI_ITP_ID = "mitsubishi_itp_id" + + +def sensors_to_config_schema(sensors): + return cv.Schema( + { + cv.GenerateID(CONF_MITSUBISHI_ITP_ID): cv.use_id(MitsubishiUART), + } + ).extend( + { + cv.Optional(sensor_designator): sensor_schema + for sensor_designator, sensor_schema in sensors.items() + } + ) + + +async def sensors_to_code(config, sensors, registration_function): + mitp_component = await cg.get_variable(config[CONF_MITSUBISHI_ITP_ID]) + + # Sensors + + for sensor_designator, _ in sensors.items(): + if sensor_conf := config.get(sensor_designator): + sensor_component = cg.new_Pvariable(sensor_conf[CONF_ID]) + + await registration_function(sensor_component, sensor_conf) + + cg.add(getattr(mitp_component, "register_listener")(sensor_component)) diff --git a/esphome/components/mitsubishi_itp/climate.py b/esphome/components/mitsubishi_itp/climate.py new file mode 100644 index 0000000000..2e69a379a4 --- /dev/null +++ b/esphome/components/mitsubishi_itp/climate.py @@ -0,0 +1,126 @@ +import esphome.codegen as cg +from esphome.components import climate, time, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_CUSTOM_FAN_MODES, + CONF_ID, + CONF_SUPPORTED_FAN_MODES, + CONF_SUPPORTED_MODES, + CONF_TIME_ID, +) +from esphome.core import coroutine + +from . import MitsubishiUART, mitsubishi_itp_ns + +DEPENDENCIES = [ + "uart", +] + +CONF_UART_HEATPUMP = "uart_heatpump" +CONF_UART_THERMOSTAT = "uart_thermostat" + +CONF_DISABLE_ACTIVE_MODE = "disable_active_mode" +CONF_ENHANCED_MHK_SUPPORT = ( + "enhanced_mhk" # EXPERIMENTAL. Will be set to default eventually. +) + +DEFAULT_POLLING_INTERVAL = "5s" + +DEFAULT_CLIMATE_MODES = ["OFF", "HEAT", "DRY", "COOL", "FAN_ONLY", "HEAT_COOL"] +DEFAULT_FAN_MODES = ["AUTO", "QUIET", "LOW", "MEDIUM", "HIGH"] +CUSTOM_FAN_MODES = {"VERYHIGH": mitsubishi_itp_ns.FAN_MODE_VERYHIGH} + +validate_custom_fan_modes = cv.enum(CUSTOM_FAN_MODES, upper=True) + +CONFIG_SCHEMA = climate.CLIMATE_SCHEMA.extend( + { + cv.GenerateID(CONF_ID): cv.declare_id(MitsubishiUART), + cv.Required(CONF_UART_HEATPUMP): cv.use_id(uart.UARTComponent), + cv.Optional(CONF_UART_THERMOSTAT): cv.use_id(uart.UARTComponent), + cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + cv.Optional( + CONF_SUPPORTED_MODES, default=DEFAULT_CLIMATE_MODES + ): cv.ensure_list(climate.validate_climate_mode), + cv.Optional( + CONF_SUPPORTED_FAN_MODES, default=DEFAULT_FAN_MODES + ): cv.ensure_list(climate.validate_climate_fan_mode), + cv.Optional(CONF_CUSTOM_FAN_MODES, default=["VERYHIGH"]): cv.ensure_list( + validate_custom_fan_modes + ), + cv.Optional(CONF_DISABLE_ACTIVE_MODE, default=False): cv.boolean, + cv.Optional(CONF_ENHANCED_MHK_SUPPORT, default=False): cv.boolean, + } +).extend(cv.polling_component_schema(DEFAULT_POLLING_INTERVAL)) + + +def final_validate(config): + schema = uart.final_validate_device_schema( + "mitsubishi_itp", + uart_bus=CONF_UART_HEATPUMP, + require_tx=True, + require_rx=True, + data_bits=8, + parity="EVEN", + stop_bits=1, + ) + if CONF_UART_THERMOSTAT in config: + schema = schema.extend( + uart.final_validate_device_schema( + "mitsubishi_itp", + uart_bus=CONF_UART_THERMOSTAT, + require_tx=True, + require_rx=True, + data_bits=8, + parity="EVEN", + stop_bits=1, + ) + ) + schema(config) + + +FINAL_VALIDATE_SCHEMA = final_validate + + +@coroutine +async def to_code(config): + hp_uart_component = await cg.get_variable(config[CONF_UART_HEATPUMP]) + mitp_component = cg.new_Pvariable(config[CONF_ID], hp_uart_component) + + await cg.register_component(mitp_component, config) + await climate.register_climate(mitp_component, config) + + # If thermostat defined + if CONF_UART_THERMOSTAT in config: + # Register thermostat with MITP + ts_uart_component = await cg.get_variable(config[CONF_UART_THERMOSTAT]) + cg.add(getattr(mitp_component, "set_thermostat_uart")(ts_uart_component)) + + # If RTC defined + if CONF_TIME_ID in config: + rtc_component = await cg.get_variable(config[CONF_TIME_ID]) + cg.add(getattr(mitp_component, "set_time_source")(rtc_component)) + elif CONF_UART_THERMOSTAT in config and config.get(CONF_ENHANCED_MHK_SUPPORT): + raise cv.RequiredFieldInvalid( + f"{CONF_TIME_ID} is required if {CONF_ENHANCED_MHK_SUPPORT} is set." + ) + + # Traits + traits = mitp_component.config_traits() + + if CONF_SUPPORTED_MODES in config: + cg.add(traits.set_supported_modes(config[CONF_SUPPORTED_MODES])) + + if CONF_SUPPORTED_FAN_MODES in config: + cg.add(traits.set_supported_fan_modes(config[CONF_SUPPORTED_FAN_MODES])) + + if CONF_CUSTOM_FAN_MODES in config: + cg.add(traits.set_supported_custom_fan_modes(config[CONF_CUSTOM_FAN_MODES])) + + # Debug Settings + if dam_conf := config.get(CONF_DISABLE_ACTIVE_MODE): + cg.add(getattr(mitp_component, "set_active_mode")(not dam_conf)) + + if enhanced_mhk_protocol := config.get(CONF_ENHANCED_MHK_SUPPORT): + cg.add( + getattr(mitp_component, "set_enhanced_mhk_support")(enhanced_mhk_protocol) + ) diff --git a/esphome/components/mitsubishi_itp/mitp_bridge.cpp b/esphome/components/mitsubishi_itp/mitp_bridge.cpp new file mode 100644 index 0000000000..350483fa5d --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitp_bridge.cpp @@ -0,0 +1,217 @@ +#include "mitp_bridge.h" + +namespace esphome { +namespace mitsubishi_itp { + +MITPBridge::MITPBridge(uart::UARTComponent *uart_component, PacketProcessor *packet_processor) + : uart_comp_{*uart_component}, pkt_processor_{*packet_processor} {} + +// The heatpump loop expects responses for most sent packets, so it tracks the last send packet and wait for a response +void HeatpumpBridge::loop() { + // Try to get a packet + if (optional pkt = receive_raw_packet_(SourceBridge::HEATPUMP, + packet_awaiting_response_.has_value() + ? packet_awaiting_response_.value().get_controller_association() + : ControllerAssociation::MITP)) { + ESP_LOGV(BRIDGE_TAG, "Parsing %x heatpump packet", pkt.value().get_packet_type()); + // Check the packet's checksum and either process it, or log an error + if (pkt.value().is_checksum_valid()) { + // If we're waiting for a response, associate the incomming packet with the request packet + classify_and_process_raw_packet_(pkt.value()); + } else { + ESP_LOGW(BRIDGE_TAG, "Invalid packet checksum!\n%s", + format_hex_pretty(&pkt.value().get_bytes()[0], pkt.value().get_length()).c_str()); + } + + // If there was a packet waiting for a response, remove it. + // TODO: This incoming packet wasn't *nessesarily* a response, but for now + // it's probably not worth checking to make sure it matches. + if (packet_awaiting_response_.has_value()) { + packet_awaiting_response_.reset(); + } + } else if (!packet_awaiting_response_.has_value() && !pkt_queue_.empty()) { + // If we're not waiting for a response and there's a packet in the queue... + + // If the packet expects a response, add it to the awaitingResponse variable + if (pkt_queue_.front().is_response_expected()) { + packet_awaiting_response_ = pkt_queue_.front(); + } + + ESP_LOGV(BRIDGE_TAG, "Sending to heatpump %s", pkt_queue_.front().to_string().c_str()); + write_raw_packet_(pkt_queue_.front().raw_packet()); + packet_sent_millis_ = millis(); + + // Remove packet from queue + pkt_queue_.pop(); + } else if (packet_awaiting_response_.has_value() && (millis() - packet_sent_millis_ > RESPONSE_TIMEOUT_MS)) { + // We've been waiting too long for a response, give up + // TODO: We could potentially retry here, but that seems unnecessary + packet_awaiting_response_.reset(); + ESP_LOGW(BRIDGE_TAG, "Timeout waiting for response to %x packet.", + packet_awaiting_response_.value().get_packet_type()); + } +} + +// The thermostat bridge loop doesn't expect any responses, so packets in queue are just sent without checking if they +// expect a response +void ThermostatBridge::loop() { + // Try to get a packet + if (optional pkt = receive_raw_packet_(SourceBridge::THERMOSTAT, ControllerAssociation::THERMOSTAT)) { + ESP_LOGV(BRIDGE_TAG, "Parsing %x thermostat packet", pkt.value().get_packet_type()); + // Check the packet's checksum and either process it, or log an error + if (pkt.value().is_checksum_valid()) { + classify_and_process_raw_packet_(pkt.value()); + } else { + ESP_LOGW(BRIDGE_TAG, "Invalid packet checksum!\n%s", + format_hex_pretty(&pkt.value().get_bytes()[0], pkt.value().get_length()).c_str()); + } + } else if (!pkt_queue_.empty()) { + // If there's a packet in the queue... + + ESP_LOGV(BRIDGE_TAG, "Sending to thermostat %s", pkt_queue_.front().to_string().c_str()); + write_raw_packet_(pkt_queue_.front().raw_packet()); + packet_sent_millis_ = millis(); + + // Remove packet from queue + pkt_queue_.pop(); + } +} + +/* Queues a packet to be sent by the bridge. If the queue is full, the packet will not be +enqueued.*/ +void MITPBridge::send_packet(const Packet &packet_to_send) { + if (pkt_queue_.size() <= MAX_QUEUE_SIZE) { + pkt_queue_.push(packet_to_send); + } else { + ESP_LOGW(BRIDGE_TAG, "Packet queue full! %x packet not sent.", packet_to_send.get_packet_type()); + } +} + +void MITPBridge::write_raw_packet_(const RawPacket &packet_to_send) const { + uart_comp_.write_array(packet_to_send.get_bytes(), packet_to_send.get_length()); +} + +/* Reads and deserializes a packet from UART. +Communication with heatpump is *slow*, so we need to check and make sure there are +enough packets available before we start reading. If there aren't enough packets, +no packet will be returned. + +Even at 2400 baud, the 100ms readtimeout should be enough to read a whole payload +after the first byte has been received though, so currently we're assuming that once +the header is available, it's safe to call read_array without timing out and severing +the packet. +*/ +optional MITPBridge::receive_raw_packet_(const SourceBridge source_bridge, + const ControllerAssociation controller_association) const { + // TODO: Can we make the source_bridge and controller_association inherent to the class instead of passed as + // arguments? + uint8_t packet_bytes[PACKET_MAX_SIZE]; + packet_bytes[0] = 0; // Reset control byte before starting + + // Drain UART until we see a control byte (times out after 100ms in UARTComponent) + while (uart_comp_.available() >= PACKET_HEADER_SIZE && uart_comp_.read_byte(&packet_bytes[0])) { + if (packet_bytes[0] == BYTE_CONTROL) + break; + // TODO: If the serial is all garbage, this may never stop-- we should have our own timeout + } + + // If we never found a control byte, we didn't receive a packet + if (packet_bytes[0] != BYTE_CONTROL) { + return nullopt; + } + + // Read the header + uart_comp_.read_array(&packet_bytes[1], PACKET_HEADER_SIZE - 1); + + // Read payload + checksum + uint8_t payload_size = packet_bytes[PACKET_HEADER_INDEX_PAYLOAD_LENGTH]; + uart_comp_.read_array(&packet_bytes[PACKET_HEADER_SIZE], payload_size + 1); + + return RawPacket(packet_bytes, PACKET_HEADER_SIZE + payload_size + 1, source_bridge, controller_association); +} + +template void MITPBridge::process_raw_packet_(RawPacket &pkt, bool expect_response) const { + P packet = P(std::move(pkt)); + packet.set_response_expected(expect_response); + pkt_processor_.process_packet(packet); +} + +void MITPBridge::classify_and_process_raw_packet_(RawPacket &pkt) const { + // Figure out how to do this without a static_cast? + switch (static_cast(pkt.get_packet_type())) { + case PacketType::CONNECT_REQUEST: + process_raw_packet_(pkt, true); + break; + case PacketType::CONNECT_RESPONSE: + process_raw_packet_(pkt, false); + break; + + case PacketType::IDENTIFY_REQUEST: + process_raw_packet_(pkt, true); + break; + case PacketType::IDENTIFY_RESPONSE: + process_raw_packet_(pkt, false); + break; + + case PacketType::GET_REQUEST: + process_raw_packet_(pkt, true); + break; + case PacketType::GET_RESPONSE: + switch (static_cast(pkt.get_command())) { + case GetCommand::SETTINGS: + process_raw_packet_(pkt, false); + break; + case GetCommand::CURRENT_TEMP: + process_raw_packet_(pkt, false); + break; + case GetCommand::ERROR_INFO: + process_raw_packet_(pkt, false); + break; + case GetCommand::RUN_STATE: + process_raw_packet_(pkt, false); + break; + case GetCommand::STATUS: + process_raw_packet_(pkt, false); + break; + case GetCommand::THERMOSTAT_STATE_DOWNLOAD: + process_raw_packet_(pkt, false); + break; + default: + process_raw_packet_(pkt, false); + } + break; + case PacketType::SET_REQUEST: + switch (static_cast(pkt.get_command())) { + case SetCommand::REMOTE_TEMPERATURE: + process_raw_packet_(pkt, true); + break; + case SetCommand::SETTINGS: + process_raw_packet_(pkt, true); + break; + case SetCommand::THERMOSTAT_SENSOR_STATUS: + process_raw_packet_(pkt, true); + break; + case SetCommand::THERMOSTAT_HELLO: + process_raw_packet_(pkt, false); + break; + case SetCommand::THERMOSTAT_STATE_UPLOAD: + process_raw_packet_(pkt, true); + break; + case SetCommand::THERMOSTAT_SET_AA: + process_raw_packet_(pkt, true); + break; + default: + process_raw_packet_(pkt, true); + } + break; + case PacketType::SET_RESPONSE: + process_raw_packet_(pkt, false); + break; + + default: + process_raw_packet_(pkt, true); // If we get an unknown packet from the thermostat, expect a response + } +} + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitp_bridge.h b/esphome/components/mitsubishi_itp/mitp_bridge.h new file mode 100644 index 0000000000..dfef3656e0 --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitp_bridge.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/components/uart/uart.h" +#include "mitp_packet.h" +#include "queue" + +namespace esphome { +namespace mitsubishi_itp { + +static constexpr char BRIDGE_TAG[] = "mitp_bridge"; +static const uint32_t RESPONSE_TIMEOUT_MS = 3000; // Maximum amount of time to wait for an expected response packet +/* Maximum number of packets allowed to be queued for sending. In some circumstances the equipment response +time can be very slow and packets would queue up faster than they were being received. TODO: Not sure what size this +should be, 4ish should be enough for almost all situations, so 8 seems plenty.*/ +static const size_t MAX_QUEUE_SIZE = 8; + +// A UARTComponent wrapper to send and receieve packets +class MITPBridge { + public: + MITPBridge(uart::UARTComponent *uart_component, PacketProcessor *packet_processor); + + // Enqueues a packet to be sent + void send_packet(const Packet &packet_to_send); + + // Checks for incoming packets, processes them, sends queued packets + virtual void loop() = 0; + + protected: + optional receive_raw_packet_(SourceBridge source_bridge, + ControllerAssociation controller_association) const; + void write_raw_packet_(const RawPacket &packet_to_send) const; + template void process_raw_packet_(RawPacket &pkt, bool expect_response = true) const; + void classify_and_process_raw_packet_(RawPacket &pkt) const; + + uart::UARTComponent &uart_comp_; + PacketProcessor &pkt_processor_; + std::queue pkt_queue_; + optional packet_awaiting_response_ = nullopt; + uint32_t packet_sent_millis_; +}; + +class HeatpumpBridge : public MITPBridge { + public: + using MITPBridge::MITPBridge; + void loop() override; +}; + +class ThermostatBridge : public MITPBridge { + public: + using MITPBridge::MITPBridge; + // ThermostatBridge(uart::UARTComponent &uart_component, PacketProcessor &packet_processor) : + // MITPBridge(uart_component, packet_processor){}; + void loop() override; +}; + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitp_listener.h b/esphome/components/mitsubishi_itp/mitp_listener.h new file mode 100644 index 0000000000..e2ad176f1e --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitp_listener.h @@ -0,0 +1,21 @@ +#pragma once + +#include "mitp_packet.h" + +namespace esphome { +namespace mitsubishi_itp { + +static constexpr char LISTENER_TAG[] = "mitsubishi_itp.listener"; + +class MITPListener : public PacketProcessor { + public: + virtual void publish() = 0; // Publish only if the underlying state has changed + + // TODO: These trhee are only used by the TemperatureSourceSelect, so might need to be broken out (putting them here + // now to get things working) + virtual void setup(bool thermostat_is_present){}; // Called during hub-component setup(); + virtual void temperature_source_change(const std::string &temp_source){}; +}; + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitp_mhk.h b/esphome/components/mitsubishi_itp/mitp_mhk.h new file mode 100644 index 0000000000..4cd75f4b08 --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitp_mhk.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace esphome { +namespace mitsubishi_itp { + +/// A struct that represents the connected MHK's state for management and synchronization purposes. +struct MHKState { + float cool_setpoint_ = NAN; + float heat_setpoint_ = NAN; +}; + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitp_packet-derived.cpp b/esphome/components/mitsubishi_itp/mitp_packet-derived.cpp new file mode 100644 index 0000000000..ff0ba6aa5f --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitp_packet-derived.cpp @@ -0,0 +1,463 @@ +#include "mitp_packet.h" +#include "mitp_utils.h" +#include "mitsubishi_itp.h" +#include "esphome/core/datatypes.h" + +namespace esphome { +namespace mitsubishi_itp { + +// Packet to_strings() + +std::string ConnectRequestPacket::to_string() const { return ("Connect Request: " + Packet::to_string()); } +std::string ConnectResponsePacket::to_string() const { return ("Connect Response: " + Packet::to_string()); } +std::string CapabilitiesResponsePacket::to_string() const { + return ( + "Identify Base Capabilities Response: " + Packet::to_string() + CONSOLE_COLOR_PURPLE + + "\n HeatDisabled:" + (is_heat_disabled() ? "Yes" : "No") + " SupportsVane:" + (supports_vane() ? "Yes" : "No") + + " SupportsVaneSwing:" + (supports_vane_swing() ? "Yes" : "No") + + + " DryDisabled:" + (is_dry_disabled() ? "Yes" : "No") + " FanDisabled:" + (is_fan_disabled() ? "Yes" : "No") + + " ExtTempRange:" + (has_extended_temperature_range() ? "Yes" : "No") + + " AutoFanDisabled:" + (auto_fan_speed_disabled() ? "Yes" : "No") + + " InstallerSettings:" + (supports_installer_settings() ? "Yes" : "No") + + " TestMode:" + (supports_test_mode() ? "Yes" : "No") + " DryTemp:" + (supports_dry_temperature() ? "Yes" : "No") + + + " StatusDisplay:" + (has_status_display() ? "Yes" : "No") + + + "\n CoolDrySetpoint:" + std::to_string(get_min_cool_dry_setpoint()) + "/" + + std::to_string(get_max_cool_dry_setpoint()) + " HeatSetpoint:" + std::to_string(get_min_heating_setpoint()) + + "/" + std::to_string(get_max_heating_setpoint()) + " AutoSetpoint:" + std::to_string(get_min_auto_setpoint()) + + "/" + std::to_string(get_max_auto_setpoint()) + " FanSpeeds:" + std::to_string(get_supported_fan_speeds())); +} +std::string IdentifyCDResponsePacket::to_string() const { return "Identify CD Response: " + Packet::to_string(); } +std::string CurrentTempGetResponsePacket::to_string() const { + return ("Current Temp Response: " + Packet::to_string() + CONSOLE_COLOR_PURPLE + + "\n Temp:" + std::to_string(get_current_temp()) + + " Outdoor:" + (std::isnan(get_outdoor_temp()) ? "Unsupported" : std::to_string(get_outdoor_temp()))); +} +std::string SettingsGetResponsePacket::to_string() const { + return ("Settings Response: " + Packet::to_string() + CONSOLE_COLOR_PURPLE + "\n Fan:" + format_hex(get_fan()) + + " Mode:" + format_hex(get_mode()) + " Power:" + + (get_power() == 3 ? "Test" + : get_power() > 0 ? "On" + : "Off") + + " TargetTemp:" + std::to_string(get_target_temp()) + " Vane:" + format_hex(get_vane()) + + " HVane:" + format_hex(get_horizontal_vane()) + (get_horizontal_vane_msb() ? " (MSB Set)" : "") + + "\n PowerLock:" + (locked_power() ? "Yes" : "No") + " ModeLock:" + (locked_mode() ? "Yes" : "No") + + " TempLock:" + (locked_temp() ? "Yes" : "No")); +} +std::string RunStateGetResponsePacket::to_string() const { + return ("RunState Response: " + Packet::to_string() + CONSOLE_COLOR_PURPLE + + "\n ServiceFilter:" + (service_filter() ? "Yes" : "No") + " Defrost:" + (in_defrost() ? "Yes" : "No") + + " Preheat:" + (in_preheat() ? "Yes" : "No") + " Standby:" + (in_standby() ? "Yes" : "No") + + " ActualFan:" + ACTUAL_FAN_SPEED_NAMES[get_actual_fan_speed()] + " (" + + std::to_string(get_actual_fan_speed()) + ")" + " AutoMode:" + format_hex(get_auto_mode())); +} +std::string StatusGetResponsePacket::to_string() const { + return ("Status Response: " + Packet::to_string() + CONSOLE_COLOR_PURPLE + "\n CompressorFrequency: " + + std::to_string(get_compressor_frequency()) + " Operating: " + (get_operating() ? "Yes" : "No")); +} +std::string ErrorStateGetResponsePacket::to_string() const { + return ("Error State Response: " + Packet::to_string() + CONSOLE_COLOR_PURPLE + + "\n Error State: " + (error_present() ? "Yes" : "No") + " ErrorCode: " + format_hex(get_error_code()) + + " ShortCode: " + get_short_code() + "(" + format_hex(get_raw_short_code()) + ")"); +} +std::string RemoteTemperatureSetRequestPacket::to_string() const { + return ("Remote Temp Set Request: " + Packet::to_string() + CONSOLE_COLOR_PURPLE + + "\n Temp:" + std::to_string(get_remote_temperature())); +} + +std::string ThermostatSensorStatusPacket::to_string() const { + return ("Thermostat Sensor Status: " + Packet::to_string() + CONSOLE_COLOR_PURPLE + + "\n Indoor RH: " + std::to_string(get_indoor_humidity_percent()) + "%" + + " MHK Battery: " + THERMOSTAT_BATTERY_STATE_NAMES[get_thermostat_battery_state()] + "(" + + std::to_string(get_thermostat_battery_state()) + ")" + + " Sensor Flags: " + std::to_string(get_sensor_flags())); +} + +std::string ThermostatHelloPacket::to_string() const { + return ("Thermostat Hello: " + Packet::to_string() + CONSOLE_COLOR_PURPLE + "\n Model: " + get_thermostat_model() + + " Serial: " + get_thermostat_serial() + " Version: " + get_thermostat_version_string()); +} + +std::string ThermostatStateUploadPacket::to_string() const { + uint8_t flags = get_flags(); + + std::string result = + "Thermostat Sync " + Packet::to_string() + CONSOLE_COLOR_PURPLE + "\n Flags: " + format_hex(flags) + " =>"; + + if (flags & TSSF_TIMESTAMP) { + ESPTime timestamp{}; + get_thermostat_timestamp(×tamp); + + result += " TS Time: " + timestamp.strftime("%Y-%m-%d %H:%M:%S"); + } + + if (flags & TSSF_AUTO_MODE) + result += " AutoMode: " + std::to_string(get_auto_mode()); + if (flags & TSSF_HEAT_SETPOINT) + result += " HeatSetpoint: " + std::to_string(get_heat_setpoint()); + if (flags & TSSF_COOL_SETPOINT) + result += " CoolSetpoint: " + std::to_string(get_cool_setpoint()); + + return result; +} + +std::string GetRequestPacket::to_string() const { + return ("Get Request: " + Packet::to_string() + CONSOLE_COLOR_PURPLE + + "\n CommandID: " + format_hex((uint8_t) get_requested_command())); +} + +std::string SettingsSetRequestPacket::to_string() const { + uint8_t flags = get_flags(); + uint8_t flags2 = get_flags_2(); + + std::string result = "Settings Set Request: " + Packet::to_string() + CONSOLE_COLOR_PURPLE + + "\n Flags: " + format_hex(flags2) + format_hex(flags) + " =>"; + + if (flags & SettingFlag::SF_POWER) + result += " Power: " + std::to_string(get_power()); + if (flags & SettingFlag::SF_MODE) + result += " Mode: " + std::to_string(get_mode()); + if (flags & SettingFlag::SF_TARGET_TEMPERATURE) + result += " TargetTemp: " + std::to_string(get_target_temp()); + if (flags & SettingFlag::SF_FAN) + result += " Fan: " + std::to_string(get_fan()); + if (flags & SettingFlag::SF_VANE) + result += " Vane: " + std::to_string(get_vane()); + + if (flags2 & SettingFlag2::SF2_HORIZONTAL_VANE) + result += " HVane: " + std::to_string(get_horizontal_vane()) + (get_horizontal_vane_msb() ? " (MSB Set)" : ""); + + return result; +} + +// TODO: Are there function implementations for packets in the .h file? (Yes) Should they be here? + +// SettingsSetRequestPacket functions + +void SettingsSetRequestPacket::add_settings_flag_(const SettingFlag flag_to_add) { add_flag(flag_to_add); } + +void SettingsSetRequestPacket::add_settings_flag2_(const SettingFlag2 flag2_to_add) { add_flag2(flag2_to_add); } + +SettingsSetRequestPacket &SettingsSetRequestPacket::set_power(const bool is_on) { + pkt_.set_payload_byte(PLINDEX_POWER, is_on ? 0x01 : 0x00); + add_settings_flag_(SF_POWER); + return *this; +} + +SettingsSetRequestPacket &SettingsSetRequestPacket::set_mode(const ModeByte mode) { + pkt_.set_payload_byte(PLINDEX_MODE, mode); + add_settings_flag_(SF_MODE); + return *this; +} + +SettingsSetRequestPacket &SettingsSetRequestPacket::set_target_temperature(const float temperature_degrees_c) { + if (temperature_degrees_c < 63.5 && temperature_degrees_c > -64.0) { + pkt_.set_payload_byte(PLINDEX_TARGET_TEMPERATURE, MITPUtils::deg_c_to_temp_scale_a(temperature_degrees_c)); + pkt_.set_payload_byte(PLINDEX_TARGET_TEMPERATURE_CODE, + MITPUtils::deg_c_to_legacy_target_temp(temperature_degrees_c)); + + // TODO: while spawning a warning here is fine, we should (a) only actually send that warning if the system can't + // support this setpoint, and (b) clamp the setpoint to the known-acceptable values. + // The utility class will already clamp this for us, so we only need to worry about the warning. + if (temperature_degrees_c < 16 || temperature_degrees_c > 31.5) { + ESP_LOGW(PTAG, "Target temp %f is out of range for the legacy temp scale. This may be a problem on older units.", + temperature_degrees_c); + } + + add_settings_flag_(SF_TARGET_TEMPERATURE); + } else { + ESP_LOGW(PTAG, "Target temp %f is outside valid range - target temperature not set!", temperature_degrees_c); + } + + return *this; +} +SettingsSetRequestPacket &SettingsSetRequestPacket::set_fan(const FanByte fan) { + pkt_.set_payload_byte(PLINDEX_FAN, fan); + add_settings_flag_(SF_FAN); + return *this; +} + +SettingsSetRequestPacket &SettingsSetRequestPacket::set_vane(const VaneByte vane) { + pkt_.set_payload_byte(PLINDEX_VANE, vane); + add_settings_flag_(SF_VANE); + return *this; +} + +SettingsSetRequestPacket &SettingsSetRequestPacket::set_horizontal_vane(const HorizontalVaneByte horizontal_vane) { + pkt_.set_payload_byte(PLINDEX_HORIZONTAL_VANE, horizontal_vane); + add_settings_flag2_(SF2_HORIZONTAL_VANE); + return *this; +} + +float SettingsSetRequestPacket::get_target_temp() const { + uint8_t enhanced_raw_temp = pkt_.get_payload_byte(PLINDEX_TARGET_TEMPERATURE); + + if (enhanced_raw_temp == 0x00) { + uint8_t legacy_raw_temp = pkt_.get_payload_byte(PLINDEX_TARGET_TEMPERATURE_CODE); + return MITPUtils::legacy_target_temp_to_deg_c(legacy_raw_temp); + } + + return MITPUtils::temp_scale_a_to_deg_c(enhanced_raw_temp); +} + +// SettingsGetResponsePacket functions +float SettingsGetResponsePacket::get_target_temp() const { + uint8_t enhanced_raw_temp = pkt_.get_payload_byte(PLINDEX_TARGETTEMP); + + if (enhanced_raw_temp == 0x00) { + uint8_t legacy_raw_temp = pkt_.get_payload_byte(PLINDEX_TARGETTEMP_LEGACY); + return MITPUtils::legacy_target_temp_to_deg_c(legacy_raw_temp); + } + + return MITPUtils::temp_scale_a_to_deg_c(enhanced_raw_temp); +} + +bool SettingsGetResponsePacket::is_i_see_enabled() const { + uint8_t mode = pkt_.get_payload_byte(PLINDEX_MODE); + + // so far only modes 0x09 to 0x11 are known to be i-see. + // Mode 0x08 technically *can* be, but it's not a guarantee by itself. + return (mode >= 0x09 && mode <= 0x11); +} + +// RemoteTemperatureSetRequestPacket functions + +float RemoteTemperatureSetRequestPacket::get_remote_temperature() const { + uint8_t raw_temp_a = pkt_.get_payload_byte(PLINDEX_REMOTE_TEMPERATURE); + + if (raw_temp_a == 0) { + uint8_t raw_temp_legacy = pkt_.get_payload_byte(PLINDEX_LEGACY_REMOTE_TEMPERATURE); + return MITPUtils::legacy_room_temp_to_deg_c(raw_temp_legacy); + } + + return MITPUtils::temp_scale_a_to_deg_c(raw_temp_a); +} + +RemoteTemperatureSetRequestPacket &RemoteTemperatureSetRequestPacket::set_remote_temperature( + float temperature_degrees_c) { + if (temperature_degrees_c < 63.5 && temperature_degrees_c > -64.0) { + pkt_.set_payload_byte(PLINDEX_REMOTE_TEMPERATURE, MITPUtils::deg_c_to_temp_scale_a(temperature_degrees_c)); + pkt_.set_payload_byte(PLINDEX_LEGACY_REMOTE_TEMPERATURE, + MITPUtils::deg_c_to_legacy_room_temp(temperature_degrees_c)); + set_flags(0x01); // Set flags to say we're providing the temperature + } else { + ESP_LOGW(PTAG, "Remote temp %f is outside valid range.", temperature_degrees_c); + } + return *this; +} +RemoteTemperatureSetRequestPacket &RemoteTemperatureSetRequestPacket::use_internal_temperature() { + set_flags(0x00); // Set flags to say to use internal temperature + return *this; +} + +// SettingsSetRunStatusPacket functions +SetRunStatePacket &SetRunStatePacket::set_filter_reset(bool do_reset) { + pkt_.set_payload_byte(PLINDEX_FILTER_RESET, do_reset ? 1 : 0); + set_flags(0x01); + return *this; +} + +// CurrentTempGetResponsePacket functions +float CurrentTempGetResponsePacket::get_current_temp() const { + uint8_t enhanced_raw_temp = pkt_.get_payload_byte(PLINDEX_CURRENTTEMP); + + // TODO: Figure out how to handle "out of range" issues here. + if (enhanced_raw_temp == 0) { + uint8_t legacy_raw_temp = pkt_.get_payload_byte(PLINDEX_CURRENTTEMP_LEGACY); + return MITPUtils::legacy_room_temp_to_deg_c(legacy_raw_temp); + } + + return MITPUtils::temp_scale_a_to_deg_c(enhanced_raw_temp); +} + +float CurrentTempGetResponsePacket::get_outdoor_temp() const { + uint8_t enhanced_raw_temp = pkt_.get_payload_byte(PLINDEX_OUTDOORTEMP); + + // Return NAN if unsupported + return enhanced_raw_temp == 0 ? NAN : MITPUtils::temp_scale_a_to_deg_c(enhanced_raw_temp); +} + +// ThermostatHelloPacket functions +std::string ThermostatHelloPacket::get_thermostat_model() const { + return MITPUtils::decode_n_bit_string((pkt_.get_payload_bytes(1)), 4, 6); +} + +std::string ThermostatHelloPacket::get_thermostat_serial() const { + return MITPUtils::decode_n_bit_string((pkt_.get_payload_bytes(4)), 12, 6); +} + +std::string ThermostatHelloPacket::get_thermostat_version_string() const { + char buf[16]; + sprintf(buf, "%02d.%02d.%02d", pkt_.get_payload_byte(13), pkt_.get_payload_byte(14), pkt_.get_payload_byte(15)); + + return buf; +} + +// ThermostatStateUploadPacket functions +time_t ThermostatStateUploadPacket::get_thermostat_timestamp(esphome::ESPTime *out_timestamp) const { + int32_be_t magic; + std::memcpy(&magic, pkt_.get_payload_bytes(PLINDEX_THERMOSTAT_TIMESTAMP), 4); + + out_timestamp->second = magic & 63; + out_timestamp->minute = (magic >> 6) & 63; + out_timestamp->hour = (magic >> 12) & 31; + out_timestamp->day_of_month = (magic >> 17) & 31; + out_timestamp->month = (magic >> 22) & 15; + out_timestamp->year = (magic >> 26) + 2017; + + out_timestamp->recalc_timestamp_local(); + return out_timestamp->timestamp; +} + +uint8_t ThermostatStateUploadPacket::get_auto_mode() const { return pkt_.get_payload_byte(PLINDEX_AUTO_MODE); } + +float ThermostatStateUploadPacket::get_heat_setpoint() const { + uint8_t enhanced_raw_temp = pkt_.get_payload_byte(PLINDEX_HEAT_SETPOINT); + return MITPUtils::temp_scale_a_to_deg_c(enhanced_raw_temp); +} + +float ThermostatStateUploadPacket::get_cool_setpoint() const { + uint8_t enhanced_raw_temp = pkt_.get_payload_byte(PLINDEX_COOL_SETPOINT); + return MITPUtils::temp_scale_a_to_deg_c(enhanced_raw_temp); +} + +// ThermostatStateDownloadResponsePacket functions +ThermostatStateDownloadResponsePacket &ThermostatStateDownloadResponsePacket::set_timestamp(esphome::ESPTime ts) { + int32_t encoded_timestamp = ((ts.year - 2017) << 26) | (ts.month << 22) | (ts.day_of_month << 17) | (ts.hour << 12) | + (ts.minute << 6) | (ts.second); + + int32_t swapped_timestamp = byteswap(encoded_timestamp); + + pkt_.set_payload_bytes(PLINDEX_ADAPTER_TIMESTAMP, &swapped_timestamp, 4); + pkt_.set_payload_byte(10, 0x07); // ??? + + return *this; +} + +ThermostatStateDownloadResponsePacket &ThermostatStateDownloadResponsePacket::set_auto_mode(bool is_auto) { + pkt_.set_payload_byte(PLINDEX_AUTO_MODE, is_auto ? 0x01 : 0x00); + return *this; +} + +ThermostatStateDownloadResponsePacket &ThermostatStateDownloadResponsePacket::set_heat_setpoint(float high_temp) { + uint8_t temp_a = high_temp != NAN ? MITPUtils::deg_c_to_temp_scale_a(high_temp) : 0x00; + + pkt_.set_payload_byte(PLINDEX_HEAT_SETPOINT, temp_a); + return *this; +} + +ThermostatStateDownloadResponsePacket &ThermostatStateDownloadResponsePacket::set_cool_setpoint(float low_temp) { + uint8_t temp_a = low_temp != NAN ? MITPUtils::deg_c_to_temp_scale_a(low_temp) : 0x00; + + pkt_.set_payload_byte(PLINDEX_COOL_SETPOINT, temp_a); + return *this; +} + +// ErrorStateGetResponsePacket functions +std::string ErrorStateGetResponsePacket::get_short_code() const { + const char *upper_alphabet = "AbEFJLPU"; + const char *lower_alphabet = "0123456789ABCDEFOHJLPU"; + const uint8_t error_code = this->get_raw_short_code(); + + uint8_t low_bits = error_code & 0x1F; + if (low_bits > 0x15) { + char buf[7]; + sprintf(buf, "ERR_%x", error_code); + return buf; + } + + return {upper_alphabet[(error_code & 0xE0) >> 5], lower_alphabet[low_bits]}; +} + +// CapabilitiesResponsePacket functions +uint8_t CapabilitiesResponsePacket::get_supported_fan_speeds() const { + uint8_t raw_value = ((pkt_.get_payload_byte(7) & 0x10) >> 2) + ((pkt_.get_payload_byte(8) & 0x08) >> 2) + + ((pkt_.get_payload_byte(9) & 0x02) >> 1); + + switch (raw_value) { + case 1: + case 2: + case 4: + return raw_value; + case 0: + return 3; + case 6: + return 5; + + default: + ESP_LOGW(PACKETS_TAG, "Unexpected supported fan speeds: %i", raw_value); + return 0; // TODO: Depending on how this is used, it might be more useful to just return 3 and hope for the best + } +} + +climate::ClimateTraits CapabilitiesResponsePacket::as_traits() const { + auto ct = climate::ClimateTraits(); + + // always enabled + ct.add_supported_mode(climate::CLIMATE_MODE_COOL); + ct.add_supported_mode(climate::CLIMATE_MODE_OFF); + + if (!this->is_heat_disabled()) + ct.add_supported_mode(climate::CLIMATE_MODE_HEAT); + if (!this->is_dry_disabled()) + ct.add_supported_mode(climate::CLIMATE_MODE_DRY); + if (!this->is_fan_disabled()) + ct.add_supported_mode(climate::CLIMATE_MODE_FAN_ONLY); + + if (this->supports_vane_swing()) { + ct.add_supported_swing_mode(climate::CLIMATE_SWING_OFF); + + if (this->supports_vane() && this->supports_h_vane()) + ct.add_supported_swing_mode(climate::CLIMATE_SWING_BOTH); + if (this->supports_vane()) + ct.add_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + if (this->supports_h_vane()) + ct.add_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL); + } + + ct.set_visual_min_temperature(std::min(this->get_min_cool_dry_setpoint(), this->get_min_heating_setpoint())); + ct.set_visual_max_temperature(std::max(this->get_max_cool_dry_setpoint(), this->get_max_heating_setpoint())); + + // TODO: Figure out what these states *actually* map to so we aren't sending bad data. + // This is probably a dynamic map, so the setter will need to be aware of things. + switch (this->get_supported_fan_speeds()) { + case 1: + ct.set_supported_fan_modes({climate::CLIMATE_FAN_HIGH}); + break; + case 2: + ct.set_supported_fan_modes({climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_HIGH}); + break; + case 3: + ct.set_supported_fan_modes({climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}); + break; + case 4: + ct.set_supported_fan_modes({ + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, + }); + break; + case 5: + ct.set_supported_fan_modes({ + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, + }); + ct.add_supported_custom_fan_mode("Very High"); + break; + default: + // no-op, don't set a fan mode. + break; + } + if (!this->auto_fan_speed_disabled()) + ct.add_supported_fan_mode(climate::CLIMATE_FAN_AUTO); + + return ct; +} + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitp_packet.cpp b/esphome/components/mitsubishi_itp/mitp_packet.cpp new file mode 100644 index 0000000000..8956acee15 --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitp_packet.cpp @@ -0,0 +1,77 @@ +#include "mitp_packet.h" + +namespace esphome { +namespace mitsubishi_itp { + +// Creates an empty packet +Packet::Packet() { + // TODO: Is this okay? +} + +// std::string Packet::to_string() const { +// return format_hex_pretty(&pkt_.getBytes()[0], pkt_.getLength()); +// } + +static char format_hex_pretty_char(uint8_t v) { return v >= 10 ? 'A' + (v - 10) : '0' + v; } + +std::string Packet::to_string() const { + // Based on `format_hex_pretty` from ESPHome + if (pkt_.get_length() < PACKET_HEADER_SIZE) + return ""; + std::stringstream stream; + + stream << CONSOLE_COLOR_CYAN; // Cyan + stream << '['; + + for (size_t i = 0; i < PACKET_HEADER_SIZE; i++) { + if (i == 1) { + stream << CONSOLE_COLOR_CYAN_BOLD; + } + stream << format_hex_pretty_char((pkt_.get_bytes()[i] & 0xF0) >> 4); + stream << format_hex_pretty_char(pkt_.get_bytes()[i] & 0x0F); + if (i < PACKET_HEADER_SIZE - 1) { + stream << '.'; + } + if (i == 1) { + stream << CONSOLE_COLOR_CYAN; + } + } + // Header close-bracket + stream << ']'; + stream << CONSOLE_COLOR_WHITE; // White + + // Payload + for (size_t i = PACKET_HEADER_SIZE; i < pkt_.get_length() - 1; i++) { + stream << format_hex_pretty_char((pkt_.get_bytes()[i] & 0xF0) >> 4); + stream << format_hex_pretty_char(pkt_.get_bytes()[i] & 0x0F); + if (i < pkt_.get_length() - 2) { + stream << '.'; + } + } + + // Space + stream << ' '; + stream << CONSOLE_COLOR_GREEN; // Green + + // Checksum + stream << format_hex_pretty_char((pkt_.get_bytes()[pkt_.get_length() - 1] & 0xF0) >> 4); + stream << format_hex_pretty_char(pkt_.get_bytes()[pkt_.get_length() - 1] & 0x0F); + + stream << CONSOLE_COLOR_NONE; // Reset + + return stream.str(); +} + +void Packet::set_flags(const uint8_t flag_value) { pkt_.set_payload_byte(PLINDEX_FLAGS, flag_value); } + +// Adds a flag (ONLY APPLICABLE FOR SOME COMMANDS) +void Packet::add_flag(const uint8_t flag_to_add) { + pkt_.set_payload_byte(PLINDEX_FLAGS, pkt_.get_payload_byte(PLINDEX_FLAGS) | flag_to_add); +} +// Adds a flag2 (ONLY APPLICABLE FOR SOME COMMANDS) +void Packet::add_flag2(const uint8_t flag2_to_add) { + pkt_.set_payload_byte(PLINDEX_FLAGS2, pkt_.get_payload_byte(PLINDEX_FLAGS2) | flag2_to_add); +} + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitp_packet.h b/esphome/components/mitsubishi_itp/mitp_packet.h new file mode 100644 index 0000000000..b41064f3b4 --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitp_packet.h @@ -0,0 +1,573 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/climate/climate.h" +#include "esphome/components/uart/uart.h" +#include "mitp_rawpacket.h" +#include "mitp_utils.h" +#include "esphome/core/time.h" +#include + +namespace esphome { +namespace mitsubishi_itp { +static constexpr char PACKETS_TAG[] = "mitsubishi_itp.packets"; + +#define CONSOLE_COLOR_NONE "\033[0m" +#define CONSOLE_COLOR_GREEN "\033[0;32m" +#define CONSOLE_COLOR_PURPLE "\033[0;35m" +#define CONSOLE_COLOR_CYAN "\033[0;36m" +#define CONSOLE_COLOR_CYAN_BOLD "\033[1;36m" +#define CONSOLE_COLOR_WHITE "\033[0;37m" + +// Defined as constant for use as a Custom Fan Mode +const std::string FAN_MODE_VERYHIGH = "Very High"; + +// These are named to match with set fan speeds where possible. "Very Low" is a special speed +// for e.g. preheating or thermal off. +const std::array ACTUAL_FAN_SPEED_NAMES = {"Off", "Very Low", "Low", "Medium", + "High", FAN_MODE_VERYHIGH, "Quiet"}; + +const std::array THERMOSTAT_BATTERY_STATE_NAMES = {"OK", "Low", "Critical", "Replace", "Unknown"}; + +class PacketProcessor; + +// Generic Base Packet wrapper over RawPacket +class Packet { + public: + Packet(RawPacket &&pkt) : pkt_(pkt){}; // TODO: Confirm this needs std::move if call to constructor ALSO has move + Packet(); // For optional<> construction + + // Returns a (more) human-readable string of the packet + virtual std::string to_string() const; + + // Is a response packet expected when this packet is sent. Defaults to true since + // most requests receive a response. + bool is_response_expected() const { return response_expected_; }; + void set_response_expected(bool expect_response) { response_expected_ = expect_response; }; + + // Passthrough methods to RawPacket + RawPacket &raw_packet() { return pkt_; }; + uint8_t get_packet_type() const { return pkt_.get_packet_type(); } + bool is_checksum_valid() const { return pkt_.is_checksum_valid(); }; + + // Returns flags (ONLY APPLICABLE FOR SOME COMMANDS) + // TODO: Probably combine these a bit? + uint8_t get_flags() const { return pkt_.get_payload_byte(PLINDEX_FLAGS); } + uint8_t get_flags_2() const { return pkt_.get_payload_byte(PLINDEX_FLAGS2); } + // Sets flags (ONLY APPLICABLE FOR SOME COMMANDS) + void set_flags(uint8_t flag_value); + // Adds a flag (ONLY APPLICABLE FOR SOME COMMANDS) + void add_flag(uint8_t flag_to_add); + // Adds a flag2 (ONLY APPLICABLE FOR SOME COMMANDS) + void add_flag2(uint8_t flag2_to_add); + + SourceBridge get_source_bridge() const { return pkt_.get_source_bridge(); } + ControllerAssociation get_controller_association() const { return pkt_.get_controller_association(); } + + protected: + static const int PLINDEX_FLAGS = 1; + static const int PLINDEX_FLAGS2 = 2; + + RawPacket pkt_; + + private: + bool response_expected_ = true; +}; + +//// +// Connect +//// +class ConnectRequestPacket : public Packet { + public: + using Packet::Packet; + static ConnectRequestPacket &instance() { + static ConnectRequestPacket instance; + return instance; + } + + std::string to_string() const override; + + private: + ConnectRequestPacket() : Packet(RawPacket(PacketType::CONNECT_REQUEST, 2)) { + pkt_.set_payload_byte(0, 0xca); + pkt_.set_payload_byte(1, 0x01); + } +}; + +class ConnectResponsePacket : public Packet { + public: + using Packet::Packet; + std::string to_string() const override; +}; + +//// +// Identify packets +//// +class CapabilitiesRequestPacket : public Packet { + public: + static CapabilitiesRequestPacket &instance() { + static CapabilitiesRequestPacket instance; + return instance; + } + using Packet::Packet; + + private: + CapabilitiesRequestPacket() : Packet(RawPacket(PacketType::IDENTIFY_REQUEST, 1)) { pkt_.set_payload_byte(0, 0xc9); } +}; + +class CapabilitiesResponsePacket : public Packet { + using Packet::Packet; + + public: + // Byte 7 + bool is_heat_disabled() const { return pkt_.get_payload_byte(7) & 0x02; } + bool supports_vane() const { return pkt_.get_payload_byte(7) & 0x20; } + bool supports_vane_swing() const { return pkt_.get_payload_byte(7) & 0x40; } + + // Byte 8 + bool is_dry_disabled() const { return pkt_.get_payload_byte(8) & 0x01; } + bool is_fan_disabled() const { return pkt_.get_payload_byte(8) & 0x02; } + bool has_extended_temperature_range() const { return pkt_.get_payload_byte(8) & 0x04; } + bool auto_fan_speed_disabled() const { return pkt_.get_payload_byte(8) & 0x10; } + bool supports_installer_settings() const { return pkt_.get_payload_byte(8) & 0x20; } + bool supports_test_mode() const { return pkt_.get_payload_byte(8) & 0x40; } + bool supports_dry_temperature() const { return pkt_.get_payload_byte(8) & 0x80; } + + // Byte 9 + bool has_status_display() const { return pkt_.get_payload_byte(9) & 0x01; } + + // Bytes 10-15 + float get_min_cool_dry_setpoint() const { return MITPUtils::temp_scale_a_to_deg_c(pkt_.get_payload_byte(10)); } + float get_max_cool_dry_setpoint() const { return MITPUtils::temp_scale_a_to_deg_c(pkt_.get_payload_byte(11)); } + float get_min_heating_setpoint() const { return MITPUtils::temp_scale_a_to_deg_c(pkt_.get_payload_byte(12)); } + float get_max_heating_setpoint() const { return MITPUtils::temp_scale_a_to_deg_c(pkt_.get_payload_byte(13)); } + float get_min_auto_setpoint() const { return MITPUtils::temp_scale_a_to_deg_c(pkt_.get_payload_byte(14)); } + float get_max_auto_setpoint() const { return MITPUtils::temp_scale_a_to_deg_c(pkt_.get_payload_byte(15)); } + + // Things that have to exist, but we don't know where yet. + bool supports_h_vane() const { return true; } + + // Fan Speeds TODO: Probably move this to .cpp? + uint8_t get_supported_fan_speeds() const; + + // Convert a temperature response into ClimateTraits. This will *not* include library-provided features. + // This will also not handle things like MHK2 humidity detection. + climate::ClimateTraits as_traits() const; + + std::string to_string() const override; +}; + +class IdentifyCDRequestPacket : public Packet { + public: + static IdentifyCDRequestPacket &instance() { + static IdentifyCDRequestPacket instance; + return instance; + } + using Packet::Packet; + + private: + IdentifyCDRequestPacket() : Packet(RawPacket(PacketType::IDENTIFY_REQUEST, 1)) { pkt_.set_payload_byte(0, 0xCD); } +}; + +class IdentifyCDResponsePacket : public Packet { + using Packet::Packet; + + public: + std::string to_string() const override; +}; + +//// +// Get +//// +class GetRequestPacket : public Packet { + public: + static GetRequestPacket &get_settings_instance() { + static GetRequestPacket instance = GetRequestPacket(GetCommand::SETTINGS); + return instance; + } + static GetRequestPacket &get_current_temp_instance() { + static GetRequestPacket instance = GetRequestPacket(GetCommand::CURRENT_TEMP); + return instance; + } + static GetRequestPacket &get_status_instance() { + static GetRequestPacket instance = GetRequestPacket(GetCommand::STATUS); + return instance; + } + static GetRequestPacket &get_runstate_instance() { + static GetRequestPacket instance = GetRequestPacket(GetCommand::RUN_STATE); + return instance; + } + static GetRequestPacket &get_error_info_instance() { + static GetRequestPacket instance = GetRequestPacket(GetCommand::ERROR_INFO); + return instance; + } + using Packet::Packet; + + GetCommand get_requested_command() const { return (GetCommand) pkt_.get_payload_byte(0); } + + std::string to_string() const override; + + private: + GetRequestPacket(GetCommand get_command) : Packet(RawPacket(PacketType::GET_REQUEST, 1)) { + pkt_.set_payload_byte(0, static_cast(get_command)); + } +}; + +class SettingsGetResponsePacket : public Packet { + static const int PLINDEX_POWER = 3; + static const int PLINDEX_MODE = 4; + static const int PLINDEX_TARGETTEMP_LEGACY = 5; + static const int PLINDEX_FAN = 6; + static const int PLINDEX_VANE = 7; + static const int PLINDEX_PROHIBITFLAGS = 8; + static const int PLINDEX_HVANE = 10; + static const int PLINDEX_TARGETTEMP = 11; + using Packet::Packet; + + public: + uint8_t get_power() const { return pkt_.get_payload_byte(PLINDEX_POWER); } + uint8_t get_mode() const { return pkt_.get_payload_byte(PLINDEX_MODE); } + uint8_t get_fan() const { return pkt_.get_payload_byte(PLINDEX_FAN); } + uint8_t get_vane() const { return pkt_.get_payload_byte(PLINDEX_VANE); } + bool locked_power() const { return pkt_.get_payload_byte(PLINDEX_PROHIBITFLAGS) & 0x01; } + bool locked_mode() const { return pkt_.get_payload_byte(PLINDEX_PROHIBITFLAGS) & 0x02; } + bool locked_temp() const { return pkt_.get_payload_byte(PLINDEX_PROHIBITFLAGS) & 0x04; } + uint8_t get_horizontal_vane() const { return pkt_.get_payload_byte(PLINDEX_HVANE) & 0x7F; } + bool get_horizontal_vane_msb() const { return pkt_.get_payload_byte(PLINDEX_HVANE) & 0x80; } + + float get_target_temp() const; + + bool is_i_see_enabled() const; + + std::string to_string() const override; +}; + +class CurrentTempGetResponsePacket : public Packet { + static const int PLINDEX_CURRENTTEMP_LEGACY = 3; + static const int PLINDEX_OUTDOORTEMP = 5; + static const int PLINDEX_CURRENTTEMP = 6; + using Packet::Packet; + + public: + float get_current_temp() const; + // Returns outdoor temperature or NAN if unsupported + float get_outdoor_temp() const; + std::string to_string() const override; +}; + +class StatusGetResponsePacket : public Packet { + static const int PLINDEX_COMPRESSOR_FREQUENCY = 3; + static const int PLINDEX_OPERATING = 4; + + using Packet::Packet; + + public: + uint8_t get_compressor_frequency() const { return pkt_.get_payload_byte(PLINDEX_COMPRESSOR_FREQUENCY); } + bool get_operating() const { return pkt_.get_payload_byte(PLINDEX_OPERATING); } + std::string to_string() const override; +}; + +class RunStateGetResponsePacket : public Packet { + static const int PLINDEX_STATUSFLAGS = 3; + static const int PLINDEX_ACTUALFAN = 4; + static const int PLINDEX_AUTOMODE = 5; + using Packet::Packet; + + public: + bool service_filter() const { return pkt_.get_payload_byte(PLINDEX_STATUSFLAGS) & 0x01; } + bool in_defrost() const { return pkt_.get_payload_byte(PLINDEX_STATUSFLAGS) & 0x02; } + bool in_preheat() const { return pkt_.get_payload_byte(PLINDEX_STATUSFLAGS) & 0x04; } + bool in_standby() const { return pkt_.get_payload_byte(PLINDEX_STATUSFLAGS) & 0x08; } + uint8_t get_actual_fan_speed() const { return pkt_.get_payload_byte(PLINDEX_ACTUALFAN); } + uint8_t get_auto_mode() const { return pkt_.get_payload_byte(PLINDEX_AUTOMODE); } + std::string to_string() const override; +}; + +class ErrorStateGetResponsePacket : public Packet { + using Packet::Packet; + + public: + uint16_t get_error_code() const { return pkt_.get_payload_byte(4) << 8 | pkt_.get_payload_byte(5); } + uint8_t get_raw_short_code() const { return pkt_.get_payload_byte(6); } + std::string get_short_code() const; + + bool error_present() const { return get_error_code() != 0x8000 || get_raw_short_code() != 0x00; } + + std::string to_string() const override; +}; + +//// +// Set +//// + +class SettingsSetRequestPacket : public Packet { + static const int PLINDEX_POWER = 3; + static const int PLINDEX_MODE = 4; + static const int PLINDEX_TARGET_TEMPERATURE_CODE = 5; + static const int PLINDEX_FAN = 6; + static const int PLINDEX_VANE = 7; + static const int PLINDEX_HORIZONTAL_VANE = 13; + static const int PLINDEX_TARGET_TEMPERATURE = 14; + + enum SettingFlag : uint8_t { + SF_POWER = 0x01, + SF_MODE = 0x02, + SF_TARGET_TEMPERATURE = 0x04, + SF_FAN = 0x08, + SF_VANE = 0x10 + }; + + enum SettingFlag2 : uint8_t { + SF2_HORIZONTAL_VANE = 0x01, + }; + + public: + enum ModeByte : uint8_t { + MODE_BYTE_HEAT = 0x01, + MODE_BYTE_DRY = 0x02, + MODE_BYTE_COOL = 0x03, + MODE_BYTE_FAN = 0x07, + MODE_BYTE_AUTO = 0x08, + }; + + enum FanByte : uint8_t { + FAN_AUTO = 0x00, + FAN_QUIET = 0x01, + FAN_1 = 0x02, + FAN_2 = 0x03, + FAN_3 = 0x05, + FAN_4 = 0x06, + }; + + enum VaneByte : uint8_t { + VANE_AUTO = 0x00, + VANE_1 = 0x01, + VANE_2 = 0x02, + VANE_3 = 0x03, + VANE_4 = 0x04, + VANE_5 = 0x05, + VANE_SWING = 0x07, + }; + + enum HorizontalVaneByte : uint8_t { + HV_AUTO = 0x00, + HV_LEFT_FULL = 0x01, + HV_LEFT = 0x02, + HV_CENTER = 0x03, + HV_RIGHT = 0x04, + HV_RIGHT_FULL = 0x05, + HV_SPLIT = 0x08, + HV_SWING = 0x0c, + }; + + SettingsSetRequestPacket() : Packet(RawPacket(PacketType::SET_REQUEST, 16)) { + pkt_.set_payload_byte(0, static_cast(SetCommand::SETTINGS)); + } + using Packet::Packet; + + uint8_t get_power() const { return pkt_.get_payload_byte(PLINDEX_POWER); } + ModeByte get_mode() const { return (ModeByte) pkt_.get_payload_byte(PLINDEX_MODE); } + FanByte get_fan() const { return (FanByte) pkt_.get_payload_byte(PLINDEX_FAN); } + VaneByte get_vane() const { return (VaneByte) pkt_.get_payload_byte(PLINDEX_VANE); } + HorizontalVaneByte get_horizontal_vane() const { + return (HorizontalVaneByte) (pkt_.get_payload_byte(PLINDEX_HORIZONTAL_VANE) & 0x7F); + } + bool get_horizontal_vane_msb() const { return pkt_.get_payload_byte(PLINDEX_HORIZONTAL_VANE) & 0x80; } + + float get_target_temp() const; + + SettingsSetRequestPacket &set_power(bool is_on); + SettingsSetRequestPacket &set_mode(ModeByte mode); + SettingsSetRequestPacket &set_target_temperature(float temperature_degrees_c); + SettingsSetRequestPacket &set_fan(FanByte fan); + SettingsSetRequestPacket &set_vane(VaneByte vane); + SettingsSetRequestPacket &set_horizontal_vane(HorizontalVaneByte horizontal_vane); + + std::string to_string() const override; + + private: + void add_settings_flag_(SettingFlag flag_to_add); + void add_settings_flag2_(SettingFlag2 flag2_to_add); +}; + +class RemoteTemperatureSetRequestPacket : public Packet { + static const uint8_t PLINDEX_LEGACY_REMOTE_TEMPERATURE = 2; + static const uint8_t PLINDEX_REMOTE_TEMPERATURE = 3; + + public: + RemoteTemperatureSetRequestPacket() : Packet(RawPacket(PacketType::SET_REQUEST, 4)) { + pkt_.set_payload_byte(0, static_cast(SetCommand::REMOTE_TEMPERATURE)); + } + using Packet::Packet; + + float get_remote_temperature() const; + + RemoteTemperatureSetRequestPacket &set_remote_temperature(float temperature_degrees_c); + RemoteTemperatureSetRequestPacket &use_internal_temperature(); + + std::string to_string() const override; +}; + +class SetResponsePacket : public Packet { + using Packet::Packet; + + public: + SetResponsePacket() : Packet(RawPacket(PacketType::SET_RESPONSE, 16)) {} + + uint8_t get_result_code() const { return pkt_.get_payload_byte(0); } + bool is_successful() const { return get_result_code() == 0; } +}; + +class SetRunStatePacket : public Packet { + // bytes 1 and 2 are update flags + static const uint8_t PLINDEX_FILTER_RESET = 3; + + using Packet::Packet; + + public: + SetRunStatePacket() : Packet(RawPacket(PacketType::SET_REQUEST, 10)) { + pkt_.set_payload_byte(0, static_cast(SetCommand::RUN_STATE)); + } + + bool get_filter_reset() { return pkt_.get_payload_byte(PLINDEX_FILTER_RESET) != 0; }; + SetRunStatePacket &set_filter_reset(bool do_reset); +}; + +class ThermostatSensorStatusPacket : public Packet { + using Packet::Packet; + + public: + enum ThermostatBatteryState : uint8_t { + THERMOSTAT_BATTERY_OK = 0x00, + THERMOSTAT_BATTERY_LOW = 0x01, + THERMOSTAT_BATTERY_CRITICAL = 0x02, + THERMOSTAT_BATTERY_REPLACE = 0x03, + THERMOSTAT_BATTERY_UNKNOWN = 0x04, + }; + + ThermostatSensorStatusPacket() : Packet(RawPacket(PacketType::SET_REQUEST, 16)) { + pkt_.set_payload_byte(0, static_cast(SetCommand::THERMOSTAT_SENSOR_STATUS)); + } + + uint8_t get_indoor_humidity_percent() const { return pkt_.get_payload_byte(5); } + ThermostatBatteryState get_thermostat_battery_state() const { + return (ThermostatBatteryState) pkt_.get_payload_byte(6); + } + uint8_t get_sensor_flags() const { return pkt_.get_payload_byte(7); } + + std::string to_string() const override; +}; + +// Sent by MHK2 but with no response; defined to allow setResponseExpected(false) +class ThermostatHelloPacket : public Packet { + using Packet::Packet; + + public: + ThermostatHelloPacket() : Packet(RawPacket(PacketType::SET_REQUEST, 16)) { + pkt_.set_payload_byte(0, static_cast(SetCommand::THERMOSTAT_HELLO)); + } + + std::string get_thermostat_model() const; + std::string get_thermostat_serial() const; + std::string get_thermostat_version_string() const; + + std::string to_string() const override; +}; + +class ThermostatStateUploadPacket : public Packet { + // Packet 0x41 - AG 0xA8 + + static const uint8_t PLINDEX_THERMOSTAT_TIMESTAMP = 2; + static const uint8_t PLINDEX_AUTO_MODE = 7; + static const uint8_t PLINDEX_HEAT_SETPOINT = 8; + static const uint8_t PLINDEX_COOL_SETPOINT = 9; + + enum TSStateSyncFlags : uint8_t { + TSSF_TIMESTAMP = 0x01, + TSSF_AUTO_MODE = 0x04, + TSSF_HEAT_SETPOINT = 0x08, + TSSF_COOL_SETPOINT = 0x10, + }; + + using Packet::Packet; + + public: + ThermostatStateUploadPacket() : Packet(RawPacket(PacketType::SET_REQUEST, 16)) { + pkt_.set_payload_byte(0, static_cast(SetCommand::THERMOSTAT_STATE_UPLOAD)); + } + + time_t get_thermostat_timestamp(esphome::ESPTime *out_timestamp) const; + uint8_t get_auto_mode() const; + float get_heat_setpoint() const; + float get_cool_setpoint() const; + + std::string to_string() const override; +}; + +class ThermostatStateDownloadResponsePacket : public Packet { + static const uint8_t PLINDEX_ADAPTER_TIMESTAMP = 1; + static const uint8_t PLINDEX_AUTO_MODE = 6; + static const uint8_t PLINDEX_HEAT_SETPOINT = 7; + static const uint8_t PLINDEX_COOL_SETPOINT = 8; + + using Packet::Packet; + + public: + ThermostatStateDownloadResponsePacket() : Packet(RawPacket(PacketType::GET_RESPONSE, 16)) { + pkt_.set_payload_byte(0, static_cast(GetCommand::THERMOSTAT_STATE_DOWNLOAD)); + } + + ThermostatStateDownloadResponsePacket &set_timestamp(ESPTime ts); + ThermostatStateDownloadResponsePacket &set_auto_mode(bool is_auto); + ThermostatStateDownloadResponsePacket &set_heat_setpoint(float high_temp); + ThermostatStateDownloadResponsePacket &set_cool_setpoint(float low_temp); +}; + +class ThermostatAASetRequestPacket : public Packet { + using Packet::Packet; + + public: + ThermostatAASetRequestPacket() : Packet(RawPacket(PacketType::SET_REQUEST, 16)) { + pkt_.set_payload_byte(0, static_cast(SetCommand::THERMOSTAT_SET_AA)); + } +}; + +class ThermostatABGetResponsePacket : public Packet { + using Packet::Packet; + + public: + ThermostatABGetResponsePacket() : Packet(RawPacket(PacketType::GET_RESPONSE, 16)) { + pkt_.set_payload_byte(0, static_cast(GetCommand::THERMOSTAT_GET_AB)); + pkt_.set_payload_byte(1, 1); + } +}; + +class PacketProcessor { + public: + virtual void process_packet(const Packet &packet){}; + virtual void process_packet(const ConnectRequestPacket &packet){}; + virtual void process_packet(const ConnectResponsePacket &packet){}; + virtual void process_packet(const CapabilitiesRequestPacket &packet){}; + virtual void process_packet(const CapabilitiesResponsePacket &packet){}; + virtual void process_packet(const GetRequestPacket &packet){}; + virtual void process_packet(const SettingsGetResponsePacket &packet){}; + virtual void process_packet(const CurrentTempGetResponsePacket &packet){}; + virtual void process_packet(const StatusGetResponsePacket &packet){}; + virtual void process_packet(const RunStateGetResponsePacket &packet){}; + virtual void process_packet(const ErrorStateGetResponsePacket &packet){}; + virtual void process_packet(const SettingsSetRequestPacket &packet){}; + virtual void process_packet(const RemoteTemperatureSetRequestPacket &packet){}; + virtual void process_packet(const ThermostatSensorStatusPacket &packet){}; + virtual void process_packet(const ThermostatHelloPacket &packet){}; + virtual void process_packet(const ThermostatStateUploadPacket &packet){}; + virtual void process_packet(const ThermostatStateDownloadResponsePacket &packet){}; + virtual void process_packet(const ThermostatAASetRequestPacket &packet){}; + virtual void process_packet(const ThermostatABGetResponsePacket &packet){}; + virtual void process_packet(const SetResponsePacket &packet){}; + + virtual void handle_thermostat_state_download_request(const GetRequestPacket &packet){}; + virtual void handle_thermostat_ab_get_request(const GetRequestPacket &packet){}; +}; + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitp_rawpacket.cpp b/esphome/components/mitsubishi_itp/mitp_rawpacket.cpp new file mode 100644 index 0000000000..a0a0378500 --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitp_rawpacket.cpp @@ -0,0 +1,70 @@ +#include "mitp_rawpacket.h" + +namespace esphome { +namespace mitsubishi_itp { + +// Creates an empty packet +RawPacket::RawPacket(PacketType packet_type, uint8_t payload_size, SourceBridge source_bridge, + ControllerAssociation controller_association) + : length_{(uint8_t) (payload_size + PACKET_HEADER_SIZE + 1)}, + checksum_index_{(uint8_t) (length_ - 1)}, + source_bridge_{source_bridge}, + controller_association_{controller_association} { + memcpy(packet_bytes_, EMPTY_PACKET, length_); + packet_bytes_[PACKET_HEADER_INDEX_PACKET_TYPE] = static_cast(packet_type); + packet_bytes_[PACKET_HEADER_INDEX_PAYLOAD_LENGTH] = payload_size; + + update_checksum_(); +} + +// Creates a packet with the provided bytes +RawPacket::RawPacket(const uint8_t packet_bytes[], const uint8_t packet_length, SourceBridge source_bridge, + ControllerAssociation controller_association) + : length_{(uint8_t) packet_length}, + checksum_index_{(uint8_t) (packet_length - 1)}, + source_bridge_{source_bridge}, + controller_association_{controller_association} { + memcpy(packet_bytes_, packet_bytes, packet_length); + + if (!this->is_checksum_valid()) { + // For now, just log this as information (we can decide if we want to process it elsewhere) + ESP_LOGI(PTAG, "Packet of type %x has invalid checksum!", this->get_packet_type()); + } +} + +// Creates an empty RawPacket +RawPacket::RawPacket() { + // TODO: Is this okay? +} + +uint8_t RawPacket::calculate_checksum_() const { // NOLINT(readability-identifier-naming) + uint8_t sum = 0; + for (int i = 0; i < checksum_index_; i++) { + sum += packet_bytes_[i]; + } + + return (0xfc - sum) & 0xff; +} + +RawPacket &RawPacket::update_checksum_() { + packet_bytes_[checksum_index_] = calculate_checksum_(); + return *this; +} + +bool RawPacket::is_checksum_valid() const { return packet_bytes_[checksum_index_] == calculate_checksum_(); } + +// Sets a payload byte and automatically updates the packet checksum +RawPacket &RawPacket::set_payload_byte(const uint8_t payload_byte_index, const uint8_t value) { + packet_bytes_[PACKET_HEADER_SIZE + payload_byte_index] = value; + update_checksum_(); + return *this; +} + +RawPacket &RawPacket::set_payload_bytes(const uint8_t begin_index, const void *value, const size_t size) { + memcpy(&packet_bytes_[PACKET_HEADER_SIZE + begin_index], value, size); + update_checksum_(); + return *this; +} + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitp_rawpacket.h b/esphome/components/mitsubishi_itp/mitp_rawpacket.h new file mode 100644 index 0000000000..0cc9b56920 --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitp_rawpacket.h @@ -0,0 +1,122 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/climate/climate.h" +#include "esphome/components/uart/uart.h" +#include + +namespace esphome { +namespace mitsubishi_itp { + +static constexpr char PTAG[] = "mitsubishi_itp.packets"; + +const uint8_t BYTE_CONTROL = 0xfc; +const uint8_t PACKET_MAX_SIZE = 22; // Used to intialize empty packet +const uint8_t PACKET_HEADER_SIZE = 5; +const uint8_t PACKET_HEADER_INDEX_PACKET_TYPE = 1; +const uint8_t PACKET_HEADER_INDEX_PAYLOAD_LENGTH = 4; + +// TODO: Figure out something here so we don't have to static_cast as much +enum class PacketType : uint8_t { + CONNECT_REQUEST = 0x5a, + CONNECT_RESPONSE = 0x7a, + GET_REQUEST = 0x42, + GET_RESPONSE = 0x62, + SET_REQUEST = 0x41, + SET_RESPONSE = 0x61, + IDENTIFY_REQUEST = 0x5b, + IDENTIFY_RESPONSE = 0x7b +}; + +// Used to specify certain packet subtypes +enum class GetCommand : uint8_t { + SETTINGS = 0x02, + CURRENT_TEMP = 0x03, + ERROR_INFO = 0x04, + STATUS = 0x06, + RUN_STATE = 0x09, + THERMOSTAT_STATE_DOWNLOAD = 0xa9, + THERMOSTAT_GET_AB = 0xab, +}; + +// Used to specify certain packet subtypes +enum class SetCommand : uint8_t { + SETTINGS = 0x01, + REMOTE_TEMPERATURE = 0x07, + RUN_STATE = 0x08, + THERMOSTAT_SENSOR_STATUS = 0xa6, + THERMOSTAT_HELLO = 0xa7, + THERMOSTAT_STATE_UPLOAD = 0xa8, + THERMOSTAT_SET_AA = 0xaa, +}; + +// Which MITPBridge was the packet read from (used to determine flow direction of the packet) +enum class SourceBridge { NONE, HEATPUMP, THERMOSTAT }; + +// Specifies which controller the packet "belongs" to (i.e. which controler created it either directly or via a request +// packet) +enum class ControllerAssociation { MITP, THERMOSTAT }; + +static const uint8_t EMPTY_PACKET[PACKET_MAX_SIZE] = {BYTE_CONTROL, // Sync + 0x00, // Packet type + 0x01, 0x30, // Unknown + 0x00, // Payload Size + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Payload + 0x00}; + +/* A class representing the raw packet sent to or from the Mitsubishi equipment with definitions +for header indexes, checksum calculations, utility methods, etc. These generally shouldn't be accessed +directly outside the MITPBridge, and the Packet class (or its subclasses) should be used instead. +*/ +class RawPacket { + public: + RawPacket( + const uint8_t packet_bytes[], uint8_t packet_length, SourceBridge source_bridge = SourceBridge::NONE, + ControllerAssociation controller_association = ControllerAssociation::MITP); // For reading or copying packets + // TODO: Can I hide this constructor except from optional? + RawPacket(); // For optional construction + RawPacket(PacketType packet_type, uint8_t payload_size, SourceBridge source_bridge = SourceBridge::NONE, + ControllerAssociation controller_association = ControllerAssociation::MITP); // For building packets + virtual ~RawPacket() {} + + virtual std::string to_string() const { return format_hex_pretty(&get_bytes()[0], get_length()); }; + + uint8_t get_length() const { return length_; }; + const uint8_t *get_bytes() const { return packet_bytes_; }; // Primarily for sending packets + + bool is_checksum_valid() const; + + // Returns the packet type byte + uint8_t get_packet_type() const { return packet_bytes_[PACKET_HEADER_INDEX_PACKET_TYPE]; }; + // Returns the first byte of the payload, often used as a command + uint8_t get_command() const { return get_payload_byte(PLINDEX_COMMAND); }; + + SourceBridge get_source_bridge() const { return source_bridge_; }; + ControllerAssociation get_controller_association() const { return controller_association_; }; + + RawPacket &set_payload_byte(uint8_t payload_byte_index, uint8_t value); + RawPacket &set_payload_bytes(uint8_t begin_index, const void *value, size_t size); + uint8_t get_payload_byte(const uint8_t payload_byte_index) const { + return packet_bytes_[PACKET_HEADER_SIZE + payload_byte_index]; + }; + const uint8_t *get_payload_bytes(size_t start_index = 0) const { + return &packet_bytes_[PACKET_HEADER_SIZE + start_index]; + } + + private: + static const int PLINDEX_COMMAND = 0; + + uint8_t packet_bytes_[PACKET_MAX_SIZE]{}; + uint8_t length_; + uint8_t checksum_index_; + + SourceBridge source_bridge_; + ControllerAssociation controller_association_; + + uint8_t calculate_checksum_() const; + RawPacket &update_checksum_(); +}; + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitp_utils.h b/esphome/components/mitsubishi_itp/mitp_utils.h new file mode 100644 index 0000000000..b934c2f1e7 --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitp_utils.h @@ -0,0 +1,87 @@ +#pragma once + +namespace esphome { +namespace mitsubishi_itp { + +class MITPUtils { + public: + /// Read a string out of data, wordSize bits at a time. + /// Used to decode serial numbers and other information from a thermostat. + static std::string decode_n_bit_string(const uint8_t data[], size_t data_length, size_t word_size = 6) { + auto result = std::string(); + + for (int i = 0; i < data_length; i++) { + auto bits = bit_slice(data, i * word_size, ((i + 1) * word_size) - 1); + if (bits <= 0x1F) + bits += 0x40; + result += (char) bits; + } + + return result; + } + + static float temp_scale_a_to_deg_c(const uint8_t value) { return (float) (value - 128) / 2.0f; } + + static uint8_t deg_c_to_temp_scale_a(const float value) { + // Special cases + if (value < -64) + return 0; + if (value > 63.5f) + return 0xFF; + + return (uint8_t) round(value * 2) + 128; + } + + static float legacy_target_temp_to_deg_c(const uint8_t value) { + return ((float) (31 - (value & 0x0F)) + (((value & 0xF0) > 0) ? 0.5f : 0)); + } + + static uint8_t deg_c_to_legacy_target_temp(const float value) { + // Special cases per docs + if (value < 16) + return 0x0F; + if (value > 31.5) + return 0x10; + + return ((31 - (uint8_t) value) & 0xF) + (((int) (value * 2) % 2) << 4); + } + + static float legacy_room_temp_to_deg_c(const uint8_t value) { return (float) value + 10; } + + static uint8_t deg_c_to_legacy_room_temp(const float value) { + if (value < 10) + return 0x00; + if (value > 41) + return 0x1F; + + return (uint8_t) value - 10; + } + + private: + /// Extract the specified bits (inclusive) from an arbitrarily-sized byte array. Does not perform bounds checks. + /// Max extraction is 64 bits. Preserves endianness of incoming data stream. + static uint64_t bit_slice(const uint8_t ds[], size_t start, size_t end) { + if ((end - start) >= 64) + return 0; + + uint64_t result = 0; + + size_t start_byte = (start) / 8; + size_t end_byte = ((end) / 8) + 1; // exclusive, used for length calc + + // raw copy the relevant bytes into our int64, preserving endian-ness + std::memcpy(&result, &ds[start_byte], end_byte - start_byte); + result = byteswap(result); + + // shift out the bits we don't want from the end (64 + credit any pre-sliced bits) + result >>= (sizeof(uint64_t) * 8) + (start_byte * 8) - end - 1; + + // mask out the number of bits we want + result &= (1 << (end - start + 1)) - 1; + + return result; + } +}; + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitsubishi_itp-climatecall.cpp b/esphome/components/mitsubishi_itp/mitsubishi_itp-climatecall.cpp new file mode 100644 index 0000000000..1af9c20d83 --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitsubishi_itp-climatecall.cpp @@ -0,0 +1,120 @@ +#include "mitsubishi_itp.h" + +namespace esphome { +namespace mitsubishi_itp { + +// Called to instruct a change of the climate controls +void MitsubishiUART::control(const climate::ClimateCall &call) { + if (!active_mode_) + return; // If we're not in active mode, ignore control requests + + SettingsSetRequestPacket set_request_packet = SettingsSetRequestPacket(); + + // Apply fan settings + // Prioritize a custom fan mode if it's set. + if (call.get_custom_fan_mode().has_value()) { + if (call.get_custom_fan_mode().value() == FAN_MODE_VERYHIGH) { + set_custom_fan_mode_(FAN_MODE_VERYHIGH); + set_request_packet.set_fan(SettingsSetRequestPacket::FAN_4); + } + } else if (call.get_fan_mode().has_value()) { + switch (call.get_fan_mode().value()) { + case climate::CLIMATE_FAN_QUIET: + set_fan_mode_(climate::CLIMATE_FAN_QUIET); + set_request_packet.set_fan(SettingsSetRequestPacket::FAN_QUIET); + break; + case climate::CLIMATE_FAN_LOW: + set_fan_mode_(climate::CLIMATE_FAN_LOW); + set_request_packet.set_fan(SettingsSetRequestPacket::FAN_1); + break; + case climate::CLIMATE_FAN_MEDIUM: + set_fan_mode_(climate::CLIMATE_FAN_MEDIUM); + set_request_packet.set_fan(SettingsSetRequestPacket::FAN_2); + break; + case climate::CLIMATE_FAN_HIGH: + set_fan_mode_(climate::CLIMATE_FAN_HIGH); + set_request_packet.set_fan(SettingsSetRequestPacket::FAN_3); + break; + case climate::CLIMATE_FAN_AUTO: + set_fan_mode_(climate::CLIMATE_FAN_AUTO); + set_request_packet.set_fan(SettingsSetRequestPacket::FAN_AUTO); + break; + default: + ESP_LOGW(TAG, "Unhandled fan mode %i!", call.get_fan_mode().value()); + break; + } + } + + // Mode + + if (call.get_mode().has_value()) { + mode = call.get_mode().value(); + + switch (call.get_mode().value()) { + case climate::CLIMATE_MODE_HEAT_COOL: + set_request_packet.set_power(true).set_mode(SettingsSetRequestPacket::MODE_BYTE_AUTO); + break; + case climate::CLIMATE_MODE_COOL: + set_request_packet.set_power(true).set_mode(SettingsSetRequestPacket::MODE_BYTE_COOL); + break; + case climate::CLIMATE_MODE_HEAT: + set_request_packet.set_power(true).set_mode(SettingsSetRequestPacket::MODE_BYTE_HEAT); + break; + case climate::CLIMATE_MODE_FAN_ONLY: + set_request_packet.set_power(true).set_mode(SettingsSetRequestPacket::MODE_BYTE_FAN); + break; + case climate::CLIMATE_MODE_DRY: + set_request_packet.set_power(true).set_mode(SettingsSetRequestPacket::MODE_BYTE_DRY); + break; + case climate::CLIMATE_MODE_OFF: + default: + set_request_packet.set_power(false); + break; + } + } + + // Target Temperature + + if (call.get_target_temperature().has_value()) { + target_temperature = call.get_target_temperature().value(); + set_request_packet.set_target_temperature(call.get_target_temperature().value()); + + // update our MHK tracking setpoints accordingly + switch (mode) { + case climate::CLIMATE_MODE_COOL: + case climate::CLIMATE_MODE_DRY: + this->mhk_state_.cool_setpoint_ = target_temperature; + break; + case climate::CLIMATE_MODE_HEAT: + this->mhk_state_.heat_setpoint_ = target_temperature; + break; + case climate::CLIMATE_MODE_HEAT_COOL: + if (this->get_traits().get_supports_two_point_target_temperature()) { + this->mhk_state_.cool_setpoint_ = target_temperature_low; + this->mhk_state_.heat_setpoint_ = target_temperature_high; + } else { + // HACK: This is not accurate, but it's good enough for testing. + this->mhk_state_.cool_setpoint_ = target_temperature + 2; + this->mhk_state_.heat_setpoint_ = target_temperature - 2; + } + default: + break; + } + } + + // TODO: + // Vane + // HVane? + // Swing? + + // We're assuming that every climate call *does* make some change worth sending to the heat pump + // Queue the packet to be sent first (so any subsequent update packets come *after* our changes) + hp_bridge_.send_packet(set_request_packet); + + // Publish state and any sensor changes (shouldn't be any a result of this function, but + // since they lazy-publish, no harm in trying) + do_publish_(); +} + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitsubishi_itp-packetprocessing.cpp b/esphome/components/mitsubishi_itp/mitsubishi_itp-packetprocessing.cpp new file mode 100644 index 0000000000..2427ed7d9a --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitsubishi_itp-packetprocessing.cpp @@ -0,0 +1,363 @@ +#include "mitsubishi_itp.h" + +namespace esphome { +namespace mitsubishi_itp { + +void MitsubishiUART::route_packet_(const Packet &packet) { + // If the packet is associated with the thermostat and just came from the thermostat, send it to the heatpump + // If it came from the heatpump, send it back to the thermostat + if (packet.get_controller_association() == ControllerAssociation::THERMOSTAT) { + if (packet.get_source_bridge() == SourceBridge::THERMOSTAT) { + hp_bridge_.send_packet(packet); + } else if (packet.get_source_bridge() == SourceBridge::HEATPUMP) { + ts_bridge_->send_packet(packet); + } + } +} + +// Packet Handlers +void MitsubishiUART::process_packet(const Packet &packet) { + ESP_LOGI(TAG, "Generic unhandled packet type %x received.", packet.get_packet_type()); + ESP_LOGD(TAG, "%s", packet.to_string().c_str()); + route_packet_(packet); +} + +void MitsubishiUART::process_packet(const ConnectRequestPacket &packet) { + // Nothing to be done for these except forward them along from thermostat to heat pump. + // This method defined so that these packets are not "unhandled" + ESP_LOGV(TAG, "Passing through inbound %s", packet.to_string().c_str()); + route_packet_(packet); +} +void MitsubishiUART::process_packet(const ConnectResponsePacket &packet) { + ESP_LOGV(TAG, "Processing %s", packet.to_string().c_str()); + route_packet_(packet); + // Not sure if there's any needed content in this response, so assume we're connected. + hp_connected_ = true; + ESP_LOGI(TAG, "Heatpump connected."); +} + +void MitsubishiUART::process_packet(const CapabilitiesRequestPacket &packet) { + // Nothing to be done for these except forward them along from thermostat to heat pump. + // This method defined so that these packets are not "unhandled" + ESP_LOGV(TAG, "Passing through inbound %s", packet.to_string().c_str()); + route_packet_(packet); +} +void MitsubishiUART::process_packet(const CapabilitiesResponsePacket &packet) { + ESP_LOGV(TAG, "Processing %s", packet.to_string().c_str()); + route_packet_(packet); + // Not sure if there's any needed content in this response, so assume we're connected. + // TODO: Is there more useful info in these? + hp_connected_ = true; + capabilities_cache_ = packet; + ESP_LOGI(TAG, "Received heat pump identification packet."); +} + +void MitsubishiUART::process_packet(const GetRequestPacket &packet) { + ESP_LOGV(TAG, "Processing %s", packet.to_string().c_str()); + + switch (packet.get_requested_command()) { + case GetCommand::THERMOSTAT_STATE_DOWNLOAD: + this->handle_thermostat_state_download_request(packet); + break; + case GetCommand::THERMOSTAT_GET_AB: + this->handle_thermostat_ab_get_request(packet); + break; + default: + route_packet_(packet); + } +} + +void MitsubishiUART::process_packet(const SettingsGetResponsePacket &packet) { + ESP_LOGV(TAG, "Processing %s", packet.to_string().c_str()); + route_packet_(packet); + alert_listeners_(packet); + + // Mode + + const climate::ClimateMode old_mode = mode; + if (packet.get_power()) { + switch (packet.get_mode()) { + case 0x01: + case 0x09: // i-see + mode = climate::CLIMATE_MODE_HEAT; + break; + case 0x02: + case 0x0A: // i-see + mode = climate::CLIMATE_MODE_DRY; + break; + case 0x03: + case 0x0B: // i-see + mode = climate::CLIMATE_MODE_COOL; + break; + case 0x07: + mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + case 0x08: + // unsure when 0x21 or 0x23 would ever be sent, as they seem to be Kumo exclusive, but let's handle them anyways. + case 0x21: + case 0x23: + mode = climate::CLIMATE_MODE_HEAT_COOL; + break; + default: + mode = climate::CLIMATE_MODE_OFF; + } + } else { + mode = climate::CLIMATE_MODE_OFF; + } + + publish_on_update_ |= (old_mode != mode); + + // Temperature + const float old_target_temperature = target_temperature; + target_temperature = packet.get_target_temp(); + publish_on_update_ |= (old_target_temperature != target_temperature); + + switch (mode) { + case climate::CLIMATE_MODE_COOL: + case climate::CLIMATE_MODE_DRY: + this->mhk_state_.cool_setpoint_ = target_temperature; + break; + case climate::CLIMATE_MODE_HEAT: + this->mhk_state_.heat_setpoint_ = target_temperature; + break; + case climate::CLIMATE_MODE_HEAT_COOL: + this->mhk_state_.cool_setpoint_ = target_temperature + 2; + this->mhk_state_.heat_setpoint_ = target_temperature - 2; + default: + break; + } + + // Fan + static bool fan_changed = false; + switch (packet.get_fan()) { + case 0x00: + fan_changed = set_fan_mode_(climate::CLIMATE_FAN_AUTO); + break; + case 0x01: + fan_changed = set_fan_mode_(climate::CLIMATE_FAN_QUIET); + break; + case 0x02: + fan_changed = set_fan_mode_(climate::CLIMATE_FAN_LOW); + break; + case 0x03: + fan_changed = set_fan_mode_(climate::CLIMATE_FAN_MEDIUM); + break; + case 0x05: + fan_changed = set_fan_mode_(climate::CLIMATE_FAN_HIGH); + break; + case 0x06: + fan_changed = set_custom_fan_mode_(FAN_MODE_VERYHIGH); + break; + } + + publish_on_update_ |= fan_changed; +} + +void MitsubishiUART::process_packet(const CurrentTempGetResponsePacket &packet) { + ESP_LOGV(TAG, "Processing %s", packet.to_string().c_str()); + route_packet_(packet); + alert_listeners_(packet); + // This will be the same as the remote temperature if we're using a remote sensor, otherwise the internal temp + const float old_current_temperature = current_temperature; + current_temperature = packet.get_current_temp(); + + publish_on_update_ |= (old_current_temperature != current_temperature); +} + +void MitsubishiUART::process_packet(const StatusGetResponsePacket &packet) { + ESP_LOGV(TAG, "Processing %s", packet.to_string().c_str()); + route_packet_(packet); + alert_listeners_(packet); + + const climate::ClimateAction old_action = action; + + // If mode is off, action is off + if (mode == climate::CLIMATE_MODE_OFF) { + action = climate::CLIMATE_ACTION_OFF; + } + // If mode is fan only, packet.getOperating() may be false, but the fan is running + else if (mode == climate::CLIMATE_MODE_FAN_ONLY) { + action = climate::CLIMATE_ACTION_FAN; + } + // If mode is anything other than off or fan, and the unit is operating, determine the action + else if (packet.get_operating()) { + switch (mode) { + case climate::CLIMATE_MODE_HEAT: + action = climate::CLIMATE_ACTION_HEATING; + break; + case climate::CLIMATE_MODE_COOL: + action = climate::CLIMATE_ACTION_COOLING; + break; + case climate::CLIMATE_MODE_DRY: + action = climate::CLIMATE_ACTION_DRYING; + break; + // TODO: This only works if we get an update while the temps are in this configuration + // Surely there's some info from the heat pump about which of these modes it's in? + case climate::CLIMATE_MODE_HEAT_COOL: + if (current_temperature > target_temperature) { + action = climate::CLIMATE_ACTION_COOLING; + } else if (current_temperature < target_temperature) { + action = climate::CLIMATE_ACTION_HEATING; + } + // When the heat pump *changes* to a new action, these temperature comparisons should be accurate. + // If the mode hasn't changed, but the temps are equal, we can assume the same action and make no change. + // If the unit overshoots, this still doesn't work. + break; + default: + ESP_LOGW(TAG, "Unhandled mode %i.", mode); + break; + } + } + // If we're not operating (but not off or in fan mode), we're idle + // Should be relatively safe to fall through any unknown modes into showing IDLE + else { + action = climate::CLIMATE_ACTION_IDLE; + } + + publish_on_update_ |= (old_action != action); +} +void MitsubishiUART::process_packet(const RunStateGetResponsePacket &packet) { + ESP_LOGV(TAG, "Processing %s", packet.to_string().c_str()); + route_packet_(packet); + alert_listeners_(packet); + + run_state_received_ = true; // Set this since we received one + + // TODO: Not sure what AutoMode does yet +} + +void MitsubishiUART::process_packet(const ErrorStateGetResponsePacket &packet) { + ESP_LOGV(TAG, "Processing %s", packet.to_string().c_str()); + route_packet_(packet); + alert_listeners_(packet); +} + +void MitsubishiUART::process_packet(const SettingsSetRequestPacket &packet) { + ESP_LOGV(TAG, "Passing through inbound %s", packet.to_string().c_str()); + + // forward this packet as-is; we're just intercepting to log. + alert_listeners_(packet); +} + +void MitsubishiUART::process_packet(const RemoteTemperatureSetRequestPacket &packet) { + ESP_LOGV(TAG, "Processing %s", packet.to_string().c_str()); + + // Only send this temperature packet to the heatpump if Thermostat is the selected source, + // or we're in passive mode (since in passive mode we're not generating any packets to + // set the temperature) otherwise just respond to the thermostat to keep it happy. + if (current_temperature_source_ == TEMPERATURE_SOURCE_THERMOSTAT || !active_mode_) { + route_packet_(packet); + } else { + ts_bridge_->send_packet(SetResponsePacket()); + } + alert_listeners_(packet); + + float t = packet.get_remote_temperature(); + temperature_source_report(TEMPERATURE_SOURCE_THERMOSTAT, t); +} + +void MitsubishiUART::process_packet(const ThermostatSensorStatusPacket &packet) { + if (!enhanced_mhk_support_) { + ESP_LOGV(TAG, "Passing through inbound %s", packet.to_string().c_str()); + + route_packet_(packet); + return; + } + + ESP_LOGV(TAG, "Processing inbound %s", packet.to_string().c_str()); + + alert_listeners_(packet); + + ts_bridge_->send_packet(SetResponsePacket()); +} + +void MitsubishiUART::process_packet(const ThermostatHelloPacket &packet) { + if (!enhanced_mhk_support_) { + ESP_LOGV(TAG, "Passing through inbound %s", packet.to_string().c_str()); + + route_packet_(packet); + return; + } + + ESP_LOGV(TAG, "Processing inbound %s", packet.to_string().c_str()); + ts_bridge_->send_packet(SetResponsePacket()); +} + +void MitsubishiUART::process_packet(const ThermostatStateUploadPacket &packet) { + if (!enhanced_mhk_support_) { + ESP_LOGV(TAG, "Passing through inbound %s", packet.to_string().c_str()); + + route_packet_(packet); + return; + } + + ESP_LOGV(TAG, "Processing inbound %s", packet.to_string().c_str()); + + if (packet.get_flags() & 0x08) + this->mhk_state_.heat_setpoint_ = packet.get_heat_setpoint(); + if (packet.get_flags() & 0x10) + this->mhk_state_.cool_setpoint_ = packet.get_cool_setpoint(); + + ts_bridge_->send_packet(SetResponsePacket()); +} + +void MitsubishiUART::process_packet(const ThermostatAASetRequestPacket &packet) { + if (!enhanced_mhk_support_) { + ESP_LOGV(TAG, "Passing through inbound %s", packet.to_string().c_str()); + + route_packet_(packet); + return; + } + + ESP_LOGV(TAG, "Processing inbound %s", packet.to_string().c_str()); + + ts_bridge_->send_packet(SetResponsePacket()); +} + +void MitsubishiUART::process_packet(const SetResponsePacket &packet) { + ESP_LOGV(TAG, "Got Set Response packet, success = %s (code = %x)", packet.is_successful() ? "true" : "false", + packet.get_result_code()); + route_packet_(packet); +} + +// Process incoming data requests from an MHK probing for/running in enhanced mode +void MitsubishiUART::handle_thermostat_state_download_request(const GetRequestPacket &packet) { + if (!enhanced_mhk_support_) { + route_packet_(packet); + return; + } + + auto response = ThermostatStateDownloadResponsePacket(); + + response.set_auto_mode((mode == climate::CLIMATE_MODE_HEAT_COOL || mode == climate::CLIMATE_MODE_AUTO)); + response.set_heat_setpoint(this->mhk_state_.heat_setpoint_); + response.set_cool_setpoint(this->mhk_state_.cool_setpoint_); + +#ifdef USE_TIME + if (this->time_source_ != nullptr) { + response.set_timestamp(this->time_source_->now()); + } else { + ESP_LOGW(TAG, "No time source specified. Cannot provide accurate time!"); + response.set_timestamp(ESPTime::from_epoch_utc(1704067200)); // 2024-01-01 00:00:00Z + } +#else + ESP_LOGW(TAG, "No time source specified. Cannot provide accurate time!"); + response.set_timestamp(ESPTime::from_epoch_utc(1704067200)); // 2024-01-01 00:00:00Z +#endif + + ts_bridge_->send_packet(response); +} + +void MitsubishiUART::handle_thermostat_ab_get_request(const GetRequestPacket &packet) { + if (!enhanced_mhk_support_) { + route_packet_(packet); + return; + } + + auto response = ThermostatABGetResponsePacket(); + + ts_bridge_->send_packet(response); +} + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitsubishi_itp.cpp b/esphome/components/mitsubishi_itp/mitsubishi_itp.cpp new file mode 100644 index 0000000000..c7ab8a0ad4 --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitsubishi_itp.cpp @@ -0,0 +1,273 @@ +#include "mitsubishi_itp.h" + +namespace esphome { +namespace mitsubishi_itp { + +//// +// MitsubishiUART +//// + +MitsubishiUART::MitsubishiUART(uart::UARTComponent *hp_uart_comp) + : hp_uart_{*hp_uart_comp}, hp_bridge_{HeatpumpBridge(hp_uart_comp, this)} { + /** + * Climate pushes all its data to Home Assistant immediately when the API connects, this causes + * the default 0 to be sent as temperatures, but since this is a valid value (0 deg C), it + * can cause confusion and mess with graphs when looking at the state in HA. Setting this to + * NAN gets HA to treat this value as "unavailable" until we have a real value to publish. + */ + target_temperature = NAN; + current_temperature = NAN; +} + +// Used to restore state of previous MITP-specific settings (like temperature source or pass-thru mode) +// Most other climate-state is preserved by the heatpump itself and will be retrieved after connection +void MitsubishiUART::setup() { + for (auto *listener : listeners_) { + listener->setup(bool(ts_uart_)); + } +} + +void MitsubishiUART::send_if_active_(const Packet &packet) { + if (active_mode_) + hp_bridge_.send_packet(packet); +} + +#define IFACTIVE(dothis) \ + if (active_mode_) { \ + dothis \ + } +#define IFNOTACTIVE(dothis) \ + if (!active_mode_) { \ + dothis \ + } + +/* Used for receiving and acting on incoming packets as soon as they're available. + Because packet processing happens as part of the receiving process, packet processing + should not block for very long (e.g. no publishing inside the packet processing) +*/ +void MitsubishiUART::loop() { + // Loop bridge to handle sending and receiving packets + hp_bridge_.loop(); + if (ts_bridge_) + ts_bridge_->loop(); + + // If it's been too long since we received a temperature update (and we're not set to Internal) + if (((millis() - last_received_temperature_) > TEMPERATURE_SOURCE_TIMEOUT_MS) && + current_temperature_source_ != TEMPERATURE_SOURCE_INTERNAL && !temperature_source_timeout_) { + ESP_LOGW(TAG, "No temperature received from %s for %lu milliseconds, reverting to Internal source", + current_temperature_source_.c_str(), (unsigned long) TEMPERATURE_SOURCE_TIMEOUT_MS); + // Let listeners know we've changed to the Internal temperature source (but do not change + // currentTemperatureSource) + for (auto *listener : listeners_) { + listener->temperature_source_change(TEMPERATURE_SOURCE_INTERNAL); + } + temperature_source_timeout_ = true; + // Send a packet to the heat pump to tell it to switch to internal temperature sensing + IFACTIVE(hp_bridge_.send_packet(RemoteTemperatureSetRequestPacket().use_internal_temperature());) + } +} + +void MitsubishiUART::dump_config() { + if (capabilities_cache_.has_value()) { + ESP_LOGCONFIG(TAG, "Discovered Capabilities: %s", capabilities_cache_.value().to_string().c_str()); + } + + if (enhanced_mhk_support_) { + ESP_LOGCONFIG(TAG, "MHK Enhanced Protocol Mode is ENABLED! This is currently *experimental* and things may break!"); + } +} + +// Set thermostat UART component +void MitsubishiUART::set_thermostat_uart(uart::UARTComponent *uart) { + ESP_LOGCONFIG(TAG, "Thermostat uart was set."); + ts_uart_ = uart; + ts_bridge_ = make_unique(ts_uart_, static_cast(this)); +} + +/* Called periodically as PollingComponent; used to send packets to connect or request updates. + +Possible TODO: If we only publish during updates, since data is received during loop, updates will always +be about `update_interval` late from their actual time. Generally the update interval should be low enough +(default is 5seconds) this won't pose a practical problem. +*/ +void MitsubishiUART::update() { + // TODO: Temporarily wait 5 seconds on startup to help with viewing logs + if (millis() < 5000) { + return; + } + + // If we're not yet connected, send off a connection request (we'll check again next update) + if (!hp_connected_) { + IFACTIVE(hp_bridge_.send_packet(ConnectRequestPacket::instance());) + return; + } + + // Attempt to read capabilities on the next loop after connect. + // TODO: This should likely be done immediately after connect, and will likely need to block setup for proper + // autoconf. + // For now, just requesting it as part of our "init loops" is a good first step. + if (!this->capabilities_requested_) { + IFACTIVE(hp_bridge_.send_packet(CapabilitiesRequestPacket::instance()); this->capabilities_requested_ = true;) + } + + // Before requesting additional updates, publish any changes waiting from packets received + + // Notify all listeners a publish is happening, they will decide if actual publish is needed. + for (auto *listener : listeners_) { + listener->publish(); + } + + if (publish_on_update_) { + do_publish_(); + + publish_on_update_ = false; + } + + IFACTIVE( + // Request an update from the heatpump + // TODO: This isn't a problem *yet*, but sending all these packets every loop might start to cause some issues + // in + // certain configurations or setups. We may want to consider only asking for certain packets on a rarer + // cadence, depending on their utility (e.g. we dont need to check for errors every loop). + hp_bridge_.send_packet( + GetRequestPacket::get_settings_instance()); // Needs to be done before status packet for mode logic to work + if (in_discovery_ || run_state_received_) { hp_bridge_.send_packet(GetRequestPacket::get_runstate_instance()); } + + hp_bridge_.send_packet(GetRequestPacket::get_status_instance()); + hp_bridge_.send_packet(GetRequestPacket::get_current_temp_instance()); + hp_bridge_.send_packet(GetRequestPacket::get_error_info_instance());) + + if (in_discovery_) { + // After criteria met, exit discovery mode + // Currently this is either 5 updates or a successful RunState response. + if (discovery_updates_++ > 5 || run_state_received_) { + ESP_LOGD(TAG, "Discovery complete."); + in_discovery_ = false; + + if (!run_state_received_) { + ESP_LOGI(TAG, "RunState packets not supported."); + } + } + } +} + +void MitsubishiUART::do_publish_() { publish_state(); } + +bool MitsubishiUART::select_temperature_source(const std::string &state) { + // TODO: Possibly check to see if state is available from the select options? (Might be a bit redundant) + + current_temperature_source_ = state; + // Reset the timeout for received temperature (without this, the menu dropdown will switch back to Internal + // temporarily) + last_received_temperature_ = millis(); + + // If we've switched to internal, let the HP know right away + if (TEMPERATURE_SOURCE_INTERNAL == state) { + IFACTIVE(hp_bridge_.send_packet(RemoteTemperatureSetRequestPacket().use_internal_temperature());) + } + + return true; +} + +bool MitsubishiUART::select_vane_position(const std::string &state) { + IFNOTACTIVE(return false;) // Skip this if we're not in active mode + SettingsSetRequestPacket::VaneByte position_byte = SettingsSetRequestPacket::VANE_AUTO; + + // NOTE: Annoyed that C++ doesn't have switches for strings, but since this is going to be called + // infrequently, this is probably a better solution than over-optimizing via maps or something + + if (state == "Auto") { + position_byte = SettingsSetRequestPacket::VANE_AUTO; + } else if (state == "1") { + position_byte = SettingsSetRequestPacket::VANE_1; + } else if (state == "2") { + position_byte = SettingsSetRequestPacket::VANE_2; + } else if (state == "3") { + position_byte = SettingsSetRequestPacket::VANE_3; + } else if (state == "4") { + position_byte = SettingsSetRequestPacket::VANE_4; + } else if (state == "5") { + position_byte = SettingsSetRequestPacket::VANE_5; + } else if (state == "Swing") { + position_byte = SettingsSetRequestPacket::VANE_SWING; + } else { + ESP_LOGW(TAG, "Unknown vane position %s", state.c_str()); + return false; + } + + hp_bridge_.send_packet(SettingsSetRequestPacket().set_vane(position_byte)); + return true; +} + +bool MitsubishiUART::select_horizontal_vane_position(const std::string &state) { + IFNOTACTIVE(return false;) // Skip this if we're not in active mode + SettingsSetRequestPacket::HorizontalVaneByte position_byte = SettingsSetRequestPacket::HV_CENTER; + + // NOTE: Annoyed that C++ doesn't have switches for strings, but since this is going to be called + // infrequently, this is probably a better solution than over-optimizing via maps or something + + if (state == "Auto") { + position_byte = SettingsSetRequestPacket::HV_AUTO; + } else if (state == "<<") { + position_byte = SettingsSetRequestPacket::HV_LEFT_FULL; + } else if (state == "<") { + position_byte = SettingsSetRequestPacket::HV_LEFT; + } else if (state == "|") { + position_byte = SettingsSetRequestPacket::HV_CENTER; + } else if (state == ">") { + position_byte = SettingsSetRequestPacket::HV_RIGHT; + } else if (state == ">>") { + position_byte = SettingsSetRequestPacket::HV_RIGHT_FULL; + } else if (state == "<>") { + position_byte = SettingsSetRequestPacket::HV_SPLIT; + } else if (state == "Swing") { + position_byte = SettingsSetRequestPacket::HV_SWING; + } else { + ESP_LOGW(TAG, "Unknown horizontal vane position %s", state.c_str()); + return false; + } + + hp_bridge_.send_packet(SettingsSetRequestPacket().set_horizontal_vane(position_byte)); + return true; +} + +// Called by temperature_source sensors to report values. Will only take action if the currentTemperatureSource +// matches the incoming source. Specifically this means that we are not storing any values +// for sensors other than the current source, and selecting a different source won't have any +// effect until that source reports a temperature. +// TODO: ? Maybe store all temperatures (and report on them using internal sensors??) so that selecting a new +// source takes effect immediately? Only really needed if source sensors are configured with very slow update times. +void MitsubishiUART::temperature_source_report(const std::string &temperature_source, const float &v) { + ESP_LOGI(TAG, "Received temperature from %s of %f. (Current source: %s)", temperature_source.c_str(), v, + current_temperature_source_.c_str()); + + // Only proceed if the incomming source matches our chosen source. + if (current_temperature_source_ == temperature_source) { + // Reset the timeout for received temperature + last_received_temperature_ = millis(); + temperature_source_timeout_ = false; + + // Tell the heat pump about the temperature asap, but don't worry about setting it locally, the next update() will + // get it + IFACTIVE(RemoteTemperatureSetRequestPacket pkt = RemoteTemperatureSetRequestPacket(); pkt.set_remote_temperature(v); + hp_bridge_.send_packet(pkt);) + + // If we've changed the select to reflect a temporary reversion to a different source, change it back. + for (auto *listener : listeners_) { + listener->temperature_source_change(current_temperature_source_); + } + } +} + +void MitsubishiUART::reset_filter_status() { + ESP_LOGI(TAG, "Received a request to reset the filter status."); + + IFNOTACTIVE(return;) + + SetRunStatePacket pkt = SetRunStatePacket(); + pkt.set_filter_reset(true); + hp_bridge_.send_packet(pkt); +} + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/esphome/components/mitsubishi_itp/mitsubishi_itp.h b/esphome/components/mitsubishi_itp/mitsubishi_itp.h new file mode 100644 index 0000000000..996dacf051 --- /dev/null +++ b/esphome/components/mitsubishi_itp/mitsubishi_itp.h @@ -0,0 +1,182 @@ +#pragma once + +#include "esphome/core/application.h" +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#ifdef USE_TIME +#include "esphome/components/time/real_time_clock.h" +#endif +#include "esphome/components/climate/climate.h" +#include "mitp_listener.h" +#include "mitp_packet.h" +#include "mitp_bridge.h" +#include "mitp_mhk.h" +#include + +namespace esphome { +namespace mitsubishi_itp { + +static constexpr char TAG[] = "mitsubishi_itp"; + +const uint8_t MITP_MIN_TEMP = 16; // Degrees C +const uint8_t MITP_MAX_TEMP = 31; // Degrees C +const float MITP_TEMPERATURE_STEP = 0.5; + +const std::string TEMPERATURE_SOURCE_INTERNAL = "Internal"; +const std::string TEMPERATURE_SOURCE_THERMOSTAT = "Thermostat"; + +const uint32_t TEMPERATURE_SOURCE_TIMEOUT_MS = 420000; // (7min) The heatpump will revert on its own in ~10min + +class MitsubishiUART : public PollingComponent, public climate::Climate, public PacketProcessor { + public: + /** + * Create a new MitsubishiUART with the specified esphome::uart::UARTComponent. + */ + MitsubishiUART(uart::UARTComponent *hp_uart_comp); + + // Used to restore state of previous MITP-specific settings (like temperature source or pass-thru mode) + // Most other climate-state is preserved by the heatpump itself and will be retrieved after connection + void setup() override; + + // Called repeatedly (used for UART receiving/forwarding) + void loop() override; + + // Called periodically as PollingComponent (used for UART sending periodically) + void update() override; + + // Returns default traits for MITP + climate::ClimateTraits traits() override { return climate_traits_; } + + // Returns a reference to traits for MITP to be used during configuration + // TODO: Maybe replace this with specific functions for the traits needed in configuration (a la the override + // fuctions) + climate::ClimateTraits &config_traits() { return climate_traits_; } + + // Dumps some configuration data that we may have missed in the real-time logs + void dump_config() override; + + // Called to instruct a change of the climate controls + void control(const climate::ClimateCall &call) override; + + // Set thermostat UART component + void set_thermostat_uart(uart::UARTComponent *uart); + + // Listener-sensors + void register_listener(MITPListener *listener) { this->listeners_.push_back(listener); } + + // Returns true if select was valid (even if not yet successful) to indicate select component + // should optimistically publish + bool select_temperature_source(const std::string &state); + bool select_vane_position(const std::string &state); + bool select_horizontal_vane_position(const std::string &state); + + // Used by external sources to report a temperature + void temperature_source_report(const std::string &temperature_source, const float &v); + + // Button triggers + void reset_filter_status(); + + // Turns on or off actively sending packets + void set_active_mode(const bool active) { active_mode_ = active; }; + + // Turns on or off Kumo emulation mode + void set_enhanced_mhk_support(const bool supports) { enhanced_mhk_support_ = supports; } + +#ifdef USE_TIME + void set_time_source(time::RealTimeClock *rtc) { time_source_ = rtc; } +#endif + + protected: + void route_packet_(const Packet &packet); + + void process_packet(const Packet &packet) override; + void process_packet(const ConnectRequestPacket &packet) override; + void process_packet(const ConnectResponsePacket &packet) override; + void process_packet(const CapabilitiesRequestPacket &packet) override; + void process_packet(const CapabilitiesResponsePacket &packet) override; + void process_packet(const GetRequestPacket &packet) override; + void process_packet(const SettingsGetResponsePacket &packet) override; + void process_packet(const CurrentTempGetResponsePacket &packet) override; + void process_packet(const StatusGetResponsePacket &packet) override; + void process_packet(const RunStateGetResponsePacket &packet) override; + void process_packet(const ErrorStateGetResponsePacket &packet) override; + void process_packet(const SettingsSetRequestPacket &packet) override; + void process_packet(const RemoteTemperatureSetRequestPacket &packet) override; + void process_packet(const ThermostatSensorStatusPacket &packet) override; + void process_packet(const ThermostatHelloPacket &packet) override; + void process_packet(const ThermostatStateUploadPacket &packet) override; + void process_packet(const ThermostatAASetRequestPacket &packet) override; + void process_packet(const SetResponsePacket &packet) override; + + void handle_thermostat_state_download_request(const GetRequestPacket &packet) override; + void handle_thermostat_ab_get_request(const GetRequestPacket &packet) override; + + void do_publish_(); + + private: + // Default climate_traits for MITP + climate::ClimateTraits climate_traits_ = []() -> climate::ClimateTraits { + climate::ClimateTraits ct = climate::ClimateTraits(); + + ct.set_supports_action(true); + ct.set_supports_current_temperature(true); + ct.set_supports_two_point_target_temperature(false); + ct.set_visual_min_temperature(MITP_MIN_TEMP); + ct.set_visual_max_temperature(MITP_MAX_TEMP); + ct.set_visual_temperature_step(MITP_TEMPERATURE_STEP); + + return ct; + }(); + + // UARTComponent connected to heatpump + const uart::UARTComponent &hp_uart_; + // UART packet wrapper for heatpump + HeatpumpBridge hp_bridge_; + // UARTComponent connected to thermostat + uart::UARTComponent *ts_uart_ = nullptr; + // UART packet wrapper for heatpump + std::unique_ptr ts_bridge_ = nullptr; + + // Are we connected to the heatpump? + bool hp_connected_ = false; + // Should we call publish on the next update? + bool publish_on_update_ = false; + // Are we still discovering information about the device? + bool in_discovery_ = true; + // Number of times update() has been called in discovery mode + size_t discovery_updates_ = 0; + + optional capabilities_cache_; + bool capabilities_requested_ = false; + // Have we received at least one RunState response? + bool run_state_received_ = false; + +// Time Source +#ifdef USE_TIME + time::RealTimeClock *time_source_ = nullptr; +#endif + + // Listener-sensors + std::vector listeners_{}; + template void alert_listeners_(const T &packet) const { + for (auto *listener : this->listeners_) { + listener->process_packet(packet); + } + } + + // Temperature select extras + std::string current_temperature_source_ = TEMPERATURE_SOURCE_INTERNAL; + uint32_t last_received_temperature_ = millis(); + bool temperature_source_timeout_ = false; // Has the current source timed out? + + void send_if_active_(const Packet &packet); + bool active_mode_ = true; + + // used to track whether to support/handle the enhanced MHK protocol packets + bool enhanced_mhk_support_ = false; + + MHKState mhk_state_; +}; + +} // namespace mitsubishi_itp +} // namespace esphome diff --git a/tests/components/mitsubishi_itp/common.yaml b/tests/components/mitsubishi_itp/common.yaml new file mode 100644 index 0000000000..61c52529b9 --- /dev/null +++ b/tests/components/mitsubishi_itp/common.yaml @@ -0,0 +1,49 @@ +wifi: + ssid: MySSID + password: password1 + +api: + +time: + - platform: homeassistant + id: homeassistant_time + timezone: America/Los_Angeles + +# Temporarily commented out until this component can be merged +# select: +# - platform: mitsubishi_itp +# temperature_source: +# name: "Temperature Source" +# sources: +# - fake_temp + +sensor: + - platform: template + id: fake_temp + name: "Fake Temperature" + lambda: |- + return 20.5; + +uart: + - id: hp_uart + baud_rate: 2400 + parity: EVEN + rx_pin: + number: GPIO0 + tx_pin: + number: GPIO1 + - id: tstat_uart + baud_rate: 2400 + parity: EVEN + rx_pin: + number: GPIO3 + tx_pin: + number: GPIO4 + +climate: + - platform: mitsubishi_itp + name: "Climate" + uart_heatpump: hp_uart + uart_thermostat: tstat_uart + time_id: homeassistant_time + update_interval: 12s diff --git a/tests/components/mitsubishi_itp/test.esp32-ard.yaml b/tests/components/mitsubishi_itp/test.esp32-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/mitsubishi_itp/test.esp32-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/mitsubishi_itp/test.esp32-c3-ard.yaml b/tests/components/mitsubishi_itp/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/mitsubishi_itp/test.esp32-c3-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/mitsubishi_itp/test.esp32-c3-idf.yaml b/tests/components/mitsubishi_itp/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/mitsubishi_itp/test.esp32-c3-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/mitsubishi_itp/test.esp32-idf.yaml b/tests/components/mitsubishi_itp/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/mitsubishi_itp/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/mitsubishi_itp/test.esp8266-ard.yaml b/tests/components/mitsubishi_itp/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/mitsubishi_itp/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml