diff --git a/components/ratgdo/__init__.py b/components/ratgdo/__init__.py new file mode 100644 index 0000000..33b3f59 --- /dev/null +++ b/components/ratgdo/__init__.py @@ -0,0 +1,250 @@ +from dataclasses import dataclass + +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TRIGGER_ID +from esphome.core import CORE +from esphome.coroutine import CoroPriority, coroutine_with_priority +import voluptuous as vol + +DEPENDENCIES = ["preferences"] +MULTI_CONF = False + +DOMAIN = "ratgdo" + +ratgdo_ns = cg.esphome_ns.namespace("ratgdo") +RATGDO = ratgdo_ns.class_("RATGDOComponent", cg.Component) + + +@dataclass +class RATGDOData: + """Track observable subscriber counts for compile-time sizing.""" + + door_state: int = 0 + door_action_delayed: int = 0 + distance: int = 0 + vehicle_detected: int = 0 + vehicle_arriving: int = 0 + vehicle_leaving: int = 0 + + +def _get_data() -> RATGDOData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = RATGDOData() + return CORE.data[DOMAIN] + + +def subscribe_door_state() -> None: + _get_data().door_state += 1 + + +def subscribe_door_action_delayed() -> None: + _get_data().door_action_delayed += 1 + + +def subscribe_distance() -> None: + _get_data().distance += 1 + + +def subscribe_vehicle_detected() -> None: + _get_data().vehicle_detected += 1 + + +def subscribe_vehicle_arriving() -> None: + _get_data().vehicle_arriving += 1 + + +def subscribe_vehicle_leaving() -> None: + _get_data().vehicle_leaving += 1 + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _emit_subscriber_defines(): + """Emit observable subscriber count defines after all children have registered.""" + data = _get_data() + cg.add_define("RATGDO_MAX_DOOR_STATE_SUBSCRIBERS", data.door_state) + cg.add_define( + "RATGDO_MAX_DOOR_ACTION_DELAYED_SUBSCRIBERS", data.door_action_delayed + ) + cg.add_define("RATGDO_MAX_DISTANCE_SUBSCRIBERS", data.distance) + cg.add_define("RATGDO_MAX_VEHICLE_DETECTED_SUBSCRIBERS", data.vehicle_detected) + cg.add_define("RATGDO_MAX_VEHICLE_ARRIVING_SUBSCRIBERS", data.vehicle_arriving) + cg.add_define("RATGDO_MAX_VEHICLE_LEAVING_SUBSCRIBERS", data.vehicle_leaving) + + +SyncFailed = ratgdo_ns.class_("SyncFailed", automation.Trigger.template()) + +CONF_OUTPUT_GDO = "output_gdo_pin" +DEFAULT_OUTPUT_GDO = ( + "D4" # D4 red control terminal / GarageDoorOpener (UART1 TX) pin is D4 on D1 Mini +) +CONF_INPUT_GDO = "input_gdo_pin" +DEFAULT_INPUT_GDO = ( + "D2" # D2 red control terminal / GarageDoorOpener (UART1 RX) pin is D2 on D1 Mini +) +CONF_INPUT_OBST = "input_obst_pin" +DEFAULT_INPUT_OBST = "D7" # D7 black obstruction sensor terminal + +CONF_OBST_SLEEP_LOW = "obst_sleep_low" + +CONF_DISCRETE_OPEN_PIN = "discrete_open_pin" +CONF_DISCRETE_CLOSE_PIN = "discrete_close_pin" + +CONF_RATGDO_ID = "ratgdo_id" + +CONF_ON_SYNC_FAILED = "on_sync_failed" + +CONF_PROTOCOL = "protocol" + +PROTOCOL_SECPLUSV1 = "secplusv1" +PROTOCOL_SECPLUSV2 = "secplusv2" +PROTOCOL_DRYCONTACT = "drycontact" +SUPPORTED_PROTOCOLS = [PROTOCOL_SECPLUSV1, PROTOCOL_SECPLUSV2, PROTOCOL_DRYCONTACT] + +CONF_DRY_CONTACT_OPEN_SENSOR = "dry_contact_open_sensor" +CONF_DRY_CONTACT_CLOSE_SENSOR = "dry_contact_close_sensor" +CONF_DRY_CONTACT_SENSOR_GROUP = "dry_contact_sensor_group" + + +def validate_protocol(config): + if config.get(CONF_PROTOCOL, None) == PROTOCOL_DRYCONTACT and ( + CONF_DRY_CONTACT_CLOSE_SENSOR not in config + or CONF_DRY_CONTACT_OPEN_SENSOR not in config + ): + raise cv.Invalid( + "dry_contact_close_sensor and dry_contact_open_sensor are required when using protocol drycontact" + ) + if config.get(CONF_PROTOCOL, None) != PROTOCOL_DRYCONTACT and ( + CONF_DRY_CONTACT_CLOSE_SENSOR in config + or CONF_DRY_CONTACT_OPEN_SENSOR in config + ): + raise cv.Invalid( + "dry_contact_close_sensor and dry_contact_open_sensor are only valid when using protocol drycontact" + ) + # if config.get(CONF_PROTOCOL, None) == PROTOCOL_DRYCONTACT and CONF_DRY_CONTACT_OPEN_SENSOR not in config: + # raise cv.Invalid("dry_contact_open_sensor is required when using protocol drycontact") + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RATGDO), + cv.Optional( + CONF_OUTPUT_GDO, default=DEFAULT_OUTPUT_GDO + ): pins.gpio_output_pin_schema, + cv.Optional( + CONF_INPUT_GDO, default=DEFAULT_INPUT_GDO + ): pins.gpio_input_pin_schema, + cv.Optional(CONF_INPUT_OBST, default=DEFAULT_INPUT_OBST): cv.Any( + cv.none, pins.gpio_input_pin_schema + ), + cv.SplitDefault(CONF_OBST_SLEEP_LOW, esp32=False, esp8266=True): cv.boolean, + cv.Optional(CONF_DISCRETE_OPEN_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_DISCRETE_CLOSE_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_ON_SYNC_FAILED): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SyncFailed), + } + ), + cv.Optional(CONF_PROTOCOL, default=PROTOCOL_SECPLUSV2): cv.All( + vol.In(SUPPORTED_PROTOCOLS) + ), + # cv.Inclusive(CONF_DRY_CONTACT_OPEN_SENSOR,CONF_DRY_CONTACT_SENSOR_GROUP): cv.use_id(binary_sensor.BinarySensor), + # cv.Inclusive(CONF_DRY_CONTACT_CLOSE_SENSOR,CONF_DRY_CONTACT_SENSOR_GROUP): cv.use_id(binary_sensor.BinarySensor), + cv.Optional(CONF_DRY_CONTACT_OPEN_SENSOR): cv.use_id( + binary_sensor.BinarySensor + ), + cv.Optional(CONF_DRY_CONTACT_CLOSE_SENSOR): cv.use_id( + binary_sensor.BinarySensor + ), + } + ).extend(cv.COMPONENT_SCHEMA), + validate_protocol, +) + +RATGDO_CLIENT_SCHMEA = cv.Schema( + { + cv.GenerateID(CONF_RATGDO_ID): cv.use_id(RATGDO), + } +) + + +async def register_ratgdo_child(var, config): + parent = await cg.get_variable(config[CONF_RATGDO_ID]) + cg.add(var.set_parent(parent)) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + pin = await cg.gpio_pin_expression(config[CONF_OUTPUT_GDO]) + cg.add(var.set_output_gdo_pin(pin)) + pin = await cg.gpio_pin_expression(config[CONF_INPUT_GDO]) + cg.add(var.set_input_gdo_pin(pin)) + if config.get(CONF_INPUT_OBST): + pin = await cg.gpio_pin_expression(config[CONF_INPUT_OBST]) + cg.add(var.set_input_obst_pin(pin)) + + cg.add(var.set_obst_sleep_low(config[CONF_OBST_SLEEP_LOW])) + + if config.get(CONF_DRY_CONTACT_OPEN_SENSOR): + dry_contact_open_sensor = await cg.get_variable( + config[CONF_DRY_CONTACT_OPEN_SENSOR] + ) + cg.add(var.set_dry_contact_open_sensor(dry_contact_open_sensor)) + + if config.get(CONF_DRY_CONTACT_CLOSE_SENSOR): + dry_contact_close_sensor = await cg.get_variable( + config[CONF_DRY_CONTACT_CLOSE_SENSOR] + ) + cg.add(var.set_dry_contact_close_sensor(dry_contact_close_sensor)) + + for conf in config.get(CONF_ON_SYNC_FAILED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + + if CORE.is_esp32 and not CORE.using_arduino: + from esphome.components import esp32 + + esp32.include_builtin_idf_component("esp_driver_rmt") + esp32.add_idf_component( + name="secplus", + repo="https://github.com/ratgdo/secplus.git", + ref="add-esp-idf-support", + ) + else: + cg.add_library( + name="secplus", + repository="https://github.com/ratgdo/secplus#f98c3220356c27717a25102c0b35815ebbd26ccc", + version=None, + ) + if CORE.is_esp8266: + cg.add_library( + name="espsoftwareserial", + repository="https://github.com/ratgdo/espsoftwareserial#autobaud", + version=None, + ) + + if config[CONF_PROTOCOL] == PROTOCOL_SECPLUSV1: + cg.add_build_flag("-DPROTOCOL_SECPLUSV1") + elif config[CONF_PROTOCOL] == PROTOCOL_SECPLUSV2: + cg.add_build_flag("-DPROTOCOL_SECPLUSV2") + elif config[CONF_PROTOCOL] == PROTOCOL_DRYCONTACT: + cg.add_build_flag("-DPROTOCOL_DRYCONTACT") + cg.add(var.init_protocol()) + + # RATGDOComponent::setup() subscribes to door_state + subscribe_door_state() + + # Emit observable subscriber count defines after all children register + CORE.add_job(_emit_subscriber_defines) + + if config.get(CONF_DISCRETE_OPEN_PIN): + pin = await cg.gpio_pin_expression(config[CONF_DISCRETE_OPEN_PIN]) + cg.add(var.set_discrete_open_pin(pin)) + if config.get(CONF_DISCRETE_CLOSE_PIN): + pin = await cg.gpio_pin_expression(config[CONF_DISCRETE_CLOSE_PIN]) + cg.add(var.set_discrete_close_pin(pin)) diff --git a/components/ratgdo/automation.h b/components/ratgdo/automation.h new file mode 100644 index 0000000..6aef9f7 --- /dev/null +++ b/components/ratgdo/automation.h @@ -0,0 +1,21 @@ + +#pragma once + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "ratgdo.h" + +namespace esphome::ratgdo { + +class SyncFailed : public Trigger<> { +public: + explicit SyncFailed(RATGDOComponent* parent) + { + parent->subscribe_sync_failed([this](bool state) { + if (state) + this->trigger(); + }); + } +}; + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/binary_sensor/__init__.py b/components/ratgdo/binary_sensor/__init__.py new file mode 100644 index 0000000..1edb447 --- /dev/null +++ b/components/ratgdo/binary_sensor/__init__.py @@ -0,0 +1,77 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID + +from .. import ( + RATGDO_CLIENT_SCHMEA, + ratgdo_ns, + register_ratgdo_child, + subscribe_vehicle_arriving, + subscribe_vehicle_detected, + subscribe_vehicle_leaving, +) + +DEPENDENCIES = ["ratgdo"] + +# Track which sensor types have been used +USED_TYPES: set[str] = set() + +RATGDOBinarySensor = ratgdo_ns.class_( + "RATGDOBinarySensor", binary_sensor.BinarySensor, cg.Component +) +SensorType = ratgdo_ns.enum("SensorType") + +CONF_TYPE = "type" +TYPES = { + "motion": SensorType.RATGDO_SENSOR_MOTION, + "obstruction": SensorType.RATGDO_SENSOR_OBSTRUCTION, + "motor": SensorType.RATGDO_SENSOR_MOTOR, + "button": SensorType.RATGDO_SENSOR_BUTTON, + "vehicle_detected": SensorType.RATGDO_SENSOR_VEHICLE_DETECTED, + "vehicle_arriving": SensorType.RATGDO_SENSOR_VEHICLE_ARRIVING, + "vehicle_leaving": SensorType.RATGDO_SENSOR_VEHICLE_LEAVING, +} + +# Sensor types that require vehicle sensor support +VEHICLE_SENSOR_TYPES = {"vehicle_detected", "vehicle_arriving", "vehicle_leaving"} + + +def validate_unique_type(config): + """Validate that each sensor type is only used once.""" + sensor_type = config[CONF_TYPE] + if sensor_type in USED_TYPES: + raise cv.Invalid(f"Only one binary sensor of type '{sensor_type}' is allowed") + USED_TYPES.add(sensor_type) + return config + + +CONFIG_SCHEMA = cv.All( + binary_sensor.binary_sensor_schema(RATGDOBinarySensor) + .extend( + { + cv.Required(CONF_TYPE): cv.enum(TYPES, lower=True), + } + ) + .extend(RATGDO_CLIENT_SCHMEA), + validate_unique_type, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await binary_sensor.register_binary_sensor(var, config) + await cg.register_component(var, config) + cg.add(var.set_binary_sensor_type(config[CONF_TYPE])) + await register_ratgdo_child(var, config) + + # Add defines for enabled features and register observable subscriptions + sensor_type = config[CONF_TYPE] + if sensor_type in VEHICLE_SENSOR_TYPES: + cg.add_define("RATGDO_USE_VEHICLE_SENSORS") + if sensor_type == "vehicle_detected": + subscribe_vehicle_detected() + elif sensor_type == "vehicle_arriving": + subscribe_vehicle_arriving() + elif sensor_type == "vehicle_leaving": + subscribe_vehicle_leaving() diff --git a/components/ratgdo/binary_sensor/ratgdo_binary_sensor.cpp b/components/ratgdo/binary_sensor/ratgdo_binary_sensor.cpp new file mode 100644 index 0000000..d695638 --- /dev/null +++ b/components/ratgdo/binary_sensor/ratgdo_binary_sensor.cpp @@ -0,0 +1,92 @@ +#include "ratgdo_binary_sensor.h" +#include "../ratgdo_state.h" +#include "esphome/core/log.h" + +namespace esphome::ratgdo { + +static const char* const TAG = "ratgdo.binary_sensor"; + +void RATGDOBinarySensor::setup() +{ + // Initialize all sensors to false except motor (which doesn't set initial state) + if (this->binary_sensor_type_ != SensorType::RATGDO_SENSOR_MOTOR) { + this->publish_initial_state(false); + } + + switch (this->binary_sensor_type_) { + case SensorType::RATGDO_SENSOR_MOTION: + this->parent_->subscribe_motion_state([this](MotionState state) { + this->publish_state(state == MotionState::DETECTED); + }); + break; + case SensorType::RATGDO_SENSOR_OBSTRUCTION: + this->parent_->subscribe_obstruction_state([this](ObstructionState state) { + this->publish_state(state == ObstructionState::OBSTRUCTED); + }); + break; + case SensorType::RATGDO_SENSOR_MOTOR: + this->parent_->subscribe_motor_state([this](MotorState state) { + this->publish_state(state == MotorState::ON); + }); + break; + case SensorType::RATGDO_SENSOR_BUTTON: + this->parent_->subscribe_button_state([this](ButtonState state) { + this->publish_state(state == ButtonState::PRESSED); + }); + break; +#ifdef RATGDO_USE_VEHICLE_SENSORS + case SensorType::RATGDO_SENSOR_VEHICLE_DETECTED: + this->parent_->subscribe_vehicle_detected_state([this](VehicleDetectedState state) { + this->publish_state(state == VehicleDetectedState::YES); + this->parent_->presence_change(state == VehicleDetectedState::YES); + }); + break; + case SensorType::RATGDO_SENSOR_VEHICLE_ARRIVING: + this->parent_->subscribe_vehicle_arriving_state([this](VehicleArrivingState state) { + this->publish_state(state == VehicleArrivingState::YES); + }); + break; + case SensorType::RATGDO_SENSOR_VEHICLE_LEAVING: + this->parent_->subscribe_vehicle_leaving_state([this](VehicleLeavingState state) { + this->publish_state(state == VehicleLeavingState::YES); + }); + break; +#endif + default: + break; + } +} + +void RATGDOBinarySensor::dump_config() +{ + LOG_BINARY_SENSOR("", "RATGDO BinarySensor", this); + switch (this->binary_sensor_type_) { + case SensorType::RATGDO_SENSOR_MOTION: + ESP_LOGCONFIG(TAG, " Type: Motion"); + break; + case SensorType::RATGDO_SENSOR_OBSTRUCTION: + ESP_LOGCONFIG(TAG, " Type: Obstruction"); + break; + case SensorType::RATGDO_SENSOR_MOTOR: + ESP_LOGCONFIG(TAG, " Type: Motor"); + break; + case SensorType::RATGDO_SENSOR_BUTTON: + ESP_LOGCONFIG(TAG, " Type: Button"); + break; +#ifdef RATGDO_USE_VEHICLE_SENSORS + case SensorType::RATGDO_SENSOR_VEHICLE_DETECTED: + ESP_LOGCONFIG(TAG, " Type: VehicleDetected"); + break; + case SensorType::RATGDO_SENSOR_VEHICLE_ARRIVING: + ESP_LOGCONFIG(TAG, " Type: VehicleArriving"); + break; + case SensorType::RATGDO_SENSOR_VEHICLE_LEAVING: + ESP_LOGCONFIG(TAG, " Type: VehicleLeaving"); + break; +#endif + default: + break; + } +} + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/binary_sensor/ratgdo_binary_sensor.h b/components/ratgdo/binary_sensor/ratgdo_binary_sensor.h new file mode 100644 index 0000000..ce81cb4 --- /dev/null +++ b/components/ratgdo/binary_sensor/ratgdo_binary_sensor.h @@ -0,0 +1,33 @@ +#pragma once + +#include "../ratgdo.h" +#include "../ratgdo_state.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" + +namespace esphome::ratgdo { + +enum SensorType : uint8_t { + RATGDO_SENSOR_MOTION, + RATGDO_SENSOR_OBSTRUCTION, + RATGDO_SENSOR_MOTOR, + RATGDO_SENSOR_BUTTON, +#ifdef RATGDO_USE_VEHICLE_SENSORS + RATGDO_SENSOR_VEHICLE_DETECTED, + RATGDO_SENSOR_VEHICLE_ARRIVING, + RATGDO_SENSOR_VEHICLE_LEAVING, +#endif +}; + +class RATGDOBinarySensor : public binary_sensor::BinarySensor, public RATGDOClient, public Component { +public: + void setup() override; + void dump_config() override; + void set_binary_sensor_type(SensorType binary_sensor_type) { this->binary_sensor_type_ = binary_sensor_type; } + +protected: + SensorType binary_sensor_type_; +}; + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/callbacks.h b/components/ratgdo/callbacks.h new file mode 100644 index 0000000..d8d8e7c --- /dev/null +++ b/components/ratgdo/callbacks.h @@ -0,0 +1,49 @@ +#pragma once +#include "observable.h" +#include +#include + +namespace esphome::ratgdo { + +void log_once_callbacks_overflow(uint8_t max); + +template +class OnceCallbacks; + +template +class OnceCallbacks { +public: + // Runtime max is 1 for all current usage (door_state waits, command_sent waits). + // Set to 2 for safety margin. + static constexpr uint8_t MAX_CALLBACKS = 2; + + template + void operator()(F&& callback) + { + if (this->count_ >= MAX_CALLBACKS) { + log_once_callbacks_overflow(MAX_CALLBACKS); + return; + } + this->callbacks_[this->count_++] = Callback::create(std::forward(callback)); + } + + // Re-entrant safe: count_ is zeroed before invoking callbacks, + // so callbacks can queue new entries during trigger(). + void trigger(Ts... args) + { + uint8_t count = this->count_; + this->count_ = 0; + for (uint8_t i = 0; i < count; i++) { + this->callbacks_[i].call(args...); + } + } + + void clear() { this->count_ = 0; } + uint8_t count() const { return this->count_; } + +protected: + Callback callbacks_[MAX_CALLBACKS] { }; + uint8_t count_ { 0 }; +}; + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/common.h b/components/ratgdo/common.h new file mode 100644 index 0000000..3638cf4 --- /dev/null +++ b/components/ratgdo/common.h @@ -0,0 +1,4 @@ +#pragma once + +#define ESP_LOG1 ESP_LOGV +#define ESP_LOG2 ESP_LOGV diff --git a/components/ratgdo/cover/__init__.py b/components/ratgdo/cover/__init__.py new file mode 100644 index 0000000..b7e0037 --- /dev/null +++ b/components/ratgdo/cover/__init__.py @@ -0,0 +1,68 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import cover +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TRIGGER_ID + +from .. import ( + RATGDO_CLIENT_SCHMEA, + ratgdo_ns, + register_ratgdo_child, + subscribe_door_state, +) + +DEPENDENCIES = ["ratgdo"] + +RATGDOCover = ratgdo_ns.class_("RATGDOCover", cover.Cover, cg.Component) + + +# Triggers +CoverOpeningTrigger = ratgdo_ns.class_( + "CoverOpeningTrigger", automation.Trigger.template() +) +CoverClosingTrigger = ratgdo_ns.class_( + "CoverClosingTrigger", automation.Trigger.template() +) +CoverStateTrigger = ratgdo_ns.class_("CoverStateTrigger", automation.Trigger.template()) + +CONF_ON_OPENING = "on_opening" +CONF_ON_CLOSING = "on_closing" +CONF_ON_STATE_CHANGE = "on_state_change" + +CONFIG_SCHEMA = ( + cover.cover_schema(RATGDOCover) + .extend( + { + cv.GenerateID(): cv.declare_id(RATGDOCover), + cv.Optional(CONF_ON_OPENING): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CoverOpeningTrigger)} + ), + cv.Optional(CONF_ON_CLOSING): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CoverClosingTrigger)} + ), + cv.Optional(CONF_ON_STATE_CHANGE): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CoverStateTrigger)} + ), + } + ) + .extend(RATGDO_CLIENT_SCHMEA) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cover.register_cover(var, config) + + for conf in config.get(CONF_ON_OPENING, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + for conf in config.get(CONF_ON_CLOSING, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + for conf in config.get(CONF_ON_STATE_CHANGE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + + await register_ratgdo_child(var, config) + subscribe_door_state() diff --git a/components/ratgdo/cover/automation.h b/components/ratgdo/cover/automation.h new file mode 100644 index 0000000..395ddc0 --- /dev/null +++ b/components/ratgdo/cover/automation.h @@ -0,0 +1,43 @@ +#pragma once + +#include "esphome/components/cover/cover.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" + +namespace esphome::ratgdo { + +class CoverOpeningTrigger : public Trigger<> { +public: + CoverOpeningTrigger(cover::Cover* a_cover) + { + a_cover->add_on_state_callback([this, a_cover]() { + if (a_cover->current_operation == cover::COVER_OPERATION_OPENING) { + this->trigger(); + } + }); + } +}; + +class CoverClosingTrigger : public Trigger<> { +public: + CoverClosingTrigger(cover::Cover* a_cover) + { + a_cover->add_on_state_callback([this, a_cover]() { + if (a_cover->current_operation == cover::COVER_OPERATION_CLOSING) { + this->trigger(); + } + }); + } +}; + +class CoverStateTrigger : public Trigger<> { +public: + CoverStateTrigger(cover::Cover* a_cover) + { + a_cover->add_on_state_callback([this, a_cover]() { + this->trigger(); + }); + } +}; + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/cover/ratgdo_cover.cpp b/components/ratgdo/cover/ratgdo_cover.cpp new file mode 100644 index 0000000..4e1f88e --- /dev/null +++ b/components/ratgdo/cover/ratgdo_cover.cpp @@ -0,0 +1,94 @@ +#include "ratgdo_cover.h" +#include "../ratgdo_state.h" +#include "esphome/core/log.h" + +namespace esphome::ratgdo { + +using namespace esphome::cover; + +static const char* const TAG = "ratgdo.cover"; + +void RATGDOCover::dump_config() +{ + LOG_COVER("", "RATGDO Cover", this); +} + +void RATGDOCover::setup() +{ + auto state = this->restore_state_(); + if (state.has_value()) { + this->parent_->set_door_position(state.value().position); + } + this->parent_->subscribe_door_state([this](DoorState state, float position) { + this->on_door_state(state, position); + }); +} + +void RATGDOCover::on_door_state(DoorState state, float position) +{ + // ESP_LOGD("ON_DOOR_STATE", "%s %f", LOG_STR_ARG(DoorState_to_string(state)), position); + bool save_to_flash = true; + switch (state) { + case DoorState::OPEN: + this->position = COVER_OPEN; + this->current_operation = COVER_OPERATION_IDLE; + break; + case DoorState::CLOSED: + this->position = COVER_CLOSED; + this->current_operation = COVER_OPERATION_IDLE; + break; + case DoorState::OPENING: + this->current_operation = COVER_OPERATION_OPENING; + this->position = position; + save_to_flash = false; + break; + case DoorState::CLOSING: + this->current_operation = COVER_OPERATION_CLOSING; + this->position = position; + save_to_flash = false; + break; + case DoorState::STOPPED: + this->current_operation = COVER_OPERATION_IDLE; + this->position = position; + break; + case DoorState::UNKNOWN: + default: + this->current_operation = COVER_OPERATION_IDLE; + this->position = position; + + break; + } + + this->publish_state(save_to_flash); +} + +CoverTraits RATGDOCover::get_traits() +{ + auto traits = CoverTraits(); + traits.set_supports_stop(true); + traits.set_supports_toggle(true); + traits.set_supports_position(true); + return traits; +} + +void RATGDOCover::control(const CoverCall& call) +{ + if (call.get_stop()) { + this->parent_->door_stop(); + } + if (call.get_toggle()) { + this->parent_->door_toggle(); + } + if (call.get_position().has_value()) { + auto pos = *call.get_position(); + if (pos == COVER_OPEN) { + this->parent_->door_open(); + } else if (pos == COVER_CLOSED) { + this->parent_->door_close(); + } else { + this->parent_->door_move_to_position(pos); + } + } +} + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/cover/ratgdo_cover.h b/components/ratgdo/cover/ratgdo_cover.h new file mode 100644 index 0000000..1495197 --- /dev/null +++ b/components/ratgdo/cover/ratgdo_cover.h @@ -0,0 +1,22 @@ +#pragma once + +#include "../ratgdo.h" +#include "../ratgdo_state.h" +#include "esphome/components/cover/cover.h" +#include "esphome/core/component.h" + +namespace esphome::ratgdo { + +class RATGDOCover : public cover::Cover, public RATGDOClient, public Component { +public: + void dump_config() override; + void setup() override; + + cover::CoverTraits get_traits() override; + void on_door_state(DoorState state, float position); + +protected: + void control(const cover::CoverCall& call) override; +}; + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/light/__init__.py b/components/ratgdo/light/__init__.py new file mode 100644 index 0000000..78aa06d --- /dev/null +++ b/components/ratgdo/light/__init__.py @@ -0,0 +1,39 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import CONF_OUTPUT_ID # New in 2023.5 + +from .. import RATGDO_CLIENT_SCHMEA, ratgdo_ns, register_ratgdo_child + +DEPENDENCIES = ["ratgdo"] + +# Track if light has been used +USED_LIGHTS: set[str] = set() + +RATGDOLightOutput = ratgdo_ns.class_( + "RATGDOLightOutput", light.LightOutput, cg.Component +) + + +def validate_single_light(config): + """Validate that only one RATGDO light is configured.""" + light_id = "ratgdo_light" + if light_id in USED_LIGHTS: + raise cv.Invalid("Only one RATGDO light is allowed") + USED_LIGHTS.add(light_id) + return config + + +CONFIG_SCHEMA = cv.All( + light.LIGHT_SCHEMA.extend( + {cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(RATGDOLightOutput)} + ).extend(RATGDO_CLIENT_SCHMEA), + validate_single_light, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + await cg.register_component(var, config) + await light.register_light(var, config) + await register_ratgdo_child(var, config) diff --git a/components/ratgdo/light/ratgdo_light_output.cpp b/components/ratgdo/light/ratgdo_light_output.cpp new file mode 100644 index 0000000..82f071d --- /dev/null +++ b/components/ratgdo/light/ratgdo_light_output.cpp @@ -0,0 +1,66 @@ +#include "ratgdo_light_output.h" +#include "../ratgdo_state.h" +#include "esphome/core/log.h" + +namespace esphome::ratgdo { + +using namespace esphome::light; + +static const char* const TAG = "ratgdo.light"; + +void RATGDOLightOutput::dump_config() +{ + ESP_LOGCONFIG(TAG, "RATGDO Light"); +} + +void RATGDOLightOutput::setup() +{ + this->parent_->subscribe_light_state([this](LightState state) { + this->on_light_state(state); + }); +} + +void RATGDOLightOutput::on_light_state(esphome::ratgdo::LightState state) +{ + if (this->light_state_) { + this->has_initial_state_ = true; + set_state(state); + } +} + +void RATGDOLightOutput::set_state(esphome::ratgdo::LightState state) +{ + bool is_on = state == LightState::ON; + this->light_state_->current_values.set_state(is_on); + this->light_state_->remote_values.set_state(is_on); + this->light_state_->publish_state(); +} + +void RATGDOLightOutput::setup_state(light::LightState* light_state) +{ + esphome::ratgdo::LightState state = this->parent_->get_light_state(); + this->light_state_ = light_state; + this->set_state(state); +} + +LightTraits RATGDOLightOutput::get_traits() +{ + auto traits = LightTraits(); + traits.set_supported_color_modes({ light::ColorMode::ON_OFF }); + return traits; +} + +void RATGDOLightOutput::write_state(light::LightState* state) +{ + if (!this->has_initial_state_) + return; + bool binary; + state->current_values_as_binary(&binary); + if (binary) { + this->parent_->light_on(); + } else { + this->parent_->light_off(); + } +} + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/light/ratgdo_light_output.h b/components/ratgdo/light/ratgdo_light_output.h new file mode 100644 index 0000000..9f6ad31 --- /dev/null +++ b/components/ratgdo/light/ratgdo_light_output.h @@ -0,0 +1,27 @@ +#pragma once + +#include "../ratgdo.h" +#include "../ratgdo_state.h" +#include "esphome/components/light/light_output.h" +#include "esphome/core/component.h" + +namespace esphome::ratgdo { + +class RATGDOLightOutput : public light::LightOutput, public RATGDOClient, public Component { +public: + void dump_config() override; + void setup() override; + light::LightTraits get_traits() override; + void write_state(light::LightState* state) override; + void setup_state(light::LightState* state) override; + void set_state(esphome::ratgdo::LightState state); + light::LightState* get_state() { return this->light_state_; } + + void on_light_state(esphome::ratgdo::LightState state); + +protected: + light::LightState* light_state_; + bool has_initial_state_ = false; +}; + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/lock/__init__.py b/components/ratgdo/lock/__init__.py new file mode 100644 index 0000000..d6dafaf --- /dev/null +++ b/components/ratgdo/lock/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import lock +import esphome.config_validation as cv +from esphome.const import CONF_ID + +from .. import RATGDO_CLIENT_SCHMEA, ratgdo_ns, register_ratgdo_child + +DEPENDENCIES = ["ratgdo"] + +# Track if lock has been used +USED_LOCKS: set[str] = set() + +RATGDOLock = ratgdo_ns.class_("RATGDOLock", lock.Lock, cg.Component) + + +def validate_single_lock(config): + """Validate that only one RATGDO lock is configured.""" + lock_id = "ratgdo_lock" + if lock_id in USED_LOCKS: + raise cv.Invalid("Only one RATGDO lock is allowed") + USED_LOCKS.add(lock_id) + return config + + +CONFIG_SCHEMA = cv.All( + lock.lock_schema(RATGDOLock) + .extend( + { + cv.GenerateID(): cv.declare_id(RATGDOLock), + } + ) + .extend(RATGDO_CLIENT_SCHMEA), + validate_single_lock, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await lock.register_lock(var, config) + await cg.register_component(var, config) + await register_ratgdo_child(var, config) diff --git a/components/ratgdo/lock/ratgdo_lock.cpp b/components/ratgdo/lock/ratgdo_lock.cpp new file mode 100644 index 0000000..0c290e9 --- /dev/null +++ b/components/ratgdo/lock/ratgdo_lock.cpp @@ -0,0 +1,53 @@ +#include "ratgdo_lock.h" +#include "../ratgdo_state.h" +#include "esphome/core/log.h" + +namespace esphome::ratgdo { + +static const char* const TAG = "ratgdo.lock"; + +void RATGDOLock::dump_config() +{ + LOG_LOCK("", "RATGDO Lock", this); + ESP_LOGCONFIG(TAG, " Type: Lock"); +} + +void RATGDOLock::setup() +{ + this->parent_->subscribe_lock_state([this](LockState state) { + this->on_lock_state(state); + }); +} + +void RATGDOLock::on_lock_state(LockState state) +{ + if (state == LockState::LOCKED && this->state == lock::LockState::LOCK_STATE_LOCKED) { + return; + } + if (state == LockState::UNLOCKED && this->state == lock::LockState::LOCK_STATE_UNLOCKED) { + return; + } + + auto call = this->make_call(); + if (state == LockState::LOCKED) { + call.set_state(lock::LockState::LOCK_STATE_LOCKED); + } else if (state == LockState::UNLOCKED) { + call.set_state(lock::LockState::LOCK_STATE_UNLOCKED); + } + this->publish_state(*call.get_state()); +} + +void RATGDOLock::control(const lock::LockCall& call) +{ + auto state = *call.get_state(); + + if (state == lock::LockState::LOCK_STATE_LOCKED) { + this->parent_->lock(); + } else if (state == lock::LockState::LOCK_STATE_UNLOCKED) { + this->parent_->unlock(); + } + + this->publish_state(state); +} + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/lock/ratgdo_lock.h b/components/ratgdo/lock/ratgdo_lock.h new file mode 100644 index 0000000..94a0962 --- /dev/null +++ b/components/ratgdo/lock/ratgdo_lock.h @@ -0,0 +1,19 @@ +#pragma once + +#include "../ratgdo.h" +#include "../ratgdo_state.h" +#include "esphome/components/lock/lock.h" +#include "esphome/core/component.h" + +namespace esphome::ratgdo { + +class RATGDOLock : public lock::Lock, public RATGDOClient, public Component { +public: + void dump_config() override; + void setup() override; + + void on_lock_state(LockState state); + void control(const lock::LockCall& call) override; +}; + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/macros.h b/components/ratgdo/macros.h new file mode 100644 index 0000000..54bb25a --- /dev/null +++ b/components/ratgdo/macros.h @@ -0,0 +1,172 @@ +#pragma once + +#include +#include + +#include "esphome/core/log.h" + +#define PARENS () + +// Rescan macro tokens 256 times +#define EXPAND(...) EXPAND4(EXPAND4(EXPAND4(EXPAND4(__VA_ARGS__)))) +#define EXPAND4(...) EXPAND3(EXPAND3(EXPAND3(EXPAND3(__VA_ARGS__)))) +#define EXPAND3(...) EXPAND2(EXPAND2(EXPAND2(EXPAND2(__VA_ARGS__)))) +#define EXPAND2(...) EXPAND1(EXPAND1(EXPAND1(EXPAND1(__VA_ARGS__)))) +#define EXPAND1(...) __VA_ARGS__ + +#define FOR_EACH(macro, name, ...) \ + __VA_OPT__(EXPAND(FOR_EACH_HELPER(macro, name, __VA_ARGS__))) +#define FOR_EACH_HELPER(macro, name, a1, ...) \ + macro(name, a1) \ + __VA_OPT__(FOR_EACH_AGAIN PARENS(macro, name, __VA_ARGS__)) +#define FOR_EACH_AGAIN() FOR_EACH_HELPER + +#define ENUM_VARIANT0(name, val) name = val, +#define ENUM_VARIANT(name, tuple) ENUM_VARIANT0 tuple + +#define TUPLE(x, y) x, y + +#define LPAREN ( + +#ifdef USE_ESP8266 +#define TO_STRING_IF0(type, name, val) \ + if (_e == type::name) \ + return LOG_STR(#name); +#else +#define TO_STRING_IF0(type, name, val) \ + if (_e == type::name) \ + return #name; +#endif +#define TO_STRING_IF(type, tuple) TO_STRING_IF0 LPAREN type, TUPLE tuple) + +#define FROM_INT_CASE0(type, name, val) \ + case val: \ + return type::name; +#define FROM_INT_CASE(type, tuple) FROM_INT_CASE0 LPAREN type, TUPLE tuple) + +// String blob helpers for packed enum-to-string lookup tables +#define STR_BLOB_ENTRY0(type, name, val) #name "\0" +#define STR_BLOB_ENTRY(type, tuple) STR_BLOB_ENTRY0 LPAREN type, TUPLE tuple) + +#define COUNT_ONE0(type, name, val) +1 +#define COUNT_ONE(name, tuple) COUNT_ONE0 LPAREN name, TUPLE tuple) + +namespace esphome::ratgdo { +namespace detail { + + template + struct EnumStringOffsets { + uint8_t data[N]; + }; + + template + constexpr EnumStringOffsets compute_enum_string_offsets(const char (&blob)[BlobSize]) + { + EnumStringOffsets result { }; + result.data[0] = 0; + size_t entry = 1; + for (size_t i = 0; i < BlobSize - 1 && entry < Count; ++i) { + if (blob[i] == '\0') { + result.data[entry++] = static_cast(i + 1); + } + } + return result; + } + +} // namespace detail +} // namespace esphome::ratgdo + +// Platform-specific helpers for enum string return types +#ifdef USE_ESP8266 +#define ENUM_STR_RET const esphome::LogString* +#define ENUM_STR_UNKNOWN LOG_STR("UNKNOWN") +#define ENUM_BLOB_ATTR PROGMEM +#define ENUM_BLOB_RETURN(blob, offset) reinterpret_cast(&(blob)[offset]) +#else +#define ENUM_STR_RET const char* +#define ENUM_STR_UNKNOWN "UNKNOWN" +#define ENUM_BLOB_ATTR +#define ENUM_BLOB_RETURN(blob, offset) (&(blob)[offset]) +#endif + +// ENUM: packed string blob with O(1) offset lookup (for contiguous 0-based enums with uint8_t type) +#define ENUM(name, type, ...) \ + enum class name : type { \ + FOR_EACH(ENUM_VARIANT, name, __VA_ARGS__) \ + }; \ + static_assert(sizeof(type) == 1, "ENUM() requires uint8_t type; use ENUM_SPARSE() for wider types"); \ + inline ENUM_STR_RET \ + name##_to_string(name _e) \ + { \ + static constexpr size_t _n = (0 FOR_EACH(COUNT_ONE, name, __VA_ARGS__)); \ + static const char _b[] ENUM_BLOB_ATTR = FOR_EACH(STR_BLOB_ENTRY, name, __VA_ARGS__); \ + static_assert(sizeof(_b) <= 256, "ENUM() string blob exceeds 255 bytes; use shorter names"); \ + static constexpr auto _o = ::esphome::ratgdo::detail::compute_enum_string_offsets<_n>( \ + FOR_EACH(STR_BLOB_ENTRY, name, __VA_ARGS__)); \ + auto _i = static_cast(_e); \ + if (_i >= _n) \ + return ENUM_STR_UNKNOWN; \ + return ENUM_BLOB_RETURN(_b, _o.data[_i]); \ + } \ + inline name \ + to_##name(type _t, name _unknown) \ + { \ + switch (_t) { \ + FOR_EACH(FROM_INT_CASE, name, __VA_ARGS__) \ + default: \ + return _unknown; \ + } \ + } + +// ENUM_SPARSE: if-chain lookup (for non-contiguous enum values, avoids CSWTCH) +#define ENUM_SPARSE(name, type, ...) \ + enum class name : type { \ + FOR_EACH(ENUM_VARIANT, name, __VA_ARGS__) \ + }; \ + inline ENUM_STR_RET \ + name##_to_string(name _e) \ + { \ + FOR_EACH(TO_STRING_IF, name, __VA_ARGS__) \ + return ENUM_STR_UNKNOWN; \ + } \ + inline name \ + to_##name(type _t, name _unknown) \ + { \ + switch (_t) { \ + FOR_EACH(FROM_INT_CASE, name, __VA_ARGS__) \ + default: \ + return _unknown; \ + } \ + } + +#define SUM_TYPE_UNION_MEMBER0(type, var) type var; +#define SUM_TYPE_UNION_MEMBER(name, tuple) SUM_TYPE_UNION_MEMBER0 tuple + +#define SUM_TYPE_ENUM_MEMBER0(type, var) var, +#define SUM_TYPE_ENUM_MEMBER(name, tuple) SUM_TYPE_ENUM_MEMBER0 tuple + +#define SUM_TYPE_CONSTRUCTOR0(name, type, val) \ + name(type&& arg) \ + : tag(Tag::val) \ + { \ + value.val = std::move(arg); \ + } +#define SUM_TYPE_CONSTRUCTOR(name, tuple) SUM_TYPE_CONSTRUCTOR0 LPAREN name, TUPLE tuple) + +#define SUM_TYPE(name, ...) \ + class name { \ + public: \ + union { \ + FOR_EACH(SUM_TYPE_UNION_MEMBER, name, __VA_ARGS__) \ + } value; \ + enum class Tag { \ + void_, \ + FOR_EACH(SUM_TYPE_ENUM_MEMBER, name, __VA_ARGS__) \ + } tag; \ + \ + name() \ + : tag(Tag::void_) \ + { \ + } \ + FOR_EACH(SUM_TYPE_CONSTRUCTOR, name, __VA_ARGS__) \ + }; diff --git a/components/ratgdo/number/__init__.py b/components/ratgdo/number/__init__.py new file mode 100644 index 0000000..762b45b --- /dev/null +++ b/components/ratgdo/number/__init__.py @@ -0,0 +1,58 @@ +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import CONF_ID + +from .. import RATGDO_CLIENT_SCHMEA, ratgdo_ns, register_ratgdo_child + +DEPENDENCIES = ["ratgdo"] + +# Track which number types have been used +USED_TYPES: set[str] = set() + +RATGDONumber = ratgdo_ns.class_("RATGDONumber", number.Number, cg.Component) +NumberType = ratgdo_ns.enum("NumberType") + +CONF_TYPE = "type" +TYPES = { + "client_id": NumberType.RATGDO_CLIENT_ID, + "rolling_code_counter": NumberType.RATGDO_ROLLING_CODE_COUNTER, + "opening_duration": NumberType.RATGDO_OPENING_DURATION, + "closing_duration": NumberType.RATGDO_CLOSING_DURATION, + "closing_delay": NumberType.RATGDO_CLOSING_DELAY, + "target_distance_measurement": NumberType.RATGDO_TARGET_DISTANCE_MEASUREMENT, +} + + +def validate_unique_type(config): + """Validate that each number type is only used once.""" + number_type = config[CONF_TYPE] + if number_type in USED_TYPES: + raise cv.Invalid(f"Only one number of type '{number_type}' is allowed") + USED_TYPES.add(number_type) + return config + + +CONFIG_SCHEMA = cv.All( + number.number_schema(RATGDONumber) + .extend( + { + cv.Required(CONF_TYPE): cv.enum(TYPES, lower=True), + } + ) + .extend(RATGDO_CLIENT_SCHMEA), + validate_unique_type, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await number.register_number(var, config, step=1, min_value=0, max_value=4294967295) + await cg.register_component(var, config) + cg.add(var.set_number_type(config[CONF_TYPE])) + await register_ratgdo_child(var, config) + + # Add defines for enabled features + # sensor will add the define for the distance sensor + if config[CONF_TYPE] == "closing_delay": + cg.add_define("RATGDO_USE_CLOSING_DELAY") diff --git a/components/ratgdo/number/ratgdo_number.cpp b/components/ratgdo/number/ratgdo_number.cpp new file mode 100644 index 0000000..058f124 --- /dev/null +++ b/components/ratgdo/number/ratgdo_number.cpp @@ -0,0 +1,186 @@ +#include "ratgdo_number.h" +#include "../ratgdo_state.h" +#include "esphome/core/log.h" + +namespace esphome::ratgdo { + +using protocol::SetClientID; +using protocol::SetRollingCodeCounter; + +float normalize_client_id(float client_id) +{ + uint32_t int_value = static_cast(client_id); + if ((int_value & 0xFFF) != 0x539) { + client_id = ceil((client_id - 0x539) / 0x1000) * 0x1000 + 0x539; + } + return client_id; +} + +static const char* const TAG = "ratgdo.number"; + +void RATGDONumber::dump_config() +{ + LOG_NUMBER("", "RATGDO Number", this); + switch (this->number_type_) { + case RATGDO_CLIENT_ID: + ESP_LOGCONFIG(TAG, " Type: Client ID"); + break; + case RATGDO_ROLLING_CODE_COUNTER: + ESP_LOGCONFIG(TAG, " Type: Rolling Code Counter"); + break; + case RATGDO_OPENING_DURATION: + ESP_LOGCONFIG(TAG, " Type: Opening Duration"); + break; + case RATGDO_CLOSING_DURATION: + ESP_LOGCONFIG(TAG, " Type: Closing Duration"); + break; +#ifdef RATGDO_USE_CLOSING_DELAY + case RATGDO_CLOSING_DELAY: + ESP_LOGCONFIG(TAG, " Type: Closing Delay"); + break; +#endif +#ifdef RATGDO_USE_DISTANCE_SENSOR + case RATGDO_TARGET_DISTANCE_MEASUREMENT: + ESP_LOGCONFIG(TAG, " Type: Target Distance Measurement"); + break; +#endif + default: + break; + } +} + +void RATGDONumber::setup() +{ + float value; + this->pref_ = this->make_entity_preference(); + if (!this->pref_.load(&value)) { + if (this->number_type_ == RATGDO_CLIENT_ID) { + value = ((random_uint32() + 1) % 0x7FF) << 12 | 0x539; // max size limited to be precisely convertible to float + } else { + value = 0; + } + } else { + if (this->number_type_ == RATGDO_CLIENT_ID) { + uint32_t int_value = static_cast(value); + if ((int_value & 0xFFF) != 0x539) { + value = ((random_uint32() + 1) % 0x7FF) << 12 | 0x539; // max size limited to be precisely convertible to float + this->pref_.save(&value); + } + } + } + this->control(value); + + switch (this->number_type_) { + case RATGDO_ROLLING_CODE_COUNTER: + this->parent_->subscribe_rolling_code_counter([this](uint32_t value) { + this->update_state(value); + }); + break; + case RATGDO_OPENING_DURATION: + this->parent_->subscribe_opening_duration([this](float value) { + this->update_state(value); + }); + break; + case RATGDO_CLOSING_DURATION: + this->parent_->subscribe_closing_duration([this](float value) { + this->update_state(value); + }); + break; +#ifdef RATGDO_USE_CLOSING_DELAY + case RATGDO_CLOSING_DELAY: + this->parent_->subscribe_closing_delay([this](uint32_t value) { + this->update_state(value); + }); + break; +#endif +#ifdef RATGDO_USE_DISTANCE_SENSOR + case RATGDO_TARGET_DISTANCE_MEASUREMENT: + // this->parent_->subscribe_target_distance_measurement([=](float value) { + // this->update_state(value); + // }); + break; +#endif + default: + break; + } +} + +void RATGDONumber::set_number_type(NumberType number_type_) +{ + this->number_type_ = number_type_; + switch (this->number_type_) { + case RATGDO_OPENING_DURATION: + case RATGDO_CLOSING_DURATION: + this->traits.set_step(0.1); + this->traits.set_min_value(0.0); + this->traits.set_max_value(180.0); + break; +#ifdef RATGDO_USE_CLOSING_DELAY + case RATGDO_CLOSING_DELAY: + this->traits.set_step(1); + this->traits.set_min_value(0.0); + this->traits.set_max_value(60.0); + break; +#endif + case RATGDO_ROLLING_CODE_COUNTER: + this->traits.set_max_value(0xfffffff); + break; + case RATGDO_CLIENT_ID: + this->traits.set_step(0x1000); + this->traits.set_min_value(0x539); + this->traits.set_max_value(0x7ff539); + break; +#ifdef RATGDO_USE_DISTANCE_SENSOR + case RATGDO_TARGET_DISTANCE_MEASUREMENT: + this->traits.set_step(1); + this->traits.set_min_value(5); + this->traits.set_max_value(3500); + break; +#endif + default: + break; + } +} + +void RATGDONumber::update_state(float value) +{ + if (value == this->state) { + return; + } + this->pref_.save(&value); + this->publish_state(value); +} + +void RATGDONumber::control(float value) +{ + switch (this->number_type_) { + case RATGDO_ROLLING_CODE_COUNTER: + this->parent_->call_protocol(SetRollingCodeCounter { static_cast(value) }); + break; + case RATGDO_OPENING_DURATION: + this->parent_->set_opening_duration(value); + break; + case RATGDO_CLOSING_DURATION: + this->parent_->set_closing_duration(value); + break; +#ifdef RATGDO_USE_CLOSING_DELAY + case RATGDO_CLOSING_DELAY: + this->parent_->set_closing_delay(value); + break; +#endif + case RATGDO_CLIENT_ID: + value = normalize_client_id(value); + this->parent_->call_protocol(SetClientID { static_cast(value) }); + break; +#ifdef RATGDO_USE_DISTANCE_SENSOR + case RATGDO_TARGET_DISTANCE_MEASUREMENT: + this->parent_->set_target_distance_measurement(value); + break; +#endif + default: + break; + } + this->update_state(value); +} + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/number/ratgdo_number.h b/components/ratgdo/number/ratgdo_number.h new file mode 100644 index 0000000..be9e5a4 --- /dev/null +++ b/components/ratgdo/number/ratgdo_number.h @@ -0,0 +1,42 @@ +#pragma once + +#include "../ratgdo.h" +#include "../ratgdo_state.h" +#include "esphome/components/number/number.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" + +namespace esphome::ratgdo { + +enum NumberType { + RATGDO_CLIENT_ID, + RATGDO_ROLLING_CODE_COUNTER, + RATGDO_OPENING_DURATION, + RATGDO_CLOSING_DURATION, +#ifdef RATGDO_USE_CLOSING_DELAY + RATGDO_CLOSING_DELAY, +#endif +#ifdef RATGDO_USE_DISTANCE_SENSOR + RATGDO_TARGET_DISTANCE_MEASUREMENT, +#endif +}; + +class RATGDONumber : public number::Number, public RATGDOClient, public Component { +public: + void dump_config() override; + void setup() override; + void set_number_type(NumberType number_type); + // other esphome components that persist state in the flash have HARDWARE priority + // ensure we get initialized before them, so that the state doesn't get invalidated + // by components that might be added in the future + float get_setup_priority() const override { return setup_priority::HARDWARE + 1; } + + void update_state(float value); + void control(float value) override; + +protected: + NumberType number_type_; + ESPPreferenceObject pref_; +}; + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/observable.cpp b/components/ratgdo/observable.cpp new file mode 100644 index 0000000..fd8d336 --- /dev/null +++ b/components/ratgdo/observable.cpp @@ -0,0 +1,24 @@ +#include "observable.h" +#include "callbacks.h" +#include "esphome/core/log.h" + +namespace esphome::ratgdo { + +static const char* const TAG = "ratgdo.observable"; + +void log_multiple_subscribers() +{ + ESP_LOGE(TAG, "single_observable already has a subscriber! This will overwrite the existing subscriber."); +} + +void log_observer_overflow() +{ + ESP_LOGE(TAG, "observable has too many subscribers! Ignoring new subscriber."); +} + +void log_once_callbacks_overflow(uint8_t max) +{ + ESP_LOGE(TAG, "OnceCallbacks overflow (max %u)! Ignoring callback.", static_cast(max)); +} + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/observable.h b/components/ratgdo/observable.h new file mode 100644 index 0000000..653e98c --- /dev/null +++ b/components/ratgdo/observable.h @@ -0,0 +1,167 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace esphome::ratgdo { + +void log_multiple_subscribers(); +void log_observer_overflow(); + +// Lightweight type-erased callback (16 bytes on 32-bit). +// For small trivially-copyable callables (like [this], [this, f], or [this, f, id] lambdas), +// stores the callable inline — zero heap allocation. +// Supports up to 3 * sizeof(void*) bytes (12 bytes on 32-bit, 24 on 64-bit). +inline constexpr size_t CALLBACK_STORAGE_SIZE = 3 * sizeof(void*); + +template +struct Callback { + using fn_t = void (*)(const void*, Ts...); + fn_t fn_ { nullptr }; + alignas(void*) uint8_t storage_[CALLBACK_STORAGE_SIZE] { }; + + void call(Ts... args) const { this->fn_(this->storage_, args...); } + explicit operator bool() const { return this->fn_ != nullptr; } + + template + static Callback create(F&& f) + { + Callback cb; + using Decay = std::decay_t; + static_assert(!std::is_function_v>, + "Pass function pointers, not function references"); + static_assert(std::is_trivially_copyable_v, "Observable callbacks must be trivially copyable (e.g. [this] lambdas)"); + static_assert(sizeof(Decay) <= CALLBACK_STORAGE_SIZE, "Observable callbacks must fit in storage (capture at most 3 pointers)"); + cb.fn_ = [](const void* storage, Ts... args) { + alignas(Decay) char buf[sizeof(Decay)]; + __builtin_memcpy(buf, storage, sizeof(Decay)); + (*std::launder(reinterpret_cast(buf)))(args...); + }; + __builtin_memcpy(cb.storage_, &f, sizeof(Decay)); + return cb; + } +}; + +// Primary template for observable with subscribers. +template +class observable { +public: + observable(const T& value) + : value_(value) + { + } + + template + observable& operator=(U value) + { + if (value != this->value_) { + this->value_ = value; + this->notify(); + } + return *this; + } + + T const* operator&() const { return &this->value_; } + T const& operator*() const { return this->value_; } + + template + void subscribe(F&& observer) + { + if (this->count_ >= MaxObservers) { + log_observer_overflow(); + return; + } + this->observers_[this->count_++] = Callback::create(std::forward(observer)); + } + + void notify() const + { + for (uint8_t i = 0; i < this->count_; i++) { + this->observers_[i].call(this->value_); + } + } + +private: + T value_; + Callback observers_[MaxObservers] { }; + uint8_t count_ { 0 }; +}; + +// Specialization for zero subscribers — no array, no count, notify is a no-op. +template +class observable { +public: + observable(const T& value) + : value_(value) + { + } + + template + observable& operator=(U value) + { + if (value != this->value_) { + this->value_ = value; + } + return *this; + } + + T const* operator&() const { return &this->value_; } + T const& operator*() const { return this->value_; } + + template + void subscribe(F&&) + { + log_observer_overflow(); + } + + void notify() const { } + +private: + T value_; +}; + +template +class single_observable { +public: + single_observable(const T& value) + : value_(value) + { + } + + template + single_observable& operator=(U value) + { + if (value != this->value_) { + this->value_ = value; + this->notify(); + } + return *this; + } + + T const* operator&() const { return &this->value_; } + T const& operator*() const { return this->value_; } + + template + void subscribe(F&& observer) + { + if (this->observer_) { + log_multiple_subscribers(); + } + this->observer_ = Callback::create(std::forward(observer)); + } + + void notify() const + { + if (this->observer_) { + this->observer_.call(this->value_); + } + } + +private: + T value_; + Callback observer_ { }; +}; + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/output/__init__.py b/components/ratgdo/output/__init__.py new file mode 100644 index 0000000..70e4457 --- /dev/null +++ b/components/ratgdo/output/__init__.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import rtttl +import esphome.config_validation as cv +from esphome.const import CONF_ID + +from .. import ( + RATGDO_CLIENT_SCHMEA, + ratgdo_ns, + register_ratgdo_child, + subscribe_door_action_delayed, + subscribe_vehicle_arriving, +) + +CONF_RTTTL = "rtttl" +CONF_SONG = "song" + +DEPENDENCIES = ["esp32", "ratgdo", "rtttl"] + +RATGDOOutput = ratgdo_ns.class_("RATGDOOutput", cg.Component) +OutputType = ratgdo_ns.enum("OutputType") + +CONF_TYPE = "type" +TYPES = {"beeper": OutputType.RATGDO_BEEPER} + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(RATGDOOutput), + cv.Required(CONF_TYPE): cv.enum(TYPES, lower=True), + cv.Required(CONF_RTTTL): cv.use_id(rtttl), + cv.Required(CONF_SONG): cv.string, + } +).extend(RATGDO_CLIENT_SCHMEA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + rtttl = await cg.get_variable(config[CONF_RTTTL]) + cg.add(var.set_rtttl(rtttl)) + cg.add(var.set_song(config[CONF_SONG])) + await register_ratgdo_child(var, config) + subscribe_vehicle_arriving() + subscribe_door_action_delayed() diff --git a/components/ratgdo/output/ratgdo_output.cpp b/components/ratgdo/output/ratgdo_output.cpp new file mode 100644 index 0000000..92b4c8e --- /dev/null +++ b/components/ratgdo/output/ratgdo_output.cpp @@ -0,0 +1,58 @@ +#include "ratgdo_output.h" +#include "../ratgdo_state.h" +#include "esphome/core/log.h" + +namespace esphome::ratgdo { + +static const char* TAG = "ratgdo.output"; + +void RATGDOOutput::setup() +{ + ESP_LOGD(TAG, "Output was setup"); + + if (this->output_type_ == OutputType::RATGDO_BEEPER) { + this->beeper_->add_on_finished_playback_callback([this] { this->finished_playback(); }); + +#ifdef RATGDO_USE_VEHICLE_SENSORS + this->parent_->subscribe_vehicle_arriving_state([this](VehicleArrivingState state) { + if (state == VehicleArrivingState::YES) { + this->play(); + } + }); +#endif + + this->parent_->subscribe_door_action_delayed([this](DoorActionDelayed state) { + if (state == DoorActionDelayed::YES) { + this->play(); + this->repeat_ = true; + } else if (state == DoorActionDelayed::NO) { + this->repeat_ = false; + } + }); + } +} + +void RATGDOOutput::play() +{ + this->beeper_->play(this->rtttlSong_); +} + +void RATGDOOutput::finished_playback() +{ + if (this->repeat_) + this->play(); +} + +void RATGDOOutput::dump_config() +{ + if (this->output_type_ == OutputType::RATGDO_BEEPER) { + ESP_LOGCONFIG(TAG, " Type: Beeper"); + } +} + +void RATGDOOutput::set_output_type(OutputType output_type_) +{ + this->output_type_ = output_type_; +} + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/output/ratgdo_output.h b/components/ratgdo/output/ratgdo_output.h new file mode 100644 index 0000000..10c9303 --- /dev/null +++ b/components/ratgdo/output/ratgdo_output.h @@ -0,0 +1,30 @@ +#pragma once + +#include "../ratgdo.h" +#include "esphome/components/rtttl/rtttl.h" +#include "esphome/core/component.h" + +namespace esphome::ratgdo { + +enum OutputType { + RATGDO_BEEPER +}; + +class RATGDOOutput : public RATGDOClient, public Component { +public: + void setup() override; + void play(); + void finished_playback(); + void dump_config() override; + void set_output_type(OutputType output_type); + void set_song(std::string rtttlSong) { this->rtttlSong_ = rtttlSong; } + void set_rtttl(rtttl::Rtttl* output) { this->beeper_ = output; } + +protected: + OutputType output_type_; + rtttl::Rtttl* beeper_; + std::string rtttlSong_; + bool repeat_; +}; + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/protocol.h b/components/ratgdo/protocol.h new file mode 100644 index 0000000..71904c1 --- /dev/null +++ b/components/ratgdo/protocol.h @@ -0,0 +1,126 @@ +#pragma once + +#include "common.h" +#include "ratgdo_state.h" + +namespace esphome { + +class Scheduler; +class InternalGPIOPin; + +} // namespace esphome + +namespace esphome::ratgdo { + +class RATGDOComponent; + +namespace protocol { + + const uint32_t HAS_DOOR_OPEN = 1 << 0; // has idempotent open door command + const uint32_t HAS_DOOR_CLOSE = 1 << 1; // has idempotent close door command + const uint32_t HAS_DOOR_STOP = 1 << 2; // has idempotent stop door command + const uint32_t HAS_DOOR_STATUS = 1 << 3; + + const uint32_t HAS_LIGHT_TOGGLE = 1 << 10; // some protocols might not support this + + const uint32_t HAS_LOCK_TOGGLE = 1 << 20; + + class Traits { + uint32_t value; + + public: + Traits() + : value(0) + { + } + + bool has_door_open() const { return this->value & HAS_DOOR_OPEN; } + bool has_door_close() const { return this->value & HAS_DOOR_CLOSE; } + bool has_door_stop() const { return this->value & HAS_DOOR_STOP; } + bool has_door_status() const { return this->value & HAS_DOOR_STATUS; } + + bool has_light_toggle() const { return this->value & HAS_LIGHT_TOGGLE; } + + bool has_lock_toggle() const { return this->value & HAS_LOCK_TOGGLE; } + + void set_features(uint32_t feature) { this->value |= feature; } + void clear_features(uint32_t feature) { this->value &= ~feature; } + + static uint32_t all() + { + return HAS_DOOR_CLOSE | HAS_DOOR_OPEN | HAS_DOOR_STOP | HAS_DOOR_STATUS | HAS_LIGHT_TOGGLE | HAS_LOCK_TOGGLE; + } + }; + + struct SetRollingCodeCounter { + uint32_t counter; + }; + struct GetRollingCodeCounter { + }; + struct SetClientID { + uint64_t client_id; + }; + struct QueryStatus { + }; + struct QueryOpenings { + }; + struct ActivateLearn { + }; + struct InactivateLearn { + }; + struct QueryPairedDevices { + PairedDevice kind; + }; + struct QueryPairedDevicesAll { + }; + struct ClearPairedDevices { + PairedDevice kind; + }; + + // a poor man's sum-type, because C++ + SUM_TYPE(Args, + (SetRollingCodeCounter, set_rolling_code_counter), + (GetRollingCodeCounter, get_rolling_code_counter), + (SetClientID, set_client_id), + (QueryStatus, query_status), + (QueryOpenings, query_openings), + (ActivateLearn, activate_learn), + (InactivateLearn, inactivate_learn), + (QueryPairedDevices, query_paired_devices), + (QueryPairedDevicesAll, query_paired_devices_all), + (ClearPairedDevices, clear_paired_devices), ) + + struct RollingCodeCounter { + single_observable* value; + }; + + SUM_TYPE(Result, + (RollingCodeCounter, rolling_code_counter), ) + + class Protocol { + public: + virtual void setup(RATGDOComponent* ratgdo, Scheduler* scheduler, InternalGPIOPin* rx_pin, InternalGPIOPin* tx_pin); + virtual void loop(); + virtual void dump_config(); + + virtual void on_shutdown() { } + + virtual void sync(); + + // dry contact methods + virtual void set_open_limit(bool); + virtual void set_close_limit(bool); + virtual void set_discrete_open_pin(InternalGPIOPin* pin); + virtual void set_discrete_close_pin(InternalGPIOPin* pin); + + virtual const Traits& traits() const; + + virtual void light_action(LightAction action); + virtual void lock_action(LockAction action); + virtual void door_action(DoorAction action); + + virtual protocol::Result call(protocol::Args args); + }; + +} +} // namespace esphome::ratgdo diff --git a/components/ratgdo/ratgdo.cpp b/components/ratgdo/ratgdo.cpp new file mode 100644 index 0000000..946346b --- /dev/null +++ b/components/ratgdo/ratgdo.cpp @@ -0,0 +1,817 @@ +/************************************ + * Rage + * Against + * The + * Garage + * Door + * Opener + * + * Copyright (C) 2022 Paul Wieland + * + * GNU GENERAL PUBLIC LICENSE + ************************************/ + +#include "ratgdo.h" +#include "common.h" +#include "ratgdo_state.h" + +#ifdef PROTOCOL_DRYCONTACT +#include "dry_contact.h" +#endif +#ifdef PROTOCOL_SECPLUSV1 +#include "secplus1.h" +#endif +#ifdef PROTOCOL_SECPLUSV2 +#include "secplus2.h" +#endif + +#include "esphome/core/application.h" +#include "esphome/core/gpio.h" +#include "esphome/core/log.h" + +namespace esphome::ratgdo { + +using namespace protocol; + +static const char* const TAG = "ratgdo"; +static constexpr int SYNC_DELAY = 1000; +// Door state updates arrive over UART every ~200-400ms during movement. +// 2 seconds gives ample margin for slow openers while still expiring +// stale callbacks before a user could reasonably trigger an unrelated +// door state change. +static constexpr uint32_t DOOR_STATE_CALLBACK_TIMEOUT = 2000; + +using namespace scheduler_ids; + +void log_subscriber_overflow(const LogString* observable_name, uint32_t max) +{ + ESP_LOGE(TAG, "Too many subscribers for %s (max %d)", + LOG_STR_ARG(observable_name), (int)max); +} + +#ifdef RATGDO_USE_VEHICLE_SENSORS +static constexpr int CLEAR_PRESENCE = 60000; // how long to keep arriving/leaving active +static constexpr int PRESENCE_DETECT_WINDOW = 300000; // how long to calculate presence after door state change +static constexpr int PRESENCE_DETECT_WINDOW_AFTER_CLOSE = 15000; // how long to keep presence window active after door reaches closed + +// increasing these values increases reliability but also increases detection +// time +static constexpr int PRESENCE_DETECTION_ON_THRESHOLD = 5; // Minimum percentage of valid bitset::in_range samples required to + // detect vehicle +static constexpr int PRESENCE_DETECTION_OFF_DEBOUNCE = 2; // The number of consecutive bitset::in_range iterations that must be 0 + // before clearing vehicle detected state +#endif + +void RATGDOComponent::setup() +{ + this->output_gdo_pin_->setup(); + this->output_gdo_pin_->pin_mode(gpio::FLAG_OUTPUT); + + this->input_gdo_pin_->setup(); + this->input_gdo_pin_->pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLUP); + + this->input_obst_pin_->setup(); +#ifdef USE_ESP32 + this->input_obst_pin_->pin_mode(gpio::FLAG_INPUT | gpio::FLAG_PULLUP); +#else + this->input_obst_pin_->pin_mode(gpio::FLAG_INPUT); +#endif + this->input_obst_pin_->attach_interrupt(RATGDOStore::isr_obstruction, + &this->isr_store_, + gpio::INTERRUPT_FALLING_EDGE); + + this->protocol_->setup(this, &App.scheduler, this->input_gdo_pin_, + this->output_gdo_pin_); + + // many things happening at startup, use some delay for sync + this->set_timeout(SYNC_DELAY, [this] { this->sync(); }); + ESP_LOGD(TAG, " _____ _____ _____ _____ ____ _____ "); + ESP_LOGD(TAG, "| __ | _ |_ _| __| \\| |"); + ESP_LOGD(TAG, "| -| | | | | | | | | | |"); + ESP_LOGD(TAG, "|__|__|__|__| |_| |_____|____/|_____|"); + ESP_LOGD(TAG, "https://paulwieland.github.io/ratgdo/"); + + this->subscribe_door_state([this](DoorState state, float position) { +#ifdef RATGDO_USE_VEHICLE_SENSORS + if (this->last_door_state_for_presence_ != DoorState::UNKNOWN && state != DoorState::CLOSED && !this->flags_.presence_detect_window_active) { + this->flags_.presence_detect_window_active = true; + this->set_timeout( + TIMEOUT_PRESENCE_DETECT_WINDOW, PRESENCE_DETECT_WINDOW, + [this] { this->flags_.presence_detect_window_active = false; }); + } + + if (state == DoorState::CLOSED) { + this->set_timeout( + TIMEOUT_PRESENCE_DETECT_WINDOW, PRESENCE_DETECT_WINDOW_AFTER_CLOSE, + [this] { this->flags_.presence_detect_window_active = false; }); + } + + this->last_door_state_for_presence_ = state; +#endif + }); +} + +// initializing protocol, this gets called before setup() because +// its children components might require that +void RATGDOComponent::init_protocol() +{ +#ifdef PROTOCOL_SECPLUSV2 + this->protocol_ = new secplus2::Secplus2(); +#endif +#ifdef PROTOCOL_SECPLUSV1 + this->protocol_ = new secplus1::Secplus1(); +#endif +#ifdef PROTOCOL_DRYCONTACT + this->protocol_ = new dry_contact::DryContact(); +#endif +} + +void RATGDOComponent::loop() +{ + // obstruction_loop() must run before protocol_->loop() because it uses + // App.get_loop_component_start_time() and protocol_->loop() may block + // for up to 1.3ms (secplus2 transmit collision wait), which would make + // the cached timestamp stale. + this->obstruction_loop(); + this->protocol_->loop(); +} + +void RATGDOComponent::dump_config() +{ + ESP_LOGCONFIG(TAG, "Setting up RATGDO..."); + LOG_PIN(" Output GDO Pin: ", this->output_gdo_pin_); + LOG_PIN(" Input GDO Pin: ", this->input_gdo_pin_); + LOG_PIN(" Input Obstruction Pin: ", this->input_obst_pin_); + this->protocol_->dump_config(); +} + +void RATGDOComponent::on_shutdown() +{ + if (this->protocol_ != nullptr) { + this->protocol_->on_shutdown(); + } +} + +void RATGDOComponent::received(const DoorState door_state) +{ + ESP_LOGD(TAG, "Door state=%s", LOG_STR_ARG(DoorState_to_string(door_state))); + + auto prev_door_state = *this->door_state; + + if (prev_door_state == door_state) { + return; + } + + // opening duration calibration + if (*this->opening_duration == 0) { + if (door_state == DoorState::OPENING && prev_door_state == DoorState::CLOSED) { + this->start_opening = millis(); + } + if (door_state == DoorState::OPEN && prev_door_state == DoorState::OPENING && this->start_opening > 0) { + auto duration = (millis() - this->start_opening) / 1000; + this->set_opening_duration(round(duration * 10) / 10); + } + if (door_state == DoorState::STOPPED) { + this->start_opening = -1; + } + } + // closing duration calibration + if (*this->closing_duration == 0) { + if (door_state == DoorState::CLOSING && prev_door_state == DoorState::OPEN) { + this->start_closing = millis(); + } + if (door_state == DoorState::CLOSED && prev_door_state == DoorState::CLOSING && this->start_closing > 0) { + auto duration = (millis() - this->start_closing) / 1000; + this->set_closing_duration(round(duration * 10) / 10); + } + if (door_state == DoorState::STOPPED) { + this->start_closing = -1; + } + } + + if (door_state == DoorState::OPENING) { + // door started opening + if (prev_door_state == DoorState::CLOSING) { + this->door_position_update(); + this->cancel_position_sync_callbacks(); + this->door_move_delta = DOOR_DELTA_UNKNOWN; + } + this->door_start_moving = millis(); + this->door_start_position = *this->door_position; + if (this->door_move_delta == DOOR_DELTA_UNKNOWN) { + this->door_move_delta = 1.0 - this->door_start_position; + } + if (*this->opening_duration != 0) { + this->schedule_door_position_sync(); + } + } else if (door_state == DoorState::CLOSING) { + // door started closing + if (prev_door_state == DoorState::OPENING) { + this->door_position_update(); + this->cancel_position_sync_callbacks(); + this->door_move_delta = DOOR_DELTA_UNKNOWN; + } + this->door_start_moving = millis(); + this->door_start_position = *this->door_position; + if (this->door_move_delta == DOOR_DELTA_UNKNOWN) { + this->door_move_delta = 0.0 - this->door_start_position; + } + if (*this->closing_duration != 0) { + this->schedule_door_position_sync(); + } + } else if (door_state == DoorState::STOPPED) { + this->door_position_update(); + if (*this->door_position == DOOR_POSITION_UNKNOWN) { + this->door_position = 0.5; // best guess + } + this->cancel_position_sync_callbacks(); + this->cancel_timeout(TIMEOUT_DOOR_QUERY_STATE); + } else if (door_state == DoorState::OPEN) { + this->door_position = 1.0; + this->cancel_position_sync_callbacks(); + } else if (door_state == DoorState::CLOSED) { + this->door_position = 0.0; + this->cancel_position_sync_callbacks(); + } + + if (door_state == DoorState::OPEN || door_state == DoorState::CLOSED || door_state == DoorState::STOPPED) { + this->motor_state = MotorState::OFF; + } + + if (door_state == DoorState::CLOSED && door_state != prev_door_state) { + this->query_openings(); + } + + this->door_state = door_state; + this->on_door_state_.trigger(door_state); +} + +void RATGDOComponent::received(const LearnState learn_state) +{ + ESP_LOGD(TAG, "Learn state=%s", + LOG_STR_ARG(LearnState_to_string(learn_state))); + + if (*this->learn_state == learn_state) { + return; + } + + if (learn_state == LearnState::INACTIVE) { + this->query_paired_devices(); + } + + this->learn_state = learn_state; +} + +void RATGDOComponent::received(const LightState light_state) +{ + ESP_LOGD(TAG, "Light state=%s", + LOG_STR_ARG(LightState_to_string(light_state))); + this->light_state = light_state; +} + +void RATGDOComponent::received(const LockState lock_state) +{ + ESP_LOGD(TAG, "Lock state=%s", LOG_STR_ARG(LockState_to_string(lock_state))); + this->lock_state = lock_state; +} + +void RATGDOComponent::received(const ObstructionState obstruction_state) +{ + if (!this->flags_.obstruction_sensor_detected) { + ESP_LOGD(TAG, "Obstruction: state=%s", + LOG_STR_ARG(ObstructionState_to_string(*this->obstruction_state))); + + this->obstruction_state = obstruction_state; + // This isn't very fast to update, but its still better + // than nothing in the case the obstruction sensor is not + // wired up. + } +} + +void RATGDOComponent::received(const MotorState motor_state) +{ + ESP_LOGD(TAG, "Motor: state=%s", + LOG_STR_ARG(MotorState_to_string(*this->motor_state))); + this->motor_state = motor_state; +} + +void RATGDOComponent::received(const ButtonState button_state) +{ + ESP_LOGD(TAG, "Button state=%s", + LOG_STR_ARG(ButtonState_to_string(*this->button_state))); + this->button_state = button_state; +} + +void RATGDOComponent::received(const MotionState motion_state) +{ + ESP_LOGD(TAG, "Motion: %s", + LOG_STR_ARG(MotionState_to_string(*this->motion_state))); + this->motion_state = motion_state; + if (motion_state == MotionState::DETECTED) { + this->set_timeout(TIMEOUT_CLEAR_MOTION, 3000, + [this] { this->motion_state = MotionState::CLEAR; }); + if (*this->light_state == LightState::OFF) { + this->query_status(); + } + } +} + +void RATGDOComponent::received(const LightAction light_action) +{ + ESP_LOGD(TAG, "Light cmd=%s state=%s", + LOG_STR_ARG(LightAction_to_string(light_action)), + LOG_STR_ARG(LightState_to_string(*this->light_state))); + if (light_action == LightAction::OFF) { + this->light_state = LightState::OFF; + } else if (light_action == LightAction::ON) { + this->light_state = LightState::ON; + } else if (light_action == LightAction::TOGGLE) { + this->light_state = light_state_toggle(*this->light_state); + } +} + +void RATGDOComponent::received(const Openings openings) +{ + if (openings.flag == 0 || *this->openings != 0) { + this->openings = openings.count; + ESP_LOGD(TAG, "Openings: %d", *this->openings); + } else { + ESP_LOGD(TAG, "Ignoring openings, not from our request"); + } +} + +void RATGDOComponent::received(const PairedDeviceCount pdc) +{ + ESP_LOGD(TAG, "Paired device count, kind=%s count=%d", + LOG_STR_ARG(PairedDevice_to_string(pdc.kind)), pdc.count); + + if (pdc.kind == PairedDevice::ALL) { + this->paired_total = pdc.count; + } else if (pdc.kind == PairedDevice::REMOTE) { + this->paired_remotes = pdc.count; + } else if (pdc.kind == PairedDevice::KEYPAD) { + this->paired_keypads = pdc.count; + } else if (pdc.kind == PairedDevice::WALL_CONTROL) { + this->paired_wall_controls = pdc.count; + } else if (pdc.kind == PairedDevice::ACCESSORY) { + this->paired_accessories = pdc.count; + } +} + +void RATGDOComponent::received(const TimeToClose ttc) +{ + ESP_LOGD(TAG, "Time to close (TTC): %ds", ttc.seconds); +} + +void RATGDOComponent::received(const BatteryState battery_state) +{ + ESP_LOGD(TAG, "Battery state=%s", + LOG_STR_ARG(BatteryState_to_string(battery_state))); +} + +void RATGDOComponent::schedule_door_position_sync(float update_period) +{ + ESP_LOG1( + TAG, + "Schedule position sync: delta %f, start position: %f, start moving: %d", + this->door_move_delta, this->door_start_position, + this->door_start_moving); + auto duration = this->door_move_delta > 0 ? *this->opening_duration + : *this->closing_duration; + if (duration == 0) { + return; + } + this->position_sync_remaining_ = std::max(static_cast(1000 * duration / update_period), + static_cast(1)); + set_interval(INTERVAL_POSITION_SYNC, static_cast(update_period), + [this]() { + this->door_position_update(); + if (--this->position_sync_remaining_ == 0) { + cancel_interval(INTERVAL_POSITION_SYNC); + } + }); +} + +void RATGDOComponent::door_position_update() +{ + if (this->door_start_moving == 0 || this->door_start_position == DOOR_POSITION_UNKNOWN || this->door_move_delta == DOOR_DELTA_UNKNOWN) { + return; + } + auto now = millis(); + auto duration = this->door_move_delta > 0 ? *this->opening_duration + : -*this->closing_duration; + if (duration == 0) { + return; + } + auto position = this->door_start_position + (now - this->door_start_moving) / (1000 * duration); + ESP_LOG2(TAG, "[%d] Position update: %f", now, position); + this->door_position = clamp(position, 0.0f, 1.0f); +} + +void RATGDOComponent::set_opening_duration(float duration) +{ + ESP_LOGD(TAG, "Set opening duration: %.1fs", duration); + this->opening_duration = duration; +} + +void RATGDOComponent::set_closing_duration(float duration) +{ + ESP_LOGD(TAG, "Set closing duration: %.1fs", duration); + this->closing_duration = duration; +} + +#ifdef RATGDO_USE_DISTANCE_SENSOR +void RATGDOComponent::set_target_distance_measurement(int16_t distance) +{ + this->target_distance_measurement = distance; +} + +void RATGDOComponent::set_distance_measurement(int16_t distance) +{ + this->last_distance_measurement = distance; + +#ifdef RATGDO_USE_VEHICLE_SENSORS + this->in_range <<= 1; + this->in_range.set(0, distance <= *this->target_distance_measurement); + this->calculate_presence(); +#endif +} +#endif + +#ifdef RATGDO_USE_VEHICLE_SENSORS +void RATGDOComponent::calculate_presence() +{ + int percent = this->in_range.count() * 100 / this->in_range.size(); + + if (percent >= PRESENCE_DETECTION_ON_THRESHOLD) + this->vehicle_detected_state = VehicleDetectedState::YES; + + if (percent == 0 && *this->vehicle_detected_state == VehicleDetectedState::YES) { + this->presence_off_counter_++; + ESP_LOGD(TAG, "Off counter: %d", this->presence_off_counter_); + + if (this->presence_off_counter_ / this->in_range.size() >= PRESENCE_DETECTION_OFF_DEBOUNCE) { + this->presence_off_counter_ = 0; + this->vehicle_detected_state = VehicleDetectedState::NO; + } + } + + if (percent != this->last_presence_percent_) { + ESP_LOGD(TAG, "pct_in_range: %d", percent); + this->last_presence_percent_ = percent; + this->presence_off_counter_ = 0; + } + // ESP_LOGD(TAG, "in_range: %s", this->in_range.to_string().c_str()); +} +#endif + +#ifdef RATGDO_USE_VEHICLE_SENSORS +void RATGDOComponent::presence_change(bool sensor_value) +{ + if (this->flags_.presence_detect_window_active) { + // Arriving and leaving are mutually exclusive — each branch clears the + // other state. Sharing TIMEOUT_CLEAR_PRESENCE ensures that switching from + // arriving to leaving (or vice versa) cancels the previous clear timeout, + // which is correct since the previous state was already cleared above. + if (sensor_value) { + this->vehicle_arriving_state = VehicleArrivingState::YES; + this->vehicle_leaving_state = VehicleLeavingState::NO; + this->set_timeout(TIMEOUT_CLEAR_PRESENCE, CLEAR_PRESENCE, [this] { + this->vehicle_arriving_state = VehicleArrivingState::NO; + }); + } else { + this->vehicle_arriving_state = VehicleArrivingState::NO; + this->vehicle_leaving_state = VehicleLeavingState::YES; + this->set_timeout(TIMEOUT_CLEAR_PRESENCE, CLEAR_PRESENCE, [this] { + this->vehicle_leaving_state = VehicleLeavingState::NO; + }); + } + // if the door is closed, clear the presence detect window since a vehicle + // can't be arriving or leaving with the door shut + if (*this->door_state == DoorState::CLOSED) { + this->flags_.presence_detect_window_active = false; + this->cancel_timeout(TIMEOUT_PRESENCE_DETECT_WINDOW); + } + } +} +#endif + +Result RATGDOComponent::call_protocol(Args args) +{ + return this->protocol_->call(args); +} + +/*************************** OBSTRUCTION DETECTION ***************************/ + +void RATGDOComponent::obstruction_loop() +{ + // Safe to use cached loop timestamp here because obstruction_loop() + // runs before protocol_->loop() which contains the 1.3ms blocking + // transmit in secplus2. The 50ms CHECK_PERIOD has ample margin. + const uint32_t current_millis = App.get_loop_component_start_time(); + static uint32_t last_millis = 0; + static uint32_t last_asleep = 0; + + // the obstruction sensor has 3 states: clear (HIGH with LOW pulse every 7ms), + // obstructed (HIGH), asleep (LOW) the transitions between awake and asleep + // are tricky because the voltage drops slowly when falling asleep and is high + // without pulses when waking up + + // If at least 3 low pulses are counted within 50ms, the door is awake, not + // obstructed and we don't have to check anything else + + constexpr uint32_t CHECK_PERIOD = 50; + constexpr uint32_t PULSES_LOWER_LIMIT = 3; + + if (current_millis - last_millis > CHECK_PERIOD) { + // ESP_LOGD(TAG, "%ld: Obstruction count: %d, expected: %d, since asleep: + // %ld", + // current_millis, this->isr_store_.obstruction_low_count, + // PULSES_LOWER_LIMIT, current_millis - last_asleep + // ); + + // check to see if we got more then PULSES_LOWER_LIMIT pulses + if (this->isr_store_.obstruction_low_count > PULSES_LOWER_LIMIT) { + this->obstruction_state = ObstructionState::CLEAR; + this->flags_.obstruction_sensor_detected = true; + } else if (this->isr_store_.obstruction_low_count == 0) { + // if there have been no pulses the line is steady high or low + if (this->input_obst_pin_->digital_read() != this->flags_.obst_sleep_low) { + // asleep + last_asleep = current_millis; + } else { + // if the line is high and was last asleep more than 700ms ago, then + // there is an obstruction present + if (current_millis - last_asleep > 700) { + this->obstruction_state = ObstructionState::OBSTRUCTED; + } + } + } + last_millis = current_millis; + this->isr_store_.obstruction_low_count = 0; + } +} + +void RATGDOComponent::query_status() { this->protocol_->call(QueryStatus { }); } + +void RATGDOComponent::query_openings() +{ + this->protocol_->call(QueryOpenings { }); +} + +void RATGDOComponent::query_paired_devices() +{ + this->protocol_->call(QueryPairedDevicesAll { }); +} + +void RATGDOComponent::query_paired_devices(PairedDevice kind) +{ + this->protocol_->call(QueryPairedDevices { kind }); +} + +void RATGDOComponent::clear_paired_devices(PairedDevice kind) +{ + this->protocol_->call(ClearPairedDevices { kind }); +} + +void RATGDOComponent::sync() +{ + this->protocol_->sync(); + + // dry contact protocol: + // needed to trigger the intial state of the limit switch sensors + // ideally this would be in drycontact::sync +#ifdef PROTOCOL_DRYCONTACT + this->protocol_->set_open_limit(this->dry_contact_open_sensor_->state); + this->protocol_->set_close_limit(this->dry_contact_close_sensor_->state); +#endif +} + +void RATGDOComponent::set_door_state_expiry() +{ + this->set_timeout(TIMEOUT_DOOR_STATE_EXPIRY, DOOR_STATE_CALLBACK_TIMEOUT, + [this]() { + ESP_LOGW(TAG, "Door state callback expired, clearing"); + this->on_door_state_.clear(); + }); +} + +void RATGDOComponent::cancel_door_state_expiry() +{ + this->cancel_timeout(TIMEOUT_DOOR_STATE_EXPIRY); +} + +void RATGDOComponent::door_open() +{ + if (*this->door_state == DoorState::OPENING) { + return; // gets ignored by opener + } + + this->door_action(DoorAction::OPEN); + + if (*this->opening_duration > 0) { + // query state in case we don't get a status message + this->set_timeout( + TIMEOUT_DOOR_QUERY_STATE, (*this->opening_duration + 2) * 1000, + [this]() { + if (*this->door_state != DoorState::OPEN && *this->door_state != DoorState::STOPPED) { + this->received(DoorState::OPEN); // probably missed a status mesage, + // assume it's open + this->query_status(); // query in case we're wrong and it's stopped + } + }); + } +} + +void RATGDOComponent::door_close() +{ + if (*this->door_state == DoorState::CLOSING) { + return; // gets ignored by opener + } + + if (*this->door_state == DoorState::OPENING) { + // have to stop door first, otherwise close command is ignored + this->door_action(DoorAction::STOP); + this->on_door_state([this](DoorState s) { + if (s == DoorState::STOPPED) { + this->door_action(DoorAction::CLOSE); + } else { + ESP_LOGW(TAG, "Door did not stop, ignoring close command"); + } + }); + return; + } + + if (this->flags_.obstruction_sensor_detected) { + this->door_action(DoorAction::CLOSE); + } else if (*this->door_state == DoorState::OPEN) { + ESP_LOGD(TAG, "No obstruction sensors detected. Close using TOGGLE."); + this->door_action(DoorAction::TOGGLE); + } + + if (*this->closing_duration > 0) { + // query state in case we don't get a status message + this->set_timeout( + TIMEOUT_DOOR_QUERY_STATE, (*this->closing_duration + 2) * 1000, + [this]() { + if (*this->door_state != DoorState::CLOSED && *this->door_state != DoorState::STOPPED) { + this->received(DoorState::CLOSED); // probably missed a status + // mesage, assume it's closed + this->query_status(); // query in case we're wrong and it's stopped + } + }); + } +} + +void RATGDOComponent::door_stop() +{ + if (*this->door_state != DoorState::OPENING && *this->door_state != DoorState::CLOSING) { + ESP_LOGW(TAG, "The door is not moving."); + return; + } + this->door_action(DoorAction::STOP); +} + +void RATGDOComponent::door_toggle() { this->door_action(DoorAction::TOGGLE); } + +void RATGDOComponent::door_action(DoorAction action) +{ +#ifdef RATGDO_USE_CLOSING_DELAY + if (*this->closing_delay > 0 && (action == DoorAction::CLOSE || (action == DoorAction::TOGGLE && *this->door_state != DoorState::CLOSED))) { + this->door_action_delayed = DoorActionDelayed::YES; + this->set_timeout(TIMEOUT_DOOR_ACTION, *this->closing_delay * 1000, [this] { + this->door_action_delayed = DoorActionDelayed::NO; + this->protocol_->door_action(DoorAction::CLOSE); + }); + } else { + this->protocol_->door_action(action); + } +#else + this->protocol_->door_action(action); +#endif +} + +void RATGDOComponent::door_move_to_position(float position) +{ + if (*this->door_state == DoorState::OPENING || *this->door_state == DoorState::CLOSING) { + this->door_action(DoorAction::STOP); + this->on_door_state([this, position](DoorState s) { + if (s == DoorState::STOPPED) { + this->door_move_to_position(position); + } + }); + return; + } + + auto delta = position - *this->door_position; + if (delta == 0) { + ESP_LOGD(TAG, "Door is already at position %.2f", position); + return; + } + + auto duration = delta > 0 ? *this->opening_duration : -*this->closing_duration; + if (duration == 0) { + ESP_LOGW(TAG, "I don't know duration, ignoring move to position"); + return; + } + + auto operation_time = 1000 * duration * delta; + this->door_move_delta = delta; + ESP_LOGD(TAG, "Moving to position %.2f in %.1fs", position, + operation_time / 1000.0); + + this->door_action(delta > 0 ? DoorAction::OPEN : DoorAction::CLOSE); + this->set_timeout(TIMEOUT_MOVE_TO_POSITION, operation_time, + [this] { this->door_action(DoorAction::STOP); }); +} + +void RATGDOComponent::cancel_position_sync_callbacks() +{ + if (this->door_start_moving != 0) { + ESP_LOGD(TAG, "Cancelling position callbacks"); + this->cancel_timeout(TIMEOUT_MOVE_TO_POSITION); + cancel_interval(INTERVAL_POSITION_SYNC); + + this->door_start_moving = 0; + this->door_start_position = DOOR_POSITION_UNKNOWN; + this->door_move_delta = DOOR_DELTA_UNKNOWN; + } +} + +void RATGDOComponent::light_on() +{ + this->light_state = LightState::ON; + this->protocol_->light_action(LightAction::ON); +} + +void RATGDOComponent::light_off() +{ + this->light_state = LightState::OFF; + this->protocol_->light_action(LightAction::OFF); +} + +void RATGDOComponent::light_toggle() +{ + this->light_state = light_state_toggle(*this->light_state); + this->protocol_->light_action(LightAction::TOGGLE); +} + +LightState RATGDOComponent::get_light_state() const +{ + return *this->light_state; +} + +// Lock functions +void RATGDOComponent::lock() +{ + this->lock_state = LockState::LOCKED; + this->protocol_->lock_action(LockAction::LOCK); +} + +void RATGDOComponent::unlock() +{ + this->lock_state = LockState::UNLOCKED; + this->protocol_->lock_action(LockAction::UNLOCK); +} + +void RATGDOComponent::lock_toggle() +{ + this->lock_state = lock_state_toggle(*this->lock_state); + this->protocol_->lock_action(LockAction::TOGGLE); +} + +// Learn functions +void RATGDOComponent::activate_learn() +{ + this->protocol_->call(ActivateLearn { }); +} + +void RATGDOComponent::inactivate_learn() +{ + this->protocol_->call(InactivateLearn { }); +} + +// Subscribe implementations are now templates in ratgdo.h + +// dry contact methods +void RATGDOComponent::set_dry_contact_open_sensor( + esphome::binary_sensor::BinarySensor* dry_contact_open_sensor) +{ + dry_contact_open_sensor_ = dry_contact_open_sensor; + dry_contact_open_sensor_->add_on_state_callback([this](bool sensor_value) { + this->protocol_->set_open_limit(sensor_value); + this->door_position = 1.0; + }); +} + +void RATGDOComponent::set_dry_contact_close_sensor( + esphome::binary_sensor::BinarySensor* dry_contact_close_sensor) +{ + dry_contact_close_sensor_ = dry_contact_close_sensor; + dry_contact_close_sensor_->add_on_state_callback([this](bool sensor_value) { + this->protocol_->set_close_limit(sensor_value); + this->door_position = 0.0; + }); +} + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/ratgdo.h b/components/ratgdo/ratgdo.h new file mode 100644 index 0000000..24748f0 --- /dev/null +++ b/components/ratgdo/ratgdo.h @@ -0,0 +1,685 @@ +/************************************ + * Rage + * Against + * The + * Garage + * Door + * Opener + * + * Copyright (C) 2022 Paul Wieland + * + * GNU GENERAL PUBLIC LICENSE + ************************************/ + +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" +#include "esphome/core/preferences.h" + +#include +#include +#include + +#include "callbacks.h" +#include "macros.h" +#include "observable.h" +#include "protocol.h" +#include "ratgdo_state.h" + +// Observable subscriber counts — set by Python codegen via cg.add_define(). +// Missing defines are a build error to catch codegen issues early. +#ifndef RATGDO_MAX_DOOR_STATE_SUBSCRIBERS +#error "RATGDO_MAX_DOOR_STATE_SUBSCRIBERS must be defined by codegen" +#endif +#ifndef RATGDO_MAX_DOOR_ACTION_DELAYED_SUBSCRIBERS +#error "RATGDO_MAX_DOOR_ACTION_DELAYED_SUBSCRIBERS must be defined by codegen" +#endif +#ifndef RATGDO_MAX_DISTANCE_SUBSCRIBERS +#error "RATGDO_MAX_DISTANCE_SUBSCRIBERS must be defined by codegen" +#endif +#ifndef RATGDO_MAX_VEHICLE_DETECTED_SUBSCRIBERS +#error "RATGDO_MAX_VEHICLE_DETECTED_SUBSCRIBERS must be defined by codegen" +#endif +#ifndef RATGDO_MAX_VEHICLE_ARRIVING_SUBSCRIBERS +#error "RATGDO_MAX_VEHICLE_ARRIVING_SUBSCRIBERS must be defined by codegen" +#endif +#ifndef RATGDO_MAX_VEHICLE_LEAVING_SUBSCRIBERS +#error "RATGDO_MAX_VEHICLE_LEAVING_SUBSCRIBERS must be defined by codegen" +#endif + +namespace esphome { +class InternalGPIOPin; +} // namespace esphome + +namespace esphome::ratgdo { + +class RATGDOComponent; +typedef Parented RATGDOClient; + +const float DOOR_POSITION_UNKNOWN = -1.0; +const float DOOR_DELTA_UNKNOWN = -2.0; +const uint8_t PAIRED_DEVICES_UNKNOWN = 0xFF; + +struct RATGDOStore { + volatile uint32_t obstruction_low_count = 0; // count obstruction low pulses + + static void IRAM_ATTR HOT isr_obstruction(RATGDOStore* arg) + { + arg->obstruction_low_count++; + } +}; + +using protocol::Args; +using protocol::Result; + +class RATGDOComponent : public Component { +public: + RATGDOComponent() + { + } + + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + + void init_protocol(); + + void obstruction_loop(); + + float start_opening { -1 }; + single_observable opening_duration { 0 }; + float start_closing { -1 }; + single_observable closing_duration { 0 }; +#ifdef RATGDO_USE_CLOSING_DELAY + single_observable closing_delay { 0 }; +#endif + +#ifdef RATGDO_USE_DISTANCE_SENSOR + single_observable target_distance_measurement { -1 }; + std::bitset<256> in_range; // the length of this bitset determines how many out of range readings are required for presence detection to change states + observable last_distance_measurement { 0 }; +#endif + + single_observable openings { 0 }; // number of times the door has been opened + single_observable paired_total { PAIRED_DEVICES_UNKNOWN }; + single_observable paired_remotes { PAIRED_DEVICES_UNKNOWN }; + single_observable paired_keypads { PAIRED_DEVICES_UNKNOWN }; + single_observable paired_wall_controls { PAIRED_DEVICES_UNKNOWN }; + single_observable paired_accessories { PAIRED_DEVICES_UNKNOWN }; + + observable door_state { DoorState::UNKNOWN }; + observable door_position { DOOR_POSITION_UNKNOWN }; + observable door_action_delayed { DoorActionDelayed::NO }; + + unsigned long door_start_moving { 0 }; + float door_start_position { DOOR_POSITION_UNKNOWN }; + float door_move_delta { DOOR_DELTA_UNKNOWN }; + uint16_t position_sync_remaining_ { 0 }; + + single_observable light_state { LightState::UNKNOWN }; + single_observable lock_state { LockState::UNKNOWN }; + single_observable obstruction_state { ObstructionState::UNKNOWN }; + single_observable motor_state { MotorState::UNKNOWN }; + single_observable button_state { ButtonState::UNKNOWN }; + single_observable motion_state { MotionState::UNKNOWN }; + single_observable learn_state { LearnState::UNKNOWN }; +#ifdef RATGDO_USE_VEHICLE_SENSORS + observable vehicle_detected_state { VehicleDetectedState::NO }; + observable vehicle_arriving_state { VehicleArrivingState::NO }; + observable vehicle_leaving_state { VehicleLeavingState::NO }; +#endif + + OnceCallbacks on_door_state_; + + single_observable sync_failed { false }; + + void set_output_gdo_pin(InternalGPIOPin* pin) { this->output_gdo_pin_ = pin; } + void set_input_gdo_pin(InternalGPIOPin* pin) { this->input_gdo_pin_ = pin; } + void set_input_obst_pin(InternalGPIOPin* pin) { this->input_obst_pin_ = pin; } + void set_obst_sleep_low(bool low) { this->flags_.obst_sleep_low = low; } + + // dry contact methods + void set_dry_contact_open_sensor(esphome::binary_sensor::BinarySensor* dry_contact_open_sensor_); + void set_dry_contact_close_sensor(esphome::binary_sensor::BinarySensor* dry_contact_close_sensor_); + void set_discrete_open_pin(InternalGPIOPin* pin) { this->protocol_->set_discrete_open_pin(pin); } + void set_discrete_close_pin(InternalGPIOPin* pin) { this->protocol_->set_discrete_close_pin(pin); } + + Result call_protocol(Args args); + + void received(const DoorState door_state); + void received(const LightState light_state); + void received(const LockState lock_state); + void received(const ObstructionState obstruction_state); + void received(const LightAction light_action); + void received(const MotorState motor_state); + void received(const ButtonState button_state); + void received(const MotionState motion_state); + void received(const LearnState light_state); + void received(const Openings openings); + void received(const TimeToClose ttc); + void received(const PairedDeviceCount pdc); + void received(const BatteryState pdc); + + // door + void door_toggle(); + void door_open(); + void door_close(); + void door_stop(); + + void door_action(DoorAction action); + void ensure_door_action(DoorAction action, uint32_t delay = 1500); + void door_move_to_position(float position); + void set_door_position(float door_position) { this->door_position = door_position; } + void set_opening_duration(float duration); + void set_closing_duration(float duration); +#ifdef RATGDO_USE_CLOSING_DELAY + void set_closing_delay(uint32_t delay) { this->closing_delay = delay; } +#endif + void schedule_door_position_sync(float update_period = 500); + void door_position_update(); + void cancel_position_sync_callbacks(); +#ifdef RATGDO_USE_DISTANCE_SENSOR + void set_target_distance_measurement(int16_t distance); + void set_distance_measurement(int16_t distance); +#endif +#ifdef RATGDO_USE_VEHICLE_SENSORS + void calculate_presence(); + void presence_change(bool sensor_value); +#endif + + // light + void light_toggle(); + void light_on(); + void light_off(); + LightState get_light_state() const; + + // lock + void lock_toggle(); + void lock(); + void unlock(); + + // Learn & Paired + void activate_learn(); + void inactivate_learn(); + void query_paired_devices(); + void query_paired_devices(PairedDevice kind); + void clear_paired_devices(PairedDevice kind); + + // Uses length + first character instead of string comparisons to avoid + // string literals in RODATA which consume RAM on ESP8266. + // Valid values: "all" (3,a), "remote" (6,r), "keypad" (6,k), "wall" (4,w), "accessory" (9,a) + // Template so it works with std::string, StringRef, or any type with length() and operator[]. + template + void clear_paired_devices(const StringT& kind) + { + PairedDevice device; + if (kind.length() == 3 && kind[0] == 'a') { + device = PairedDevice::ALL; + } else if (kind.length() == 6 && kind[0] == 'r') { + device = PairedDevice::REMOTE; + } else if (kind.length() == 6 && kind[0] == 'k') { + device = PairedDevice::KEYPAD; + } else if (kind.length() == 4 && kind[0] == 'w') { + device = PairedDevice::WALL_CONTROL; + } else if (kind.length() == 9 && kind[0] == 'a') { + device = PairedDevice::ACCESSORY; + } else { + return; + } + this->clear_paired_devices(device); + } + + // button functionality + void query_status(); + void query_openings(); + void sync(); + + using Component::set_timeout; + + void set_door_state_expiry(); + void cancel_door_state_expiry(); + + // Register a one-shot door state callback with automatic expiry. + // + // Handles secplus1's nested callback chains where opening from + // STOPPED requires multiple state transitions: + // + // on_door_state(outer_cb) // wait for CLOSING + // → set_door_state_expiry() // expiry A + // → [door reports CLOSING] + // → outer_cb fires, calls: + // toggle_door() + // on_door_state(inner_cb) // wait for STOPPED + // → set_door_state_expiry() // expiry B (replaces A) + // → [door reports STOPPED] + // → inner_cb fires + // toggle_door() // door now opening + // count()==0 → cancel expiry B + // + // The user callback runs BEFORE the expiry check because it may + // re-arm the chain by calling on_door_state() again. If it does, + // the new call sets expiry B which replaces expiry A (same timeout + // ID = replace, not add). We only cancel expiry when count()==0, + // meaning no new callback was queued — otherwise we'd cancel + // expiry B here and leave the inner callback without protection. + template + void on_door_state(F&& callback) + { + using Cb = std::decay_t; + this->on_door_state_([this, cb = Cb(std::forward(callback))](DoorState s) { + cb(s); + if (!this->on_door_state_.count()) { + this->cancel_door_state_expiry(); + } + }); + this->set_door_state_expiry(); + } + + // children subscriptions — type-safe templates (no std::function) + // Callbacks must be trivially copyable and fit in Callback storage + // (3 * sizeof(void*)), e.g. [this] or [this, f] lambdas. + // Enforced at compile time by Callback::create(). + template + void subscribe_rolling_code_counter(F&& f); + template + void subscribe_opening_duration(F&& f); + template + void subscribe_closing_duration(F&& f); +#ifdef RATGDO_USE_CLOSING_DELAY + template + void subscribe_closing_delay(F&& f); +#endif + template + void subscribe_openings(F&& f); + template + void subscribe_paired_devices_total(F&& f); + template + void subscribe_paired_remotes(F&& f); + template + void subscribe_paired_keypads(F&& f); + template + void subscribe_paired_wall_controls(F&& f); + template + void subscribe_paired_accessories(F&& f); + template + void subscribe_door_state(F&& f); + template + void subscribe_light_state(F&& f); + template + void subscribe_lock_state(F&& f); + template + void subscribe_obstruction_state(F&& f); + template + void subscribe_motor_state(F&& f); + template + void subscribe_button_state(F&& f); + template + void subscribe_motion_state(F&& f); + template + void subscribe_sync_failed(F&& f); + template + void subscribe_learn_state(F&& f); + template + void subscribe_door_action_delayed(F&& f); +#ifdef RATGDO_USE_DISTANCE_SENSOR + template + void subscribe_distance_measurement(F&& f); +#endif +#ifdef RATGDO_USE_VEHICLE_SENSORS + template + void subscribe_vehicle_detected_state(F&& f); + template + void subscribe_vehicle_arriving_state(F&& f); + template + void subscribe_vehicle_leaving_state(F&& f); +#endif + +protected: + // Pointers first (4-byte aligned) + protocol::Protocol* protocol_; + InternalGPIOPin* output_gdo_pin_; + InternalGPIOPin* input_gdo_pin_; + InternalGPIOPin* input_obst_pin_; + esphome::binary_sensor::BinarySensor* dry_contact_open_sensor_; + esphome::binary_sensor::BinarySensor* dry_contact_close_sensor_; + + // 4-byte members + RATGDOStore isr_store_ { }; + + // Bool members packed into bitfield + struct { + uint8_t obstruction_sensor_detected : 1; + uint8_t obst_sleep_low : 1; +#ifdef RATGDO_USE_VEHICLE_SENSORS + uint8_t presence_detect_window_active : 1; + uint8_t reserved : 5; // Reserved for future use +#else + uint8_t reserved : 6; // Reserved for future use +#endif + } flags_ { 0 }; + + // Subscriber counters for defer name allocation + uint8_t door_state_sub_num_ { 0 }; + uint8_t door_action_delayed_sub_num_ { 0 }; +#ifdef RATGDO_USE_DISTANCE_SENSOR + uint8_t distance_sub_num_ { 0 }; +#endif +#ifdef RATGDO_USE_VEHICLE_SENSORS + uint8_t vehicle_detected_sub_num_ { 0 }; + uint8_t vehicle_arriving_sub_num_ { 0 }; + uint8_t vehicle_leaving_sub_num_ { 0 }; + int last_presence_percent_ { -1 }; + int presence_off_counter_ { 0 }; + DoorState last_door_state_for_presence_ { DoorState::UNKNOWN }; +#endif +}; // RATGDOComponent + +void log_subscriber_overflow(const LogString* observable_name, uint32_t max); + +inline uint32_t get_scheduler_id(uint32_t base, uint32_t count, uint8_t& counter, const LogString* observable_name) +{ + if (count == 0) { + log_subscriber_overflow(observable_name, count); + return base; + } + if (counter >= count) { + log_subscriber_overflow(observable_name, count); + return base + count - 1; // reuse last ID to avoid collision with first subscriber + } + return base + counter++; +} + +// Scheduler IDs using uint32_t ranges to avoid heap allocations +// Bases are auto-generated from counts to prevent ID conflicts +namespace scheduler_ids { + inline constexpr uint32_t INTERVAL_POSITION_SYNC = 0; + + // Multi-subscriber ranges — counts derived from codegen defines + inline constexpr uint32_t DEFER_DOOR_STATE_COUNT = RATGDO_MAX_DOOR_STATE_SUBSCRIBERS; + inline constexpr uint32_t DEFER_DOOR_STATE_BASE = INTERVAL_POSITION_SYNC + 1; + + inline constexpr uint32_t DEFER_DOOR_ACTION_DELAYED_COUNT = RATGDO_MAX_DOOR_ACTION_DELAYED_SUBSCRIBERS; + inline constexpr uint32_t DEFER_DOOR_ACTION_DELAYED_BASE = DEFER_DOOR_STATE_BASE + DEFER_DOOR_STATE_COUNT; + +#ifdef RATGDO_USE_DISTANCE_SENSOR + inline constexpr uint32_t DEFER_DISTANCE_COUNT = RATGDO_MAX_DISTANCE_SUBSCRIBERS; + inline constexpr uint32_t DEFER_DISTANCE_BASE = DEFER_DOOR_ACTION_DELAYED_BASE + DEFER_DOOR_ACTION_DELAYED_COUNT; + inline constexpr uint32_t DEFER_DISTANCE_END = DEFER_DISTANCE_BASE + DEFER_DISTANCE_COUNT; +#else + inline constexpr uint32_t DEFER_DISTANCE_END = DEFER_DOOR_ACTION_DELAYED_BASE + DEFER_DOOR_ACTION_DELAYED_COUNT; +#endif + +#ifdef RATGDO_USE_VEHICLE_SENSORS + inline constexpr uint32_t DEFER_VEHICLE_DETECTED_COUNT = RATGDO_MAX_VEHICLE_DETECTED_SUBSCRIBERS; + inline constexpr uint32_t DEFER_VEHICLE_DETECTED_BASE = DEFER_DISTANCE_END; + inline constexpr uint32_t DEFER_VEHICLE_ARRIVING_COUNT = RATGDO_MAX_VEHICLE_ARRIVING_SUBSCRIBERS; + inline constexpr uint32_t DEFER_VEHICLE_ARRIVING_BASE = DEFER_VEHICLE_DETECTED_BASE + DEFER_VEHICLE_DETECTED_COUNT; + inline constexpr uint32_t DEFER_VEHICLE_LEAVING_COUNT = RATGDO_MAX_VEHICLE_LEAVING_SUBSCRIBERS; + inline constexpr uint32_t DEFER_VEHICLE_LEAVING_BASE = DEFER_VEHICLE_ARRIVING_BASE + DEFER_VEHICLE_ARRIVING_COUNT; + inline constexpr uint32_t DEFER_VEHICLE_END = DEFER_VEHICLE_LEAVING_BASE + DEFER_VEHICLE_LEAVING_COUNT; +#else + inline constexpr uint32_t DEFER_VEHICLE_END = DEFER_DISTANCE_END; +#endif + + // Single-subscriber IDs + enum : uint32_t { + DEFER_ROLLING_CODE = DEFER_VEHICLE_END, + DEFER_OPENING_DURATION, + DEFER_CLOSING_DURATION, + DEFER_CLOSING_DELAY, + DEFER_OPENINGS, + DEFER_PAIRED_TOTAL, + DEFER_PAIRED_REMOTES, + DEFER_PAIRED_KEYPADS, + DEFER_PAIRED_WALL_CONTROLS, + DEFER_PAIRED_ACCESSORIES, + DEFER_LIGHT_STATE, + DEFER_LOCK_STATE, + DEFER_OBSTRUCTION_STATE, + DEFER_MOTOR_STATE, + DEFER_BUTTON_STATE, + DEFER_MOTION_STATE, + DEFER_LEARN_STATE, + + // Named timeout IDs (replacing string-based names) + TIMEOUT_DOOR_QUERY_STATE, + TIMEOUT_DOOR_ACTION, + TIMEOUT_MOVE_TO_POSITION, + TIMEOUT_CLEAR_MOTION, + // Shared by RATGDOComponent and Secplus1 — safe because only one + // protocol is compiled at a time (#ifdef PROTOCOL_SECPLUSV1) and + // both use ratgdo_ as the scheduler owner. + TIMEOUT_DOOR_STATE_EXPIRY, + TIMEOUT_PRESENCE_DETECT_WINDOW, + TIMEOUT_CLEAR_PRESENCE, + TIMEOUT_WALL_PANEL_EMULATION, + TIMEOUT_SYNC, + }; +} // namespace scheduler_ids + +// Template implementations for subscribe methods. +// Each wraps the callback in a deferred call so that if the observable +// fires multiple times during one loop iteration, only the last value +// is dispatched to the child component. + +template +void RATGDOComponent::subscribe_rolling_code_counter(F&& f) +{ + // change update to children is defered until after component loop + // if multiple changes occur during component loop, only the last one is notified + auto counter = this->protocol_->call(protocol::GetRollingCodeCounter { }); + if (counter.tag == protocol::Result::Tag::rolling_code_counter) { + counter.value.rolling_code_counter.value->subscribe([this, f](uint32_t state) { + defer(scheduler_ids::DEFER_ROLLING_CODE, [f, state] { f(state); }); + }); + } +} + +template +void RATGDOComponent::subscribe_opening_duration(F&& f) +{ + this->opening_duration.subscribe([this, f](float state) { + defer(scheduler_ids::DEFER_OPENING_DURATION, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_closing_duration(F&& f) +{ + this->closing_duration.subscribe([this, f](float state) { + defer(scheduler_ids::DEFER_CLOSING_DURATION, [f, state] { f(state); }); + }); +} + +#ifdef RATGDO_USE_CLOSING_DELAY +template +void RATGDOComponent::subscribe_closing_delay(F&& f) +{ + this->closing_delay.subscribe([this, f](uint32_t state) { + defer(scheduler_ids::DEFER_CLOSING_DELAY, [f, state] { f(state); }); + }); +} +#endif + +template +void RATGDOComponent::subscribe_openings(F&& f) +{ + this->openings.subscribe([this, f](uint16_t state) { + defer(scheduler_ids::DEFER_OPENINGS, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_paired_devices_total(F&& f) +{ + this->paired_total.subscribe([this, f](uint8_t state) { + defer(scheduler_ids::DEFER_PAIRED_TOTAL, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_paired_remotes(F&& f) +{ + this->paired_remotes.subscribe([this, f](uint8_t state) { + defer(scheduler_ids::DEFER_PAIRED_REMOTES, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_paired_keypads(F&& f) +{ + this->paired_keypads.subscribe([this, f](uint8_t state) { + defer(scheduler_ids::DEFER_PAIRED_KEYPADS, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_paired_wall_controls(F&& f) +{ + this->paired_wall_controls.subscribe([this, f](uint8_t state) { + defer(scheduler_ids::DEFER_PAIRED_WALL_CONTROLS, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_paired_accessories(F&& f) +{ + this->paired_accessories.subscribe([this, f](uint8_t state) { + defer(scheduler_ids::DEFER_PAIRED_ACCESSORIES, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_door_state(F&& f) +{ + uint32_t id = get_scheduler_id(scheduler_ids::DEFER_DOOR_STATE_BASE, scheduler_ids::DEFER_DOOR_STATE_COUNT, + this->door_state_sub_num_, LOG_STR("door_state")); + this->door_state.subscribe([this, f, id](DoorState state) { + defer(id, [this, f, state] { f(state, *this->door_position); }); + }); + this->door_position.subscribe([this, f, id](float position) { + defer(id, [this, f, position] { f(*this->door_state, position); }); + }); +} + +template +void RATGDOComponent::subscribe_light_state(F&& f) +{ + this->light_state.subscribe([this, f](LightState state) { + defer(scheduler_ids::DEFER_LIGHT_STATE, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_lock_state(F&& f) +{ + this->lock_state.subscribe([this, f](LockState state) { + defer(scheduler_ids::DEFER_LOCK_STATE, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_obstruction_state(F&& f) +{ + this->obstruction_state.subscribe([this, f](ObstructionState state) { + defer(scheduler_ids::DEFER_OBSTRUCTION_STATE, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_motor_state(F&& f) +{ + this->motor_state.subscribe([this, f](MotorState state) { + defer(scheduler_ids::DEFER_MOTOR_STATE, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_button_state(F&& f) +{ + this->button_state.subscribe([this, f](ButtonState state) { + defer(scheduler_ids::DEFER_BUTTON_STATE, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_motion_state(F&& f) +{ + this->motion_state.subscribe([this, f](MotionState state) { + defer(scheduler_ids::DEFER_MOTION_STATE, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_sync_failed(F&& f) +{ + this->sync_failed.subscribe(std::forward(f)); +} + +template +void RATGDOComponent::subscribe_learn_state(F&& f) +{ + this->learn_state.subscribe([this, f](LearnState state) { + defer(scheduler_ids::DEFER_LEARN_STATE, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_door_action_delayed(F&& f) +{ + uint32_t id = get_scheduler_id(scheduler_ids::DEFER_DOOR_ACTION_DELAYED_BASE, scheduler_ids::DEFER_DOOR_ACTION_DELAYED_COUNT, + this->door_action_delayed_sub_num_, LOG_STR("door_action_delayed")); + this->door_action_delayed.subscribe([this, f, id](DoorActionDelayed state) { + defer(id, [f, state] { f(state); }); + }); +} + +#ifdef RATGDO_USE_DISTANCE_SENSOR +template +void RATGDOComponent::subscribe_distance_measurement(F&& f) +{ + uint32_t id = get_scheduler_id(scheduler_ids::DEFER_DISTANCE_BASE, scheduler_ids::DEFER_DISTANCE_COUNT, + this->distance_sub_num_, LOG_STR("distance_measurement")); + this->last_distance_measurement.subscribe([this, f, id](int16_t state) { + defer(id, [f, state] { f(state); }); + }); +} +#endif + +#ifdef RATGDO_USE_VEHICLE_SENSORS +template +void RATGDOComponent::subscribe_vehicle_detected_state(F&& f) +{ + uint32_t id = get_scheduler_id(scheduler_ids::DEFER_VEHICLE_DETECTED_BASE, scheduler_ids::DEFER_VEHICLE_DETECTED_COUNT, + this->vehicle_detected_sub_num_, LOG_STR("vehicle_detected")); + this->vehicle_detected_state.subscribe([this, f, id](VehicleDetectedState state) { + defer(id, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_vehicle_arriving_state(F&& f) +{ + uint32_t id = get_scheduler_id(scheduler_ids::DEFER_VEHICLE_ARRIVING_BASE, scheduler_ids::DEFER_VEHICLE_ARRIVING_COUNT, + this->vehicle_arriving_sub_num_, LOG_STR("vehicle_arriving")); + this->vehicle_arriving_state.subscribe([this, f, id](VehicleArrivingState state) { + defer(id, [f, state] { f(state); }); + }); +} + +template +void RATGDOComponent::subscribe_vehicle_leaving_state(F&& f) +{ + uint32_t id = get_scheduler_id(scheduler_ids::DEFER_VEHICLE_LEAVING_BASE, scheduler_ids::DEFER_VEHICLE_LEAVING_COUNT, + this->vehicle_leaving_sub_num_, LOG_STR("vehicle_leaving")); + this->vehicle_leaving_state.subscribe([this, f, id](VehicleLeavingState state) { + defer(id, [f, state] { f(state); }); + }); +} +#endif + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/ratgdo_state.cpp b/components/ratgdo/ratgdo_state.cpp new file mode 100644 index 0000000..32cd2a4 --- /dev/null +++ b/components/ratgdo/ratgdo_state.cpp @@ -0,0 +1,47 @@ +#include "ratgdo_state.h" + +namespace esphome::ratgdo { + +LightState light_state_toggle(LightState state) +{ + switch (state) { + case LightState::OFF: + return LightState::ON; + case LightState::ON: + return LightState::OFF; + // 2 and 3 appears sometimes + case LightState::UNKNOWN: + default: + return LightState::UNKNOWN; + } +} + +LockState lock_state_toggle(LockState state) +{ + switch (state) { + case LockState::UNLOCKED: + return LockState::LOCKED; + case LockState::LOCKED: + return LockState::UNLOCKED; + // 2 and 3 appears sometimes + case LockState::UNKNOWN: + default: + return LockState::UNKNOWN; + } +} + +LearnState learn_state_toggle(LearnState state) +{ + switch (state) { + case LearnState::ACTIVE: + return LearnState::INACTIVE; + case LearnState::INACTIVE: + return LearnState::ACTIVE; + // 2 and 3 appears sometimes + case LearnState::UNKNOWN: + default: + return LearnState::UNKNOWN; + } +} + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/ratgdo_state.h b/components/ratgdo/ratgdo_state.h new file mode 100644 index 0000000..38be430 --- /dev/null +++ b/components/ratgdo/ratgdo_state.h @@ -0,0 +1,139 @@ +/************************************ + * Rage + * Against + * The + * Garage + * Door + * Opener + * + * Copyright (C) 2022 Paul Wieland + * + * GNU GENERAL PUBLIC LICENSE + ************************************/ + +#pragma once +#include "esphome/core/defines.h" +#include "macros.h" +#include + +namespace esphome::ratgdo { + +ENUM(DoorState, uint8_t, + (UNKNOWN, 0), + (OPEN, 1), + (CLOSED, 2), + (STOPPED, 3), + (OPENING, 4), + (CLOSING, 5)) + +ENUM(DoorActionDelayed, uint8_t, + (NO, 0), + (YES, 1)) + +/// Enum for all states a the light can be in. +ENUM(LightState, uint8_t, + (OFF, 0), + (ON, 1), + (UNKNOWN, 2)) +LightState light_state_toggle(LightState state); + +/// Enum for all states a the lock can be in. +ENUM(LockState, uint8_t, + (UNLOCKED, 0), + (LOCKED, 1), + (UNKNOWN, 2)) +LockState lock_state_toggle(LockState state); + +/// MotionState for all states a the motion can be in. +ENUM(MotionState, uint8_t, + (CLEAR, 0), + (DETECTED, 1), + (UNKNOWN, 2)) + +/// Enum for all states a the obstruction can be in. +ENUM(ObstructionState, uint8_t, + (OBSTRUCTED, 0), + (CLEAR, 1), + (UNKNOWN, 2)) + +/// Enum for all states a the motor can be in. +ENUM(MotorState, uint8_t, + (OFF, 0), + (ON, 1), + (UNKNOWN, 2)) + +/// Enum for all states the button can be in. +ENUM(ButtonState, uint8_t, + (PRESSED, 0), + (RELEASED, 1), + (UNKNOWN, 2)) + +ENUM_SPARSE(BatteryState, uint8_t, + (UNKNOWN, 0), + (CHARGING, 0x6), + (FULL, 0x8)) + +/// Enum for learn states. +ENUM(LearnState, uint8_t, + (INACTIVE, 0), + (ACTIVE, 1), + (UNKNOWN, 2)) +LearnState learn_state_toggle(LearnState state); + +ENUM(PairedDevice, uint8_t, + (ALL, 0), + (REMOTE, 1), + (KEYPAD, 2), + (WALL_CONTROL, 3), + (ACCESSORY, 4), + (UNKNOWN, 0xff)) + +// actions +ENUM(LightAction, uint8_t, + (OFF, 0), + (ON, 1), + (TOGGLE, 2), + (UNKNOWN, 3)) + +ENUM(LockAction, uint8_t, + (UNLOCK, 0), + (LOCK, 1), + (TOGGLE, 2), + (UNKNOWN, 3)) + +ENUM(DoorAction, uint8_t, + (CLOSE, 0), + (OPEN, 1), + (TOGGLE, 2), + (STOP, 3), + (UNKNOWN, 4)) + +#ifdef RATGDO_USE_VEHICLE_SENSORS +ENUM(VehicleDetectedState, uint8_t, + (NO, 0), + (YES, 1)) + +ENUM(VehicleArrivingState, uint8_t, + (NO, 0), + (YES, 1)) + +ENUM(VehicleLeavingState, uint8_t, + (NO, 0), + (YES, 1)) +#endif + +struct Openings { + uint16_t count; + uint8_t flag; +}; + +struct PairedDeviceCount { + PairedDevice kind; + uint8_t count; +}; + +struct TimeToClose { + uint16_t seconds; +}; + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/ratgdo_uart.h b/components/ratgdo/ratgdo_uart.h new file mode 100644 index 0000000..71b1e52 --- /dev/null +++ b/components/ratgdo/ratgdo_uart.h @@ -0,0 +1,9 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 +#include "ratgdo_uart_esp32.h" +#elif defined(USE_ESP8266) +#include "ratgdo_uart_esp8266.h" +#endif diff --git a/components/ratgdo/ratgdo_uart_esp32.cpp b/components/ratgdo/ratgdo_uart_esp32.cpp new file mode 100644 index 0000000..4b89447 --- /dev/null +++ b/components/ratgdo/ratgdo_uart_esp32.cpp @@ -0,0 +1,187 @@ +#include "ratgdo_uart.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" +#include + +#include +#include // for rmt_get_channel_id (used once during init) + +#include +#include +#include +#include +#include + +namespace esphome::ratgdo { + +static const char* const TAG = "ratgdo_uart"; + +static constexpr size_t UART_RX_BUFFER_SIZE = 512; + +// Security+ 2.0 preamble timing (microseconds, at 1MHz RMT resolution = ticks) +static constexpr uint16_t PREAMBLE_DURATION_US = 1300; +static constexpr uint16_t PREAMBLE_MARK_US = 130; +static constexpr uint8_t SIGNAL_SETTLE_US = 5; + +// RMT channel configuration +static constexpr uint32_t RMT_RESOLUTION_HZ = 1000000; // 1MHz = 1us per tick +static constexpr size_t RMT_MEM_BLOCK_SYMBOLS = 64; +static constexpr size_t RMT_TRANS_QUEUE_DEPTH = 4; + +// UART port and signal index — must stay in sync +static constexpr int UART_PORT = UART_NUM_1; +static constexpr int UART_TX_SIGNAL_IDX = U1TXD_OUT_IDX; + +RatgdoUART::RatgdoUART() { } + +RatgdoUART::~RatgdoUART() +{ + if (this->is_initialized_) { + uart_driver_delete((uart_port_t)this->uart_num_); + if (this->rmt_copy_encoder_) { + rmt_del_encoder(this->rmt_copy_encoder_); + this->rmt_copy_encoder_ = nullptr; + } + if (this->rmt_chan_handle_) { + rmt_disable(this->rmt_chan_handle_); + rmt_del_channel(this->rmt_chan_handle_); + this->rmt_chan_handle_ = nullptr; + } + } +} + +void RatgdoUART::begin(int baud, RatgdoUARTConfig config, int rx_pin, + int tx_pin, bool invert) +{ + this->tx_pin_ = tx_pin; + this->rx_pin_ = rx_pin; + this->baud_ = baud; + this->inverted_ = invert; + + this->uart_num_ = UART_PORT; + + uart_config_t uart_config = { }; + uart_config.baud_rate = baud; + uart_config.data_bits = UART_DATA_8_BITS; + uart_config.parity = (config == RATGDO_UART_8E1) ? UART_PARITY_EVEN : UART_PARITY_DISABLE; + uart_config.stop_bits = UART_STOP_BITS_1; + uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; + uart_config.source_clk = UART_SCLK_APB; + + ESP_ERROR_CHECK( + uart_driver_install((uart_port_t)this->uart_num_, UART_RX_BUFFER_SIZE, 0, 0, NULL, 0)); + ESP_ERROR_CHECK(uart_param_config((uart_port_t)this->uart_num_, &uart_config)); + + rmt_tx_channel_config_t tx_chan_config = { }; + tx_chan_config.gpio_num = (gpio_num_t)tx_pin; + tx_chan_config.clk_src = RMT_CLK_SRC_DEFAULT; + tx_chan_config.resolution_hz = RMT_RESOLUTION_HZ; + tx_chan_config.mem_block_symbols = RMT_MEM_BLOCK_SYMBOLS; + tx_chan_config.trans_queue_depth = RMT_TRANS_QUEUE_DEPTH; + tx_chan_config.flags.invert_out = 0; + ESP_ERROR_CHECK(rmt_new_tx_channel(&tx_chan_config, &this->rmt_chan_handle_)); + + rmt_copy_encoder_config_t copy_encoder_config = { }; + ESP_ERROR_CHECK( + rmt_new_copy_encoder(©_encoder_config, &this->rmt_copy_encoder_)); + + ESP_ERROR_CHECK(rmt_enable(this->rmt_chan_handle_)); + + // Cache the channel ID for GPIO matrix switching during preamble. + // The RMT driver allocates channels dynamically, so we query it once + // here rather than relying on the private esp_private/rmt.h API at runtime. + ESP_ERROR_CHECK(rmt_get_channel_id(this->rmt_chan_handle_, &this->rmt_channel_id_)); + + ESP_ERROR_CHECK(uart_set_pin((uart_port_t)this->uart_num_, tx_pin, rx_pin, + UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE)); + + if (invert) { + uart_set_line_inverse((uart_port_t)this->uart_num_, + UART_SIGNAL_TXD_INV | UART_SIGNAL_RXD_INV); + } + + this->is_initialized_ = true; + ESP_LOGD(TAG, "Hardware UART and RMT initialized on TX=%d RX=%d", tx_pin, + rx_pin); +} + +void RatgdoUART::transmit_secplus2_preamble() +{ + if (!this->is_initialized_) + return; + + // Switch GPIO matrix from UART TX to RMT output + esp_rom_gpio_connect_out_signal(this->tx_pin_, RMT_SIG_OUT0_IDX + this->rmt_channel_id_, + false, false); + + esp_rom_delay_us(SIGNAL_SETTLE_US); + + // Indicate the start of a frame by pulling the 12V line low for at least + // 1 byte followed by one STOP bit, which indicates to the receiving end + // that the start of the message follows. + // The output pin controls a transistor, so the logic is inverted: + // RMT level 1 (HIGH) pulls the wire low, level 0 (LOW) lets it float high. + rmt_symbol_word_t symbols[1]; + symbols[0].duration0 = PREAMBLE_DURATION_US; + symbols[0].level0 = 1; + symbols[0].duration1 = PREAMBLE_MARK_US; + symbols[0].level1 = 0; + + rmt_transmit_config_t transmit_config = { }; + transmit_config.loop_count = 0; + rmt_transmit(this->rmt_chan_handle_, this->rmt_copy_encoder_, symbols, + sizeof(symbols), &transmit_config); + rmt_tx_wait_all_done(this->rmt_chan_handle_, -1); + + // Switch GPIO matrix back to UART TX + esp_rom_gpio_connect_out_signal(this->tx_pin_, UART_TX_SIGNAL_IDX, false, false); + esp_rom_delay_us(SIGNAL_SETTLE_US); +} + +void RatgdoUART::write(const uint8_t* data, size_t len) +{ + if (this->is_initialized_) { + uart_write_bytes((uart_port_t)this->uart_num_, (const char*)data, len); + uart_wait_tx_done((uart_port_t)this->uart_num_, portMAX_DELAY); + } +} + +void RatgdoUART::write(uint8_t data) { write(&data, 1); } + +int RatgdoUART::available() +{ + if (!this->is_initialized_) + return 0; + size_t length = 0; + uart_get_buffered_data_len((uart_port_t)this->uart_num_, &length); + return length; +} + +int RatgdoUART::read() +{ + if (!this->is_initialized_) + return -1; + uint8_t data = 0; + int len = uart_read_bytes((uart_port_t)this->uart_num_, &data, 1, 0); + if (len > 0) { + return data; + } + return -1; +} + +void RatgdoUART::on_shutdown() +{ + if (this->is_initialized_) { + // Unmap the matrix output signal so that UART peripheral resets do not + // pull the hardware line dominant. + esp_rom_gpio_connect_out_signal(this->tx_pin_, SIG_GPIO_OUT_IDX, false, false); + gpio_set_direction((gpio_num_t)this->tx_pin_, GPIO_MODE_INPUT); + gpio_set_direction((gpio_num_t)this->rx_pin_, GPIO_MODE_INPUT); + } +} + +} // namespace esphome::ratgdo + +#endif diff --git a/components/ratgdo/ratgdo_uart_esp32.h b/components/ratgdo/ratgdo_uart_esp32.h new file mode 100644 index 0000000..ad1db2f --- /dev/null +++ b/components/ratgdo/ratgdo_uart_esp32.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include +#include +#include + +namespace esphome::ratgdo { + +enum RatgdoUARTConfig { + RATGDO_UART_8N1, + RATGDO_UART_8E1, +}; + +class RatgdoUART { +public: + RatgdoUART(); + ~RatgdoUART(); + + void begin(int baud, RatgdoUARTConfig config, int rx_pin, int tx_pin, + bool invert); + void write(const uint8_t* data, size_t len); + void write(uint8_t data); + int available(); + int read(); + void enableIntTx(bool enable) { } + void enableAutoBaud(bool enable) { } + int baudRate() { return this->baud_; } + + // Sends the SecPlus 2.0 preamble using RMT + void transmit_secplus2_preamble(); + + void on_shutdown(); + +private: + // Pointers (4 bytes on 32-bit) + rmt_channel_handle_t rmt_chan_handle_ { nullptr }; + rmt_encoder_handle_t rmt_copy_encoder_ { nullptr }; + + // 4-byte members + int tx_pin_ { -1 }; + int rx_pin_ { -1 }; + int baud_ { 9600 }; + int uart_num_ { -1 }; + int rmt_channel_id_ { 0 }; + + // 1-byte members packed at the end + bool inverted_ { true }; + bool is_initialized_ { false }; +}; + +} // namespace esphome::ratgdo + +#endif diff --git a/components/ratgdo/secplus2.cpp b/components/ratgdo/secplus2.cpp new file mode 100644 index 0000000..ae6a039 --- /dev/null +++ b/components/ratgdo/secplus2.cpp @@ -0,0 +1,494 @@ + +#ifdef PROTOCOL_SECPLUSV2 + +#include "secplus2.h" +#include "ratgdo.h" + +#include "esphome/core/gpio.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/scheduler.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; + + this->uart_.begin(9600, RATGDO_UART_8N1, rx_pin->get_pin(), tx_pin->get_pin(), true); + this->uart_.enableIntTx(false); + this->uart_.enableAutoBaud(true); + + this->traits_.set_features(Traits::all()); + } + + 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) + { + bool synced = true; + if (*this->ratgdo_->door_state == DoorState::UNKNOWN) { + this->query_status(); + synced = false; + } + if (*this->ratgdo_->openings == 0) { + this->query_openings(); + synced = false; + } + if (*this->ratgdo_->paired_total == PAIRED_DEVICES_UNKNOWN) { + this->query_paired_devices(PairedDevice::ALL); + synced = false; + } + if (*this->ratgdo_->paired_remotes == PAIRED_DEVICES_UNKNOWN) { + this->query_paired_devices(PairedDevice::REMOTE); + synced = false; + } + if (*this->ratgdo_->paired_keypads == PAIRED_DEVICES_UNKNOWN) { + this->query_paired_devices(PairedDevice::KEYPAD); + synced = false; + } + if (*this->ratgdo_->paired_wall_controls == PAIRED_DEVICES_UNKNOWN) { + this->query_paired_devices(PairedDevice::WALL_CONTROL); + synced = false; + } + if (*this->ratgdo_->paired_accessories == PAIRED_DEVICES_UNKNOWN) { + this->query_paired_devices(PairedDevice::ACCESSORY); + synced = false; + } + + if (synced) { + return; + } + + if (tries == 2 && *this->ratgdo_->door_state == DoorState::UNKNOWN) { // made a few attempts and no progress (door state is the first sync request) + // increment rolling code counter by some amount in case we crashed without writing to flash the latest value + this->increment_rolling_code_counter(MAX_CODES_WITHOUT_FLASH_WRITE); + } + + // not sync-ed after 30s, notify failure + if (millis() - start > 30000) { + ESP_LOGW(TAG, "Triggering sync failed actions."); + this->ratgdo_->sync_failed = true; + } else { + if (tries % 3 == 0) { + delay *= 1.5; + } + this->scheduler_->set_timeout(this->ratgdo_, TIMEOUT_SYNC, delay, [this, start, delay, tries]() { + this->sync_helper(start, delay, tries + 1); + }); + }; + } + + void Secplus2::sync() + { + 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); + } + + Result Secplus2::call(Args args) + { + using Tag = Args::Tag; + if (args.tag == Tag::query_status) { + this->send_command(CommandType::GET_STATUS); + } else if (args.tag == Tag::query_openings) { + this->send_command(CommandType::GET_OPENINGS); + } else if (args.tag == Tag::get_rolling_code_counter) { + return Result(RollingCodeCounter { std::addressof(this->rolling_code_counter_) }); + } else if (args.tag == Tag::set_rolling_code_counter) { + this->set_rolling_code_counter(args.value.set_rolling_code_counter.counter); + } else if (args.tag == Tag::set_client_id) { + this->set_client_id(args.value.set_client_id.client_id); + } else if (args.tag == Tag::query_paired_devices) { + this->query_paired_devices(args.value.query_paired_devices.kind); + } else if (args.tag == Tag::query_paired_devices_all) { + this->query_paired_devices(); + } else if (args.tag == Tag::clear_paired_devices) { + this->clear_paired_devices(args.value.clear_paired_devices.kind); + } else if (args.tag == Tag::activate_learn) { + this->activate_learn(); + } else if (args.tag == Tag::inactivate_learn) { + this->inactivate_learn(); + } + return { }; + } + + 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); + } + + void Secplus2::query_paired_devices() + { + const auto kinds = { + PairedDevice::ALL, + PairedDevice::REMOTE, + PairedDevice::KEYPAD, + PairedDevice::WALL_CONTROL, + PairedDevice::ACCESSORY + }; + uint32_t timeout = 0; + for (auto kind : kinds) { + timeout += 200; + this->ratgdo_->set_timeout(timeout, [this, kind] { this->query_paired_devices(kind); }); + } + } + + void Secplus2::query_paired_devices(PairedDevice kind) + { + ESP_LOGD(TAG, "Query paired devices of type: %s", LOG_STR_ARG(PairedDevice_to_string(kind))); + this->send_command(Command { CommandType::GET_PAIRED_DEVICES, static_cast(kind) }); + } + + // wipe devices from memory based on get paired devices nibble values + void Secplus2::clear_paired_devices(PairedDevice kind) + { + if (kind == PairedDevice::UNKNOWN) { + return; + } + ESP_LOGW(TAG, "Clear paired devices of type: %s", LOG_STR_ARG(PairedDevice_to_string(kind))); + if (kind == PairedDevice::ALL) { + this->ratgdo_->set_timeout(200, [this] { this->send_command(Command { CommandType::CLEAR_PAIRED_DEVICES, static_cast(PairedDevice::REMOTE) - 1 }); }); // wireless + this->ratgdo_->set_timeout(400, [this] { this->send_command(Command { CommandType::CLEAR_PAIRED_DEVICES, static_cast(PairedDevice::KEYPAD) - 1 }); }); // keypads + this->ratgdo_->set_timeout(600, [this] { this->send_command(Command { CommandType::CLEAR_PAIRED_DEVICES, static_cast(PairedDevice::WALL_CONTROL) - 1 }); }); // wall controls + this->ratgdo_->set_timeout(800, [this] { this->send_command(Command { CommandType::CLEAR_PAIRED_DEVICES, static_cast(PairedDevice::ACCESSORY) - 1 }); }); // accessories + this->ratgdo_->set_timeout(1000, [this] { this->query_status(); }); + this->ratgdo_->set_timeout(1200, [this] { this->query_paired_devices(); }); + } else { + uint8_t dev_kind = static_cast(kind) - 1; + this->send_command(Command { CommandType::CLEAR_PAIRED_DEVICES, dev_kind }); // just requested device + this->ratgdo_->set_timeout(200, [this] { this->query_status(); }); + this->ratgdo_->set_timeout(400, [this, kind] { this->query_paired_devices(kind); }); + } + } + + // Learn functions + void Secplus2::activate_learn() + { + // Send LEARN with nibble = 0 then nibble = 1 to mimic wall control learn button + this->send_command(Command { CommandType::LEARN, 0 }); + this->ratgdo_->set_timeout(150, [this] { this->send_command(Command { CommandType::LEARN, 1 }); }); + this->ratgdo_->set_timeout(500, [this] { this->query_status(); }); + } + + void Secplus2::inactivate_learn() + { + // Send LEARN twice with nibble = 0 to inactivate learn and get status to update switch state + this->send_command(Command { CommandType::LEARN, 0 }); + this->ratgdo_->set_timeout(150, [this] { this->send_command(Command { CommandType::LEARN, 0 }); }); + this->ratgdo_->set_timeout(500, [this] { this->query_status(); }); + } + + optional Secplus2::read_command() + { + if (!this->flags_.rx_reading_msg) { + while (this->uart_.available()) { + uint8_t ser_byte = this->uart_.read(); + this->rx_last_read_ = millis(); + + if (ser_byte != 0x55 && ser_byte != 0x01 && ser_byte != 0x00) { + { + char hex[format_hex_pretty_size(1)]; + ESP_LOG2(TAG, "Ignoring byte (%d): %s, baud: %d", this->rx_byte_count_, format_hex_pretty_to(hex, &ser_byte, 1), this->uart_.baudRate()); + } + this->rx_byte_count_ = 0; + continue; + } + this->rx_msg_start_ = ((this->rx_msg_start_ << 8) | ser_byte) & 0xffffff; + this->rx_byte_count_++; + + // if we are at the start of a message, capture the next 16 bytes + if (this->rx_msg_start_ == 0x550100) { + ESP_LOG1(TAG, "Baud: %d", this->uart_.baudRate()); + 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; + break; + } + } + } + if (this->flags_.rx_reading_msg) { + while (this->uart_.available()) { + uint8_t ser_byte = this->uart_.read(); + this->rx_last_read_ = millis(); + this->rx_packet_[this->rx_byte_count_] = ser_byte; + this->rx_byte_count_++; + // ESP_LOG2(TAG, "Received byte (%d): %02X, baud: %d", this->rx_byte_count_, ser_byte, this->uart_.baudRate()); + + if (this->rx_byte_count_ == PACKET_LENGTH) { + this->flags_.rx_reading_msg = false; + this->rx_byte_count_ = 0; + this->print_packet(LOG_STR("Received packet"), this->rx_packet_); + return this->decode_packet(this->rx_packet_); + } + } + + if (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 (a full packet should be received in ~20ms), + // discard it so we can read the following packet correctly + ESP_LOGW(TAG, "Discard incomplete packet, length: %d", this->rx_byte_count_); + this->flags_.rx_reading_msg = false; + this->rx_byte_count_ = 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_LOG1(TAG, "[%ld] received mine: rolling=%07" PRIx32 " fixed=%010" PRIx64 " data=%08" PRIx32, millis(), rolling, fixed, data); + return { }; + } else { + ESP_LOG1(TAG, "[%ld] received rolling=%07" PRIx32 " fixed=%010" PRIx64 " data=%08" PRIx32, millis(), 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_LOG1(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_LOG1(TAG, "Handle command: %s", LOG_STR_ARG(CommandType_to_string(cmd.type))); + + 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)); + // ESP_LOGD(TAG, "Obstruction: reading from byte2, bit2, status=%d", ((byte2 >> 2) & 1) == 1); + this->ratgdo_->received(to_ObstructionState((cmd.byte1 >> 6) & 1, ObstructionState::UNKNOWN)); + this->ratgdo_->received(to_LearnState((cmd.byte2 >> 5) & 1, LearnState::UNKNOWN)); + } else if (cmd.type == CommandType::LIGHT) { + this->ratgdo_->received(to_LightAction(cmd.nibble, LightAction::UNKNOWN)); + } else if (cmd.type == CommandType::MOTOR_ON) { + this->ratgdo_->received(MotorState::ON); + } else if (cmd.type == CommandType::DOOR_ACTION) { + auto button_state = (cmd.byte1 & 1) == 1 ? ButtonState::PRESSED : ButtonState::RELEASED; + this->ratgdo_->received(button_state); + } else if (cmd.type == CommandType::MOTION) { + this->ratgdo_->received(MotionState::DETECTED); + } else if (cmd.type == CommandType::OPENINGS) { + this->ratgdo_->received(Openings { static_cast((cmd.byte1 << 8) | cmd.byte2), cmd.nibble }); + } else if (cmd.type == CommandType::SET_TTC) { + this->ratgdo_->received(TimeToClose { static_cast((cmd.byte1 << 8) | cmd.byte2) }); + } else if (cmd.type == CommandType::PAIRED_DEVICES) { + PairedDeviceCount pdc; + pdc.kind = to_PairedDevice(cmd.nibble, PairedDevice::UNKNOWN); + if (pdc.kind == PairedDevice::ALL) { + pdc.count = cmd.byte2; + } else if (pdc.kind == PairedDevice::REMOTE) { + pdc.count = cmd.byte2; + } else if (pdc.kind == PairedDevice::KEYPAD) { + pdc.count = cmd.byte2; + } else if (pdc.kind == PairedDevice::WALL_CONTROL) { + pdc.count = cmd.byte2; + } else if (pdc.kind == PairedDevice::ACCESSORY) { + pdc.count = cmd.byte2; + } + this->ratgdo_->received(pdc); + } else if (cmd.type == CommandType::BATTERY_STATUS) { + this->ratgdo_->received(to_BatteryState(cmd.byte1, BatteryState::UNKNOWN)); + } + + ESP_LOG1(TAG, "Done handle command: %s", LOG_STR_ARG(CommandType_to_string(cmd.type))); + } + + 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_LOG2(TAG, "[%ld] Encode for transmit rolling=%07" PRIx32 " fixed=%010" PRIx64 " data=%08" PRIx32, millis(), *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) + { + this->rolling_code_counter_ = (*this->rolling_code_counter_ + delta) & 0xfffffff; + } + + void Secplus2::set_rolling_code_counter(uint32_t counter) + { + ESP_LOGV(TAG, "Set rolling code counter to %d", counter); + this->rolling_code_counter_ = counter; + } + + void Secplus2::set_client_id(uint64_t client_id) + { + this->client_id_ = client_id & 0xFFFFFFFF; + } + +} // namespace secplus2 +} // namespace esphome::ratgdo + +#endif // PROTOCOL_SECPLUSV2 diff --git a/components/ratgdo/secplus2.h b/components/ratgdo/secplus2.h new file mode 100644 index 0000000..32b181c --- /dev/null +++ b/components/ratgdo/secplus2.h @@ -0,0 +1,195 @@ +#pragma once + +#ifdef PROTOCOL_SECPLUSV2 + +#include "esphome/core/optional.h" +#include "ratgdo_uart.h" + +#include "callbacks.h" +#include "common.h" +#include "observable.h" +#include "protocol.h" +#include "ratgdo_state.h" + +namespace esphome { + +class Scheduler; +class InternalGPIOPin; + +} // namespace esphome + +namespace esphome::ratgdo { +class RATGDOComponent; + +namespace secplus2 { + + using namespace esphome::ratgdo::protocol; + + static const uint8_t PACKET_LENGTH = 19; + typedef uint8_t WirePacket[PACKET_LENGTH]; + + ENUM_SPARSE(CommandType, uint16_t, + (UNKNOWN, 0x000), + (GET_STATUS, 0x080), + (STATUS, 0x081), + (OBST_1, 0x084), // sent when an obstruction happens? + (OBST_2, 0x085), // sent when an obstruction happens? + (BATTERY_STATUS, 0x09d), + (PAIR_3, 0x0a0), + (PAIR_3_RESP, 0x0a1), + + (LEARN, 0x181), + (LOCK, 0x18c), + (DOOR_ACTION, 0x280), + (LIGHT, 0x281), + (MOTOR_ON, 0x284), + (MOTION, 0x285), + + (GET_PAIRED_DEVICES, 0x307), // nibble 0 for total, 1 wireless, 2 keypads, 3 wall, 4 accessories. + (PAIRED_DEVICES, 0x308), // byte2 holds number of paired devices + (CLEAR_PAIRED_DEVICES, 0x30D), // nibble 0 to clear remotes, 1 keypads, 2 wall, 3 accessories (offset from above) + + (LEARN_1, 0x391), + (PING, 0x392), + (PING_RESP, 0x393), + + (PAIR_2, 0x400), + (PAIR_2_RESP, 0x401), + (SET_TTC, 0x402), // ttc_in_seconds = (byte1<<8)+byte2 + (CANCEL_TTC, 0x408), // ? + (TTC, 0x40a), // Time to close + (GET_OPENINGS, 0x48b), + (OPENINGS, 0x48c), // openings = (byte1<<8)+byte2 + ) + + inline bool operator==(const uint16_t cmd_i, const CommandType& cmd_e) { return cmd_i == static_cast(cmd_e); } + inline bool operator==(const CommandType& cmd_e, const uint16_t cmd_i) { return cmd_i == static_cast(cmd_e); } + + enum class IncrementRollingCode { + NO, + YES, + }; + + struct Command { + CommandType type; + uint8_t nibble; + uint8_t byte1; + uint8_t byte2; + + Command() + : type(CommandType::UNKNOWN) + { + } + Command(CommandType type_, uint8_t nibble_ = 0, uint8_t byte1_ = 0, uint8_t byte2_ = 0) + : type(type_) + , nibble(nibble_) + , byte1(byte1_) + , byte2(byte2_) + { + } + }; + + class Secplus2 : public Protocol { + public: + void setup(RATGDOComponent* ratgdo, Scheduler* scheduler, InternalGPIOPin* rx_pin, InternalGPIOPin* tx_pin); + void loop(); + void dump_config(); + void on_shutdown() override; + + void sync(); + + void light_action(LightAction action); + void lock_action(LockAction action); + void door_action(DoorAction action); + + Result call(Args args); + + const Traits& traits() const { return this->traits_; } + + // methods not used by secplus2 + void set_open_limit(bool state) { } + void set_close_limit(bool state) { } + void set_discrete_open_pin(InternalGPIOPin* pin) { } + void set_discrete_close_pin(InternalGPIOPin* pin) { } + + protected: + void increment_rolling_code_counter(int delta = 1); + void set_rolling_code_counter(uint32_t counter); + void set_client_id(uint64_t client_id); + + optional read_command(); + void handle_command(const Command& cmd); + + void send_command(Command cmd, IncrementRollingCode increment = IncrementRollingCode::YES); + template + void send_command(Command cmd, IncrementRollingCode increment, F&& on_sent) + { + // Only register the callback if the command will be accepted. + // If transmit_pending is set the command will be dropped, and + // a stale callback would fire when the previous pending packet + // transmits -- executing logic (e.g. the second phase of a + // door_command) at the wrong time. + // + // Register before send_command() because transmit_packet() may + // succeed immediately and call on_command_sent_.trigger() inline. + if (this->flags_.transmit_pending) { + return; + } + this->on_command_sent_(std::forward(on_sent)); + this->send_command(cmd, increment); + } + void encode_packet(Command cmd, WirePacket& packet); + bool transmit_packet(); + + void door_command(DoorAction action); + + void query_status(); + void query_openings(); + void query_paired_devices(); + void query_paired_devices(PairedDevice kind); + void clear_paired_devices(PairedDevice kind); + void activate_learn(); + void inactivate_learn(); + + void print_packet(const esphome::LogString* prefix, const WirePacket& packet) const; + optional decode_packet(const WirePacket& packet) const; + + void sync_helper(uint32_t start, uint32_t delay, uint8_t tries); + + // 8-byte member first (may require 8-byte alignment on some 32-bit systems) + uint64_t client_id_ { 0x539 }; + + // Pointers (4-byte aligned) + InternalGPIOPin* tx_pin_; + InternalGPIOPin* rx_pin_; + RATGDOComponent* ratgdo_; + Scheduler* scheduler_; + + // 4-byte members + uint32_t transmit_pending_start_ { 0 }; + uint32_t rx_msg_start_ { 0 }; + uint32_t rx_last_read_ { 0 }; + + // Larger structures + single_observable rolling_code_counter_ { 0 }; + OnceCallbacks on_command_sent_; + Traits traits_; + RatgdoUART uart_; + + // 19-byte arrays + WirePacket tx_packet_; + WirePacket rx_packet_; + + // Small members at the end + uint16_t rx_byte_count_ { 0 }; + LearnState learn_state_ { LearnState::UNKNOWN }; + struct { + uint8_t transmit_pending : 1; + uint8_t rx_reading_msg : 1; + uint8_t reserved : 6; // Reserved for future use + } flags_ { 0 }; + }; +} // namespace secplus2 +} // namespace esphome::ratgdo + +#endif // PROTOCOL_SECPLUSV2 diff --git a/components/ratgdo/sensor/__init__.py b/components/ratgdo/sensor/__init__.py new file mode 100644 index 0000000..3a15ae9 --- /dev/null +++ b/components/ratgdo/sensor/__init__.py @@ -0,0 +1,71 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID + +from .. import ( + RATGDO_CLIENT_SCHMEA, + ratgdo_ns, + register_ratgdo_child, + subscribe_distance, +) + +CONF_DISTANCE = "distance" + +DEPENDENCIES = ["ratgdo"] + +# Track which sensor types have been used +USED_TYPES: set[str] = set() + +RATGDOSensor = ratgdo_ns.class_("RATGDOSensor", sensor.Sensor, cg.Component) +RATGDOSensorType = ratgdo_ns.enum("RATGDOSensorType") + +CONF_TYPE = "type" +TYPES = { + "openings": RATGDOSensorType.RATGDO_OPENINGS, + "paired_devices_total": RATGDOSensorType.RATGDO_PAIRED_DEVICES_TOTAL, + "paired_devices_remotes": RATGDOSensorType.RATGDO_PAIRED_REMOTES, + "paired_devices_keypads": RATGDOSensorType.RATGDO_PAIRED_KEYPADS, + "paired_devices_wall_controls": RATGDOSensorType.RATGDO_PAIRED_WALL_CONTROLS, + "paired_devices_accessories": RATGDOSensorType.RATGDO_PAIRED_ACCESSORIES, + "distance": RATGDOSensorType.RATGDO_DISTANCE, +} + + +def validate_unique_type(config): + """Validate that each sensor type is only used once.""" + sensor_type = config[CONF_TYPE] + if sensor_type in USED_TYPES: + raise cv.Invalid(f"Only one sensor of type '{sensor_type}' is allowed") + USED_TYPES.add(sensor_type) + return config + + +CONFIG_SCHEMA = cv.All( + sensor.sensor_schema(RATGDOSensor) + .extend( + { + cv.Required(CONF_TYPE): cv.enum(TYPES, lower=True), + } + ) + .extend(RATGDO_CLIENT_SCHMEA), + validate_unique_type, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await sensor.register_sensor(var, config) + await cg.register_component(var, config) + cg.add(var.set_ratgdo_sensor_type(config[CONF_TYPE])) + await register_ratgdo_child(var, config) + + if config["type"] == "distance": + cg.add_library(name="Wire", version=None) + cg.add_library( + name="vl53l4cx", + repository="https://github.com/stm32duino/VL53L4CX", + version=None, + ) + cg.add_define("RATGDO_USE_DISTANCE_SENSOR") + subscribe_distance() diff --git a/components/ratgdo/sensor/ratgdo_sensor.cpp b/components/ratgdo/sensor/ratgdo_sensor.cpp new file mode 100644 index 0000000..d841b6c --- /dev/null +++ b/components/ratgdo/sensor/ratgdo_sensor.cpp @@ -0,0 +1,142 @@ +#include "ratgdo_sensor.h" +#include "../ratgdo_state.h" +#include "esphome/core/log.h" + +namespace esphome::ratgdo { + +static const char* const TAG = "ratgdo.sensor"; +static const int MIN_DISTANCE = 100; // ignore bugs crawling on the distance sensor & dust protection film +static const int MAX_DISTANCE = 4500; // default maximum distance + +void RATGDOSensor::setup() +{ + switch (this->ratgdo_sensor_type_) { + case RATGDOSensorType::RATGDO_OPENINGS: + this->parent_->subscribe_openings([this](uint16_t value) { + this->publish_state(value); + }); + break; + case RATGDOSensorType::RATGDO_PAIRED_DEVICES_TOTAL: + this->parent_->subscribe_paired_devices_total([this](uint8_t value) { + this->publish_state(value); + }); + break; + case RATGDOSensorType::RATGDO_PAIRED_REMOTES: + this->parent_->subscribe_paired_remotes([this](uint8_t value) { + this->publish_state(value); + }); + break; + case RATGDOSensorType::RATGDO_PAIRED_KEYPADS: + this->parent_->subscribe_paired_keypads([this](uint8_t value) { + this->publish_state(value); + }); + break; + case RATGDOSensorType::RATGDO_PAIRED_WALL_CONTROLS: + this->parent_->subscribe_paired_wall_controls([this](uint8_t value) { + this->publish_state(value); + }); + break; + case RATGDOSensorType::RATGDO_PAIRED_ACCESSORIES: + this->parent_->subscribe_paired_accessories([this](uint8_t value) { + this->publish_state(value); + }); + break; + case RATGDOSensorType::RATGDO_DISTANCE: +#ifdef RATGDO_USE_DISTANCE_SENSOR + this->distance_sensor_.setI2cDevice(&I2C); + this->distance_sensor_.setXShutPin(32); + // I2C.begin(17,16); + I2C.begin(19, 18); + this->distance_sensor_.begin(); + this->distance_sensor_.VL53L4CX_Off(); + this->distance_sensor_.InitSensor(0x59); + this->distance_sensor_.VL53L4CX_SetDistanceMode(VL53L4CX_DISTANCEMODE_LONG); + this->distance_sensor_.VL53L4CX_StartMeasurement(); +#ifdef RATGDO_USE_DISTANCE_SENSOR + this->parent_->subscribe_distance_measurement([this](int16_t value) { + this->publish_state(value); + }); +#endif +#endif + break; + default: + break; + } +} + +void RATGDOSensor::dump_config() +{ + LOG_SENSOR("", "RATGDO Sensor", this); + switch (this->ratgdo_sensor_type_) { + case RATGDOSensorType::RATGDO_OPENINGS: + ESP_LOGCONFIG(TAG, " Type: Openings"); + break; + case RATGDOSensorType::RATGDO_PAIRED_DEVICES_TOTAL: + ESP_LOGCONFIG(TAG, " Type: Paired Devices"); + break; + case RATGDOSensorType::RATGDO_PAIRED_REMOTES: + ESP_LOGCONFIG(TAG, " Type: Paired Remotes"); + break; + case RATGDOSensorType::RATGDO_PAIRED_KEYPADS: + ESP_LOGCONFIG(TAG, " Type: Paired Keypads"); + break; + case RATGDOSensorType::RATGDO_PAIRED_WALL_CONTROLS: + ESP_LOGCONFIG(TAG, " Type: Paired Wall Controls"); + break; + case RATGDOSensorType::RATGDO_PAIRED_ACCESSORIES: + ESP_LOGCONFIG(TAG, " Type: Paired Accessories"); + break; + case RATGDOSensorType::RATGDO_DISTANCE: + ESP_LOGCONFIG(TAG, " Type: Distance"); + break; + default: + break; + } +} + +#ifdef RATGDO_USE_DISTANCE_SENSOR +void RATGDOSensor::loop() +{ + if (this->ratgdo_sensor_type_ == RATGDOSensorType::RATGDO_DISTANCE) { + VL53L4CX_MultiRangingData_t distanceData; + VL53L4CX_MultiRangingData_t* pDistanceData = &distanceData; + uint8_t dataReady = 0; + int objCount = 0; + int16_t maxDistance = -1; + int status; + + if (this->distance_sensor_.VL53L4CX_GetMeasurementDataReady(&dataReady) == 0 && dataReady) { + status = this->distance_sensor_.VL53L4CX_GetMultiRangingData(pDistanceData); + objCount = pDistanceData->NumberOfObjectsFound; + + for (int i = 0; i < distanceData.NumberOfObjectsFound; i++) { + VL53L4CX_TargetRangeData_t* d = &pDistanceData->RangeData[i]; + if (d->RangeStatus == 0) { + maxDistance = std::max(maxDistance, d->RangeMilliMeter); + maxDistance = maxDistance <= MIN_DISTANCE ? -1 : maxDistance; + } + } + + if (maxDistance < 0) + maxDistance = MAX_DISTANCE; + + // maxDistance = objCount == 0 ? -1 : pDistanceData->RangeData[objCount - 1].RangeMilliMeter; + /* + * if the sensor is pointed at glass, there are many error -1 readings which will fill the + * vector with out of range data. The sensor should be sensitive enough to detect the floor + * in most situations, but daylight and/or really high ceilings can cause long distance + * measurements to be out of range. + */ + this->parent_->set_distance_measurement(maxDistance); + + // ESP_LOGD(TAG,"# obj found %d; distance %d",objCount, maxDistance); + + if (status == 0) { + status = this->distance_sensor_.VL53L4CX_ClearInterruptAndStartMeasurement(); + } + } + } +} +#endif + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/sensor/ratgdo_sensor.h b/components/ratgdo/sensor/ratgdo_sensor.h new file mode 100644 index 0000000..acfb14d --- /dev/null +++ b/components/ratgdo/sensor/ratgdo_sensor.h @@ -0,0 +1,44 @@ +#pragma once + +#include "../ratgdo.h" +#include "../ratgdo_state.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" + +#ifdef RATGDO_USE_DISTANCE_SENSOR +#include "Wire.h" +#include "vl53l4cx_class.h" +#define I2C Wire +#endif + +namespace esphome::ratgdo { + +enum RATGDOSensorType : uint8_t { + RATGDO_OPENINGS, + RATGDO_PAIRED_DEVICES_TOTAL, + RATGDO_PAIRED_REMOTES, + RATGDO_PAIRED_KEYPADS, + RATGDO_PAIRED_WALL_CONTROLS, + RATGDO_PAIRED_ACCESSORIES, + RATGDO_DISTANCE +}; + +class RATGDOSensor : public sensor::Sensor, public RATGDOClient, public Component { +public: + void dump_config() override; + void setup() override; +#ifdef RATGDO_USE_DISTANCE_SENSOR + void loop() override; +#endif + void set_ratgdo_sensor_type(RATGDOSensorType ratgdo_sensor_type_) { this->ratgdo_sensor_type_ = ratgdo_sensor_type_; } + +protected: + RATGDOSensorType ratgdo_sensor_type_; + +#ifdef RATGDO_USE_DISTANCE_SENSOR + VL53L4CX distance_sensor_; +#endif +}; + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/switch/__init__.py b/components/ratgdo/switch/__init__.py new file mode 100644 index 0000000..fef0a83 --- /dev/null +++ b/components/ratgdo/switch/__init__.py @@ -0,0 +1,48 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_PIN + +from .. import ( + RATGDO_CLIENT_SCHMEA, + ratgdo_ns, + register_ratgdo_child, + subscribe_vehicle_arriving, +) + +DEPENDENCIES = ["ratgdo"] + +RATGDOSwitch = ratgdo_ns.class_("RATGDOSwitch", switch.Switch, cg.Component) +SwitchType = ratgdo_ns.enum("SwitchType") + +CONF_TYPE = "type" +TYPES = {"learn": SwitchType.RATGDO_LEARN, "led": SwitchType.RATGDO_LED} + + +CONFIG_SCHEMA = ( + switch.switch_schema(RATGDOSwitch) + .extend( + { + cv.Required(CONF_TYPE): cv.enum(TYPES, lower=True), + cv.Optional(CONF_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(RATGDO_CLIENT_SCHMEA) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await switch.register_switch(var, config) + await cg.register_component(var, config) + cg.add(var.set_switch_type(config[CONF_TYPE])) + await register_ratgdo_child(var, config) + if CONF_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_PIN]) + cg.add(var.set_pin(pin)) + # LED switch conditionally subscribes to vehicle_arriving in C++ (#ifdef RATGDO_USE_VEHICLE_SENSORS). + # Always register the subscription — the C++ guard ensures it's only active when vehicle sensors + # are enabled, and the codegen emits the define to size the observable accordingly. + if config[CONF_TYPE] == "led": + subscribe_vehicle_arriving() diff --git a/components/ratgdo/switch/ratgdo_switch.cpp b/components/ratgdo/switch/ratgdo_switch.cpp new file mode 100644 index 0000000..7b1c48f --- /dev/null +++ b/components/ratgdo/switch/ratgdo_switch.cpp @@ -0,0 +1,64 @@ +#include "ratgdo_switch.h" +#include "../ratgdo_state.h" +#include "esphome/core/log.h" + +namespace esphome::ratgdo { + +static const char* const TAG = "ratgdo.switch"; + +void RATGDOSwitch::dump_config() +{ + LOG_SWITCH("", "RATGDO Switch", this); + switch (this->switch_type_) { + case SwitchType::RATGDO_LEARN: + ESP_LOGCONFIG(TAG, " Type: Learn"); + break; + case SwitchType::RATGDO_LED: + ESP_LOGCONFIG(TAG, " Type: LED"); + break; + default: + break; + } +} + +void RATGDOSwitch::setup() +{ + switch (this->switch_type_) { + case SwitchType::RATGDO_LEARN: + this->parent_->subscribe_learn_state([this](LearnState state) { + this->publish_state(state == LearnState::ACTIVE); + }); + break; + case SwitchType::RATGDO_LED: + this->pin_->setup(); +#ifdef RATGDO_USE_VEHICLE_SENSORS + this->parent_->subscribe_vehicle_arriving_state([this](VehicleArrivingState state) { + this->write_state(state == VehicleArrivingState::YES); + }); +#endif + break; + default: + break; + } +} + +void RATGDOSwitch::write_state(bool state) +{ + switch (this->switch_type_) { + case SwitchType::RATGDO_LEARN: + if (state) { + this->parent_->activate_learn(); + } else { + this->parent_->inactivate_learn(); + } + break; + case SwitchType::RATGDO_LED: + this->pin_->digital_write(state); + this->publish_state(state); + break; + default: + break; + } +} + +} // namespace esphome::ratgdo diff --git a/components/ratgdo/switch/ratgdo_switch.h b/components/ratgdo/switch/ratgdo_switch.h new file mode 100644 index 0000000..174731e --- /dev/null +++ b/components/ratgdo/switch/ratgdo_switch.h @@ -0,0 +1,30 @@ +#pragma once + +#include "../ratgdo.h" +#include "../ratgdo_state.h" +#include "esphome/components/switch/switch.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" + +namespace esphome::ratgdo { + +enum SwitchType { + RATGDO_LEARN, + RATGDO_LED +}; + +class RATGDOSwitch : public switch_::Switch, public RATGDOClient, public Component { +public: + void dump_config() override; + void setup() override; + void set_switch_type(SwitchType switch_type_) { this->switch_type_ = switch_type_; } + + void write_state(bool state) override; + void set_pin(GPIOPin* pin) { pin_ = pin; } + +protected: + SwitchType switch_type_; + GPIOPin* pin_; +}; + +} // namespace esphome::ratgdo diff --git a/garage-gate.yaml b/garage-gate.yaml index 8c1363f..2af0b6a 100644 --- a/garage-gate.yaml +++ b/garage-gate.yaml @@ -28,15 +28,14 @@ esp32: framework: type: arduino -dashboard_import: - package_import_url: github://ratgdo/esphome-ratgdo/static/v32disco.yaml@v1409 +external_components: + - source: + type: local + path: components + components: [ ratgdo ] packages: - remote_package: - url: https://github.com/ratgdo/esphome-ratgdo - ref: v1409 - files: [base.yaml] - refresh: 1s + ratgdo_base: !include templates/ratgdo_base.yaml # Sync time with Home Assistant. time: diff --git a/templates/ratgdo_base.yaml b/templates/ratgdo_base.yaml new file mode 100644 index 0000000..df4e533 --- /dev/null +++ b/templates/ratgdo_base.yaml @@ -0,0 +1,279 @@ +--- +esphome: + min_version: 2026.2.0 + +external_components: + - source: + type: local + path: components + components: [ ratgdo ] + +safe_mode: + +preferences: + flash_write_interval: 1min + +text_sensor: + - platform: version + id: ${id_prefix}_firmware_version + name: "Firmware Version" + +ratgdo: + id: ${id_prefix} + input_gdo_pin: ${uart_rx_pin} + output_gdo_pin: ${uart_tx_pin} + input_obst_pin: ${input_obst_pin} + on_sync_failed: + then: + - homeassistant.service: + service: persistent_notification.create + data: + title: "${friendly_name} sync failed" + message: "Failed to communicate with garage opener on startup." + notification_id: "esphome_ratgdo_${id_prefix}_sync_failed" +api: + services: + - service: wipe_devices_from_gdo_memory + variables: + devices_to_wipe: string + then: + - lambda: !lambda |- + id($id_prefix).clear_paired_devices(devices_to_wipe); + +ota: + - platform: esphome + id: ${id_prefix}_ratgdo_ota + - platform: web_server + id: ${id_prefix}_ratgdo_ota_ws + +sensor: + - platform: ratgdo + id: ${id_prefix}_openings + type: openings + entity_category: diagnostic + name: "Openings" + unit_of_measurement: "openings" + icon: mdi:open-in-app + - platform: ratgdo + id: ${id_prefix}_paired_devices_total + type: paired_devices_total + entity_category: diagnostic + name: "Paired Devices" + icon: mdi:remote + +lock: + - platform: ratgdo + id: ${id_prefix}_lock_remotes + name: "Lock remotes" + +switch: + - platform: gpio + id: "${id_prefix}_status_door" + internal: true + pin: + number: ${status_door_pin} # output door status, HIGH for open, LOW for closed + mode: + output: true + name: "Status door" + entity_category: diagnostic + - platform: gpio + id: "${id_prefix}_status_obstruction" + internal: true + pin: + number: ${status_obstruction_pin} # output for obstruction status, HIGH for obstructed, LOW for clear + mode: + output: true + name: "Status obstruction" + entity_category: diagnostic + - platform: ratgdo + id: "${id_prefix}_learn" + type: learn + name: "Learn" + icon: mdi:plus-box + entity_category: config + +binary_sensor: + - platform: ratgdo + type: motion + id: ${id_prefix}_motion + name: "Motion" + device_class: motion + - platform: ratgdo + type: obstruction + id: ${id_prefix}_obstruction + name: "Obstruction" + device_class: problem + on_press: + - switch.turn_on: ${id_prefix}_status_obstruction + on_release: + - switch.turn_off: ${id_prefix}_status_obstruction + - platform: ratgdo + type: button + id: ${id_prefix}_button + name: "Button" + entity_category: diagnostic + - platform: ratgdo + type: motor + id: ${id_prefix}_motor + name: "Motor" + device_class: running + entity_category: diagnostic + - platform: gpio + id: "${id_prefix}_dry_contact_open" + pin: + number: ${dry_contact_open_pin} # dry contact for opening door + inverted: true + mode: + input: true + pullup: true + name: "Dry contact open" + entity_category: diagnostic + filters: + - delayed_on_off: 500ms + on_press: + then: + script.execute: "${id_prefix}_dry_contact_door_control" + - platform: gpio + id: "${id_prefix}_dry_contact_close" + pin: + number: ${dry_contact_close_pin} # dry contact for closing door + inverted: true + mode: + input: true + pullup: true + name: "Dry contact close" + entity_category: diagnostic + filters: + - delayed_on_off: 500ms + on_press: + then: + script.execute: "${id_prefix}_dry_contact_door_control" + - platform: gpio + id: "${id_prefix}_dry_contact_light" + pin: + number: ${dry_contact_light_pin} # dry contact for triggering light (no discrete light commands, so toggle only) + inverted: true + mode: + input: true + pullup: true + name: "Dry contact light" + entity_category: diagnostic + filters: + - delayed_on_off: 500ms + on_press: + - light.toggle: ${id_prefix}_light + +script: + - id: "${id_prefix}_dry_contact_door_control" + then: + - delay: 0.1s + - if: + condition: + and: + - binary_sensor.is_on: ${id_prefix}_dry_contact_close + - binary_sensor.is_on: ${id_prefix}_dry_contact_open + then: + - cover.toggle: ${id_prefix}_garage_door + - if: + condition: + and: + - binary_sensor.is_off: ${id_prefix}_dry_contact_close + - binary_sensor.is_on: ${id_prefix}_dry_contact_open + then: + - cover.open: ${id_prefix}_garage_door + - if: + condition: + and: + - binary_sensor.is_on: ${id_prefix}_dry_contact_close + - binary_sensor.is_off: ${id_prefix}_dry_contact_open + then: + - cover.close: ${id_prefix}_garage_door + +number: + - platform: ratgdo + id: ${id_prefix}_rolling_code_counter + type: rolling_code_counter + entity_category: config + name: "Rolling code counter" + mode: box + unit_of_measurement: "codes" + + - platform: ratgdo + id: ${id_prefix}_opening_duration + type: opening_duration + entity_category: config + name: "Opening duration" + unit_of_measurement: "s" + + - platform: ratgdo + id: ${id_prefix}_closing_duration + type: closing_duration + entity_category: config + name: "Closing duration" + unit_of_measurement: "s" + + - platform: ratgdo + id: ${id_prefix}_client_id + type: client_id + entity_category: config + name: "Client ID" + mode: box + +cover: + - platform: ratgdo + id: ${id_prefix}_garage_door + device_class: garage + name: "Door" + on_closed: + - switch.turn_off: ${id_prefix}_status_door + on_opened: + - switch.turn_on: ${id_prefix}_status_door + +light: + - platform: ratgdo + id: ${id_prefix}_light + name: "Light" + +button: + - platform: restart + id: ${id_prefix}_restart + name: "Restart" + - platform: safe_mode + id: ${id_prefix}_safe_mode + name: "Safe mode boot" + entity_category: diagnostic + + - platform: template + id: ${id_prefix}_query_status + entity_category: diagnostic + name: "Query status" + on_press: + then: + lambda: !lambda |- + id($id_prefix).query_status(); + + - platform: template + id: ${id_prefix}_query_openings + name: "Query openings" + entity_category: diagnostic + on_press: + then: + lambda: !lambda |- + id($id_prefix).query_openings(); + + - platform: template + id: ${id_prefix}_sync + name: "Sync" + entity_category: diagnostic + on_press: + then: + lambda: !lambda |- + id($id_prefix).sync(); + + - platform: template + id: ${id_prefix}_toggle_door + name: "Toggle door" + on_press: + then: + lambda: !lambda |- + id($id_prefix).door_toggle();