#include "secplus2.h" #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/scheduler.h" #include "ratgdo.h" extern "C" { #include "secplus.h" } namespace esphome::ratgdo { namespace secplus2 { using namespace scheduler_ids; // MAX_CODES_WITHOUT_FLASH_WRITE is a bit of a guess // since we write the flash at most every every 1min // // We want the rolling counter to be high enough that the // GDO will accept the command after an unexpected reboot // that did not save the counter to flash in time which // results in the rolling counter being behind what the GDO // expects. static const uint8_t MAX_CODES_WITHOUT_FLASH_WRITE = 60; static const char* const TAG = "ratgdo_secplus2"; void Secplus2::setup(RATGDOComponent* ratgdo, Scheduler* scheduler, InternalGPIOPin* rx_pin, InternalGPIOPin* tx_pin) { this->ratgdo_ = ratgdo; this->scheduler_ = scheduler; this->tx_pin_ = tx_pin; this->rx_pin_ = rx_pin; if (mqtt::global_mqtt_client != nullptr) { this->mqtt_rolling_code_topic_ = mqtt::global_mqtt_client->get_topic_prefix() + "/gdo/rolling_code"; this->mqtt_client_id_topic_ = mqtt::global_mqtt_client->get_topic_prefix() + "/gdo/client_id"; } this->client_id_pref_ = global_preferences->make_preference( 3497851610U); // fnv1_hash("ratgdo_client_id") uint32_t stored_client_id; if (this->client_id_pref_.load(&stored_client_id)) { this->client_id_ = stored_client_id; ESP_LOGI(TAG, "Restored Client ID from flash: 0x%04X", (unsigned)this->client_id_); if (mqtt::global_mqtt_client != nullptr) { mqtt::global_mqtt_client->publish(this->mqtt_client_id_topic_, std::to_string(this->client_id_), 0, true); } } else if (mqtt::global_mqtt_client != nullptr) { ESP_LOGI(TAG, "No Client ID in flash, waiting for MQTT: %s", this->mqtt_client_id_topic_.c_str()); mqtt::global_mqtt_client->subscribe( this->mqtt_client_id_topic_, [this](const std::string& topic, const std::string& payload) { if (this->client_id_ == 0x539) { uint32_t cid = strtoul(payload.c_str(), nullptr, 10); if (cid != 0) { ESP_LOGI(TAG, "Received Client ID from MQTT: 0x%04X", (unsigned)cid); this->set_client_id(cid); } } }, 1); } else { // Generate a unique ID on first boot. // We use a range that avoids common reserved IDs. uint32_t new_id = (random_uint32() & 0xFFFFF) | 0x539; this->client_id_ = new_id; this->client_id_pref_.save(&new_id); ESP_LOGI(TAG, "Generated new unique Client ID: 0x%04X", (unsigned)new_id); } this->rolling_code_pref_ = global_preferences->make_preference( 1868352652U); // fnv1_hash("ratgdo_rolling_code") uint32_t rolling_code; if (this->rolling_code_pref_.load(&rolling_code)) { this->rolling_code_counter_ = rolling_code; ESP_LOGI(TAG, "Restored rolling code from flash: %u", rolling_code); if (mqtt::global_mqtt_client != nullptr) { mqtt::global_mqtt_client->publish(this->mqtt_rolling_code_topic_, std::to_string(rolling_code), 0, true); } } else if (mqtt::global_mqtt_client != nullptr) { ESP_LOGI(TAG, "No rolling code in flash, waiting for MQTT: %s", this->mqtt_rolling_code_topic_.c_str()); mqtt::global_mqtt_client->subscribe( this->mqtt_rolling_code_topic_, [this](const std::string& topic, const std::string& payload) { if (*this->rolling_code_counter_ == 0) { uint32_t rc = strtoul(payload.c_str(), nullptr, 10); if (rc > 0) { ESP_LOGI(TAG, "Received rolling code from MQTT: %u", rc); this->set_rolling_code_counter(rc); } } }, 1); } else { rolling_code = 1; this->rolling_code_counter_ = rolling_code; this->rolling_code_pref_.save(&rolling_code); } this->uart_.begin(9600, RATGDO_UART_8N1, rx_pin->get_pin(), tx_pin->get_pin(), true); this->uart_.enableIntTx(false); this->uart_.enableAutoBaud(true); } void Secplus2::loop() { if (this->flags_.transmit_pending) { if (!this->transmit_packet()) { return; } } auto cmd = this->read_command(); if (cmd) { this->handle_command(*cmd); } } void Secplus2::dump_config() { ESP_LOGCONFIG(TAG, " Rolling Code Counter: %d", *this->rolling_code_counter_); ESP_LOGCONFIG(TAG, " Client ID: %d", this->client_id_); ESP_LOGCONFIG(TAG, " Protocol: SEC+ v2"); } void Secplus2::on_shutdown() { this->uart_.on_shutdown(); } void Secplus2::sync_helper(uint32_t start, uint32_t delay, uint8_t tries) { if (tries == 0 || *this->ratgdo_->door_state == DoorState::UNKNOWN) { ESP_LOGD(TAG, "Sync: querying status (attempt %d)...", tries); this->query_status(); } else if (tries == 1 || *this->ratgdo_->openings == 0) { ESP_LOGD(TAG, "Sync: querying openings (attempt %d)...", tries); this->query_openings(); } else { ESP_LOGD(TAG, "Sync successful!"); this->ratgdo_->synced = true; this->ratgdo_->sync_failed = false; return; } // not sync-ed after 30s, notify failure if (millis() - start > 30000) { ESP_LOGW(TAG, "Triggering sync failed actions."); this->ratgdo_->synced = false; this->ratgdo_->sync_failed = true; } else { // Use a slightly longer delay between queries during sync to avoid bus // saturation uint32_t next_delay = (tries < 5) ? 1000 : 2000; this->scheduler_->set_timeout(this->ratgdo_, TIMEOUT_SYNC, next_delay, [this, start, next_delay, tries]() { this->sync_helper(start, next_delay, tries + 1); }); }; } void Secplus2::sync() { ESP_LOGD(TAG, "Starting sync..."); this->ratgdo_->synced = false; this->scheduler_->cancel_timeout(this->ratgdo_, TIMEOUT_SYNC); this->sync_helper(millis(), 500, 0); } void Secplus2::light_action(LightAction action) { if (action == LightAction::UNKNOWN) { return; } this->send_command(Command(CommandType::LIGHT, static_cast(action))); } void Secplus2::lock_action(LockAction action) { if (action == LockAction::UNKNOWN) { return; } this->send_command(Command(CommandType::LOCK, static_cast(action))); } void Secplus2::door_action(DoorAction action) { if (action == DoorAction::UNKNOWN) { return; } this->door_command(action); } void Secplus2::door_command(DoorAction action) { this->send_command( Command(CommandType::DOOR_ACTION, static_cast(action), 1, 1), IncrementRollingCode::NO, [this, action]() { this->ratgdo_->set_timeout(150, [this, action] { this->send_command(Command(CommandType::DOOR_ACTION, static_cast(action), 0, 1)); }); }); } void Secplus2::query_status() { this->send_command(CommandType::GET_STATUS); } void Secplus2::query_openings() { this->send_command(CommandType::GET_OPENINGS); } optional Secplus2::read_command() { while (this->uart_.available()) { uint8_t ser_byte = this->uart_.read(); this->rx_last_read_ = millis(); // Shift byte into preamble window this->rx_msg_start_ = ((this->rx_msg_start_ << 8) | ser_byte) & 0xffffff; if (this->rx_msg_start_ == 0x550100) { // Found a preamble! Reset buffer and start fresh. if (this->flags_.rx_reading_msg) { ESP_LOGV(TAG, "Preamble detected inside message, resetting..."); } this->rx_packet_[0] = 0x55; this->rx_packet_[1] = 0x01; this->rx_packet_[2] = 0x00; this->rx_byte_count_ = 3; this->flags_.rx_reading_msg = true; continue; } if (this->flags_.rx_reading_msg) { this->rx_packet_[this->rx_byte_count_] = ser_byte; this->rx_byte_count_++; if (this->rx_byte_count_ == PACKET_LENGTH) { this->flags_.rx_reading_msg = false; this->rx_byte_count_ = 0; this->rx_msg_start_ = 0; // clear window to prevent immediate re-trigger this->print_packet(LOG_STR("Received packet"), this->rx_packet_); return this->decode_packet(this->rx_packet_); } } } if (this->flags_.rx_reading_msg && (millis() - this->rx_last_read_ > 100)) { // if we have a partial packet and it's been over 100ms since last byte was // read, the rest is not coming, discard it. ESP_LOGW(TAG, "Discard incomplete packet, length: %d", this->rx_byte_count_); this->flags_.rx_reading_msg = false; this->rx_byte_count_ = 0; this->rx_msg_start_ = 0; } return {}; } void Secplus2::print_packet(const esphome::LogString* prefix, const WirePacket& packet) const { constexpr size_t hex_size = format_hex_pretty_size(PACKET_LENGTH); char hex_buf[hex_size]; ESP_LOGD(TAG, "%s: [%s]", LOG_STR_ARG(prefix), format_hex_pretty_to(hex_buf, packet, PACKET_LENGTH)); } optional Secplus2::decode_packet(const WirePacket& packet) const { uint32_t rolling = 0; uint64_t fixed = 0; uint32_t data = 0; int err = decode_wireline(packet, &rolling, &fixed, &data); if (err < 0) { ESP_LOGW(TAG, "Decode failed (parity error or invalid frame)"); return {}; } uint16_t cmd = ((fixed >> 24) & 0xf00) | (data & 0xff); data &= ~0xf000; // clear parity nibble if ((fixed & 0xFFFFFFFF) == this->client_id_) { // my commands ESP_LOGD(TAG, " mine: rolling=%07" PRIx32 " fixed=%010" PRIx64 " data=%08" PRIx32, rolling, fixed, data); return {}; } else { ESP_LOGD(TAG, " rolling=%07" PRIx32 " fixed=%010" PRIx64 " data=%08" PRIx32, rolling, fixed, data); } CommandType cmd_type = to_CommandType(cmd, CommandType::UNKNOWN); uint8_t nibble = (data >> 8) & 0xff; uint8_t byte1 = (data >> 16) & 0xff; uint8_t byte2 = (data >> 24) & 0xff; ESP_LOGD(TAG, " cmd=%03x (%s) byte2=%02x byte1=%02x nibble=%01x", cmd, LOG_STR_ARG(CommandType_to_string(cmd_type)), byte2, byte1, nibble); return Command{cmd_type, nibble, byte1, byte2}; } void Secplus2::handle_command(const Command& cmd) { ESP_LOGD(TAG, "Handle command: %s (nibble=%01x byte1=%02x byte2=%02x)", LOG_STR_ARG(CommandType_to_string(cmd.type)), cmd.nibble, cmd.byte1, cmd.byte2); if (cmd.type == CommandType::STATUS) { this->ratgdo_->received(to_DoorState(cmd.nibble, DoorState::UNKNOWN)); this->ratgdo_->received( to_LightState((cmd.byte2 >> 1) & 1, LightState::UNKNOWN)); this->ratgdo_->received(to_LockState((cmd.byte2 & 1), LockState::UNKNOWN)); } else if (cmd.type == CommandType::LIGHT) { this->ratgdo_->received(to_LightAction(cmd.nibble, LightAction::UNKNOWN)); } else if (cmd.type == CommandType::OPENINGS) { this->ratgdo_->received(Openings{ static_cast((cmd.byte1 << 8) | cmd.byte2), cmd.nibble}); } } void Secplus2::send_command(Command command, IncrementRollingCode increment) { { uint8_t data[] = {command.byte2, command.byte1, command.nibble}; constexpr size_t hex_size = format_hex_pretty_size(3); char hex[hex_size]; ESP_LOGD(TAG, "Send command: %s, data: %s", LOG_STR_ARG(CommandType_to_string(command.type)), format_hex_pretty_to(hex, data, 3)); } if (!this->flags_.transmit_pending) { // have an untransmitted packet this->encode_packet(command, this->tx_packet_); if (increment == IncrementRollingCode::YES) { this->increment_rolling_code_counter(); } } else { // unlikely this would happed (unless not connected to GDO), we're ensuring // any pending packet is transmitted each loop before doing anyting else if (this->transmit_pending_start_ > 0) { ESP_LOGW(TAG, "Have untransmitted packet, ignoring command: %s", LOG_STR_ARG(CommandType_to_string(command.type))); } else { ESP_LOGW(TAG, "Not connected to GDO, ignoring command: %s", LOG_STR_ARG(CommandType_to_string(command.type))); } } this->transmit_packet(); } void Secplus2::encode_packet(Command command, WirePacket& packet) { auto cmd = static_cast(command.type); uint64_t fixed = ((cmd & ~0xff) << 24) | this->client_id_; uint32_t data = (static_cast(command.byte2) << 24) | (static_cast(command.byte1) << 16) | (static_cast(command.nibble) << 8) | (cmd & 0xff); ESP_LOGD(TAG, " transmit: rolling=%07" PRIx32 " fixed=%010" PRIx64 " data=%08" PRIx32, *this->rolling_code_counter_, fixed, data); encode_wireline(*this->rolling_code_counter_, fixed, data, packet); } bool Secplus2::transmit_packet() { auto now = micros(); while (micros() - now < 1300) { if (this->rx_pin_->digital_read()) { if (!this->flags_.transmit_pending) { this->flags_.transmit_pending = true; this->transmit_pending_start_ = millis(); ESP_LOGD(TAG, "Collision detected, waiting to send packet"); } else if (millis() - this->transmit_pending_start_ >= 5000) { this->transmit_pending_start_ = 0; // to indicate GDO not connected state } return false; } delayMicroseconds(100); } this->print_packet(LOG_STR("Sending packet"), this->tx_packet_); this->uart_.transmit_secplus2_preamble(); this->uart_.write(this->tx_packet_, PACKET_LENGTH); this->flags_.transmit_pending = false; this->transmit_pending_start_ = 0; this->on_command_sent_.trigger(); return true; } void Secplus2::increment_rolling_code_counter(int delta) { uint32_t counter = (*this->rolling_code_counter_ + delta) & 0xfffffff; this->rolling_code_counter_ = counter; this->rolling_code_pref_.save(&counter); if (mqtt::global_mqtt_client != nullptr) { mqtt::global_mqtt_client->publish(this->mqtt_rolling_code_topic_, std::to_string(counter), 0, true); } } void Secplus2::set_rolling_code_counter(uint32_t counter) { ESP_LOGV(TAG, "Set rolling code counter to %d", counter); this->rolling_code_counter_ = counter; this->rolling_code_pref_.save(&counter); if (mqtt::global_mqtt_client != nullptr) { mqtt::global_mqtt_client->publish(this->mqtt_rolling_code_topic_, std::to_string(counter), 0, true); } } void Secplus2::set_client_id(uint64_t client_id) { uint32_t cid = client_id & 0xFFFFFFFF; this->client_id_ = cid; this->client_id_pref_.save(&cid); if (mqtt::global_mqtt_client != nullptr) { mqtt::global_mqtt_client->publish(this->mqtt_client_id_topic_, std::to_string(cid), 0, true); } } } // namespace secplus2 } // namespace esphome::ratgdo