From 46b4b6accf0bf69f0a6cecfb59648c29cccff118 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:33:58 +0000 Subject: [PATCH 1/5] Add components/crow_alarm_panel, pyproject.toml, test YAML, and update docs Agent-Logs-Url: https://github.com/dan-s-github/esphome-components/sessions/f8015637-c53d-44c0-b32f-2fa463f91642 Co-authored-by: dan-s-github <20974454+dan-s-github@users.noreply.github.com> --- .gitignore | 4 + README.md | 50 +- components/crow_alarm_panel/README.md | 25 + components/crow_alarm_panel/__init__.py | 70 +++ .../alarm_control_panel/__init__.py | 39 ++ .../binary_sensor/__init__.py | 41 ++ .../crow_alarm_panel/button/__init__.py | 37 ++ .../button/crow_alarm_panel_button.h | 50 ++ .../crow_alarm_control_panel.cpp | 63 ++ .../crow_alarm_panel/crow_alarm_panel.cpp | 583 ++++++++++++++++++ .../crow_alarm_panel/crow_alarm_panel.h | 237 +++++++ .../crow_alarm_panel/switch/__init__.py | 45 ++ .../switch/crow_alarm_panel_switch.cpp | 24 + .../switch/crow_alarm_panel_switch.h | 33 + .../crow_alarm_panel/text_sensor/__init__.py | 30 + crow_alarm_panel_test.yaml | 79 +++ pyproject.toml | 15 + 17 files changed, 1424 insertions(+), 1 deletion(-) create mode 100644 components/crow_alarm_panel/README.md create mode 100644 components/crow_alarm_panel/__init__.py create mode 100644 components/crow_alarm_panel/alarm_control_panel/__init__.py create mode 100644 components/crow_alarm_panel/binary_sensor/__init__.py create mode 100644 components/crow_alarm_panel/button/__init__.py create mode 100644 components/crow_alarm_panel/button/crow_alarm_panel_button.h create mode 100644 components/crow_alarm_panel/crow_alarm_control_panel.cpp create mode 100644 components/crow_alarm_panel/crow_alarm_panel.cpp create mode 100644 components/crow_alarm_panel/crow_alarm_panel.h create mode 100644 components/crow_alarm_panel/switch/__init__.py create mode 100644 components/crow_alarm_panel/switch/crow_alarm_panel_switch.cpp create mode 100644 components/crow_alarm_panel/switch/crow_alarm_panel_switch.h create mode 100644 components/crow_alarm_panel/text_sensor/__init__.py create mode 100644 crow_alarm_panel_test.yaml create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index 870da92..ee73829 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ __pycache__/ # deployment secrets /secrets.yaml + +# uv / Python virtual environment +.venv/ +uv.lock diff --git a/README.md b/README.md index 7edc1a1..4377e49 100644 --- a/README.md +++ b/README.md @@ -1 +1,49 @@ -# my esphome-components +# esphome-components + +Custom ESPHome components. The `components/` directory contains components that can be used with the ESPHome `external_components` feature. + +## Components + +| Component | Description | +|-----------|-------------| +| [crow_alarm_panel](components/crow_alarm_panel/README.md) | Integration for Arrowhead Crow alarm panels via the keypad bus | + +## Local development with uv + +[uv](https://docs.astral.sh/uv/) is used to manage the Python build environment so that components can be compiled and tested locally. + +### Setup + +1. Install uv: https://docs.astral.sh/uv/getting-started/installation/ +2. Create and activate a virtual environment: + ```bash + uv sync + source .venv/bin/activate # Linux/macOS + # or + .venv\Scripts\activate # Windows + ``` + +### Validate / compile a component + +A sample test configuration is provided at [`crow_alarm_panel_test.yaml`](crow_alarm_panel_test.yaml). Create a `secrets.yaml` file with your credentials (see ESPHome docs), then run: + +```bash +# Validate the configuration +esphome config crow_alarm_panel_test.yaml + +# Compile the firmware +esphome compile crow_alarm_panel_test.yaml +``` + +### Using components in your own ESPHome configuration + +Reference the `components/` directory via `external_components`: + +```yaml +external_components: + - source: + type: git + url: https://github.com/dan-s-github/esphome-components + ref: main + components: [crow_alarm_panel] +``` diff --git a/components/crow_alarm_panel/README.md b/components/crow_alarm_panel/README.md new file mode 100644 index 0000000..1aad4cf --- /dev/null +++ b/components/crow_alarm_panel/README.md @@ -0,0 +1,25 @@ +# Crow Alarm Panel Component + + +This component allows reading and decoding most messages sent on the `keypad bus` of an Arrowhead Crow Alarm Panel. + +It requires 2 wires to the panel, `data` and `clock`. These are usually marked `DAT` and `CLK`. + + +## Example YAML + +This example will just log every message it sees on the keypad bus. + +```yaml +crow_alarm_panel: + clock_pin: REPLACEME + data_pin: REPLACEME + address: 8 + + on_message: + - logger.log: + format: "%02x - %s" + args: + - "type" + - "format_hex_pretty(data).c_str()" +``` diff --git a/components/crow_alarm_panel/__init__.py b/components/crow_alarm_panel/__init__.py new file mode 100644 index 0000000..ebd2673 --- /dev/null +++ b/components/crow_alarm_panel/__init__.py @@ -0,0 +1,70 @@ +from esphome import pins, automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import text_sensor, switch, alarm_control_panel as acp +from esphome.const import ( + CONF_ADDRESS, + CONF_ID, + CONF_CLOCK_PIN, + CONF_DATA_PIN, + CONF_NAME, + CONF_OUTPUTS, +) + +AUTO_LOAD = ["binary_sensor", "text_sensor", "switch", "button", "alarm_control_panel"] +MULTI_CONF = True + +CONF_ARMED_STATE = "armed_state" +CONF_CROW_ALARM_PANEL_ID = "crow_alarm_panel_id" +CONF_NUM_ZONES = "number_of_zones" +CONF_KEYPADS = "keypads" +CONF_ON_MESSAGE = "on_message" + +crow_alarm_panel_ns = cg.esphome_ns.namespace("crow_alarm_panel") + +CrowAlarmPanel = crow_alarm_panel_ns.class_("CrowAlarmPanel", cg.Component) +CrowAlarmControlPanel = crow_alarm_panel_ns.class_( + "CrowAlarmControlPanel", acp.AlarmControlPanel, cg.Component +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(CrowAlarmPanel), + cv.Required(CONF_CLOCK_PIN): pins.internal_gpio_input_pin_schema, + cv.Required(CONF_DATA_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_ADDRESS): cv.int_range(min=0, max=8), + cv.Optional(CONF_KEYPADS, default=[]): cv.ensure_list( + cv.Schema( + { + cv.Required(CONF_NAME): cv.string, + cv.Required(CONF_ADDRESS): cv.int_range(min=0, max=8), + } + ) + ), + cv.Optional(CONF_ON_MESSAGE): automation.validate_automation(single=True), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + clock_pin = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) + cg.add(var.set_clock_pin(clock_pin)) + + data_pin = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) + cg.add(var.set_data_pin(data_pin)) + + if CONF_ADDRESS in config: + cg.add(var.set_keypad_address(config[CONF_ADDRESS])) + + for keypad in config[CONF_KEYPADS]: + cg.add(var.add_keypad(keypad[CONF_NAME], keypad[CONF_ADDRESS])) + + if CONF_ON_MESSAGE in config: + await automation.build_automation( + var.get_on_message_trigger(), + [(cg.uint8, "type"), (cg.std_vector.template(cg.uint8), "data")], + config[CONF_ON_MESSAGE], + ) diff --git a/components/crow_alarm_panel/alarm_control_panel/__init__.py b/components/crow_alarm_panel/alarm_control_panel/__init__.py new file mode 100644 index 0000000..5d7acd9 --- /dev/null +++ b/components/crow_alarm_panel/alarm_control_panel/__init__.py @@ -0,0 +1,39 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import alarm_control_panel as acp +from esphome.const import CONF_CODE, CONF_ID +from .. import CrowAlarmPanel, CrowAlarmControlPanel, CONF_CROW_ALARM_PANEL_ID + +DEPENDENCIES = ["crow_alarm_panel"] + +CONF_REQUIRE_CODE_TO_ARM = "requires_code_to_arm" +CONF_REQUIRE_CODE = "requires_code" + +CONFIG_SCHEMA = ( + acp.alarm_control_panel_schema(CrowAlarmControlPanel) + .extend( + { + cv.GenerateID(): cv.declare_id(CrowAlarmControlPanel), + cv.GenerateID(CONF_CROW_ALARM_PANEL_ID): cv.use_id(CrowAlarmPanel), + cv.Optional(CONF_CODE): cv.string, + cv.Optional(CONF_REQUIRE_CODE_TO_ARM, default=False): cv.boolean, + cv.Optional(CONF_REQUIRE_CODE, default=True): cv.boolean, + } + ) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + parent = await cg.get_variable(config[CONF_CROW_ALARM_PANEL_ID]) + var = await acp.new_alarm_control_panel(config) + + cg.add(var.set_parent(parent)) + cg.add(parent.register_alarm_control_panel(var)) + cg.add(var.set_requires_code(config[CONF_REQUIRE_CODE])) + cg.add(var.set_requires_code_to_arm(config[CONF_REQUIRE_CODE_TO_ARM])) + + if CONF_CODE in config: + cg.add(var.set_code(config[CONF_CODE])) + + await cg.register_component(var, config) diff --git a/components/crow_alarm_panel/binary_sensor/__init__.py b/components/crow_alarm_panel/binary_sensor/__init__.py new file mode 100644 index 0000000..58ed340 --- /dev/null +++ b/components/crow_alarm_panel/binary_sensor/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import binary_sensor +from esphome.const import CONF_ID, CONF_TYPE +from .. import CrowAlarmPanel, CONF_CROW_ALARM_PANEL_ID + +DEPENDENCIES = ["crow_alarm_panel"] + +binary_sensor_ns = cg.esphome_ns.namespace("binary_sensor") +BinarySensor = binary_sensor_ns.class_("BinarySensor", cg.EntityBase) + +CONF_ZONE = "zone" +CONF_BYPASS = "bypass" + +ZONE_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(): cv.declare_id(BinarySensor), + cv.GenerateID(CONF_CROW_ALARM_PANEL_ID): cv.use_id(CrowAlarmPanel), + cv.Optional(CONF_ZONE): cv.positive_int, + } +).extend(cv.COMPONENT_SCHEMA) + +CONFIG_SCHEMA = cv.typed_schema( + { + CONF_ZONE: ZONE_SCHEMA, + CONF_BYPASS: ZONE_SCHEMA, + } +) + + +def to_code(config): + paren = yield cg.get_variable(config[CONF_CROW_ALARM_PANEL_ID]) + type = config[CONF_TYPE] + var = cg.new_Pvariable(config[CONF_ID]) + + yield binary_sensor.register_binary_sensor(var, config) + + if type == "zone": + cg.add(paren.register_zone(var, config[CONF_ZONE])) + elif type == "bypass": + cg.add(paren.register_zone_bypass(var, config[CONF_ZONE])) diff --git a/components/crow_alarm_panel/button/__init__.py b/components/crow_alarm_panel/button/__init__.py new file mode 100644 index 0000000..516ddb0 --- /dev/null +++ b/components/crow_alarm_panel/button/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import button +from esphome.const import CONF_ID, CONF_TYPE +from .. import crow_alarm_panel_ns, CrowAlarmPanel, CONF_CROW_ALARM_PANEL_ID + +DEPENDENCIES = ["crow_alarm_panel"] + +CrowAlarmPanelButton = crow_alarm_panel_ns.class_( + "CrowAlarmPanelButton", button.Button, cg.Component +) + +TYPES = ["arm_away", "arm_stay", "disarm"] + +CONF_CODE = "code" + +CONFIG_SCHEMA = button.button_schema(CrowAlarmPanelButton).extend( + { + cv.GenerateID(CONF_CROW_ALARM_PANEL_ID): cv.use_id(CrowAlarmPanel), + cv.Required(CONF_TYPE): cv.one_of(*TYPES, lower=True), + cv.Optional(CONF_CODE): cv.string, # Only needed for disarm + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + paren = await cg.get_variable(config[CONF_CROW_ALARM_PANEL_ID]) + var = cg.new_Pvariable(config[CONF_ID]) + + await button.register_button(var, config) + await cg.register_component(var, config) + + cg.add(var.set_parent(paren)) + cg.add(var.set_button_type(config[CONF_TYPE])) + + if CONF_CODE in config: + cg.add(var.set_code(config[CONF_CODE])) diff --git a/components/crow_alarm_panel/button/crow_alarm_panel_button.h b/components/crow_alarm_panel/button/crow_alarm_panel_button.h new file mode 100644 index 0000000..7369409 --- /dev/null +++ b/components/crow_alarm_panel/button/crow_alarm_panel_button.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "../crow_alarm_panel.h" + +namespace esphome { +namespace crow_alarm_panel { + +class CrowAlarmPanelButton : public button::Button, public Component { + public: + void set_parent(CrowAlarmPanel *parent) { this->parent_ = parent; } + void set_button_type(const std::string &type) { this->button_type_ = type; } + void set_code(const std::string &code) { this->code_ = code; } + + protected: + void press_action() override { + if (this->button_type_ == "arm_away") { + if (this->parent_->is_arm_in_progress()) { + ESP_LOGW("crow_alarm_panel.button", "Arm operation already in progress, ignoring button press"); + return; + } + this->parent_->arm_away(); + } else if (this->button_type_ == "arm_stay") { + if (this->parent_->is_arm_in_progress()) { + ESP_LOGW("crow_alarm_panel.button", "Arm operation already in progress, ignoring button press"); + return; + } + this->parent_->arm_stay(); + } else if (this->button_type_ == "disarm") { + if (!this->parent_->is_armed()) { + ESP_LOGW("crow_alarm_panel.button", "Cannot disarm - alarm is not armed"); + return; + } + if (this->parent_->is_disarm_in_progress()) { + ESP_LOGW("crow_alarm_panel.button", "Disarm already in progress, ignoring button press"); + return; + } + this->parent_->disarm(this->code_); + } + } + + CrowAlarmPanel *parent_; + std::string button_type_; + std::string code_; +}; + +} // namespace crow_alarm_panel +} // namespace esphome diff --git a/components/crow_alarm_panel/crow_alarm_control_panel.cpp b/components/crow_alarm_panel/crow_alarm_control_panel.cpp new file mode 100644 index 0000000..9286802 --- /dev/null +++ b/components/crow_alarm_panel/crow_alarm_control_panel.cpp @@ -0,0 +1,63 @@ +#include "crow_alarm_panel.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace crow_alarm_panel { + +static const char *TAG_ACP = "crow_alarm_panel.acp"; + +void CrowAlarmControlPanel::dump_config() { ESP_LOGCONFIG(TAG_ACP, "Crow Alarm Control Panel"); } + +void CrowAlarmControlPanel::control(const alarm_control_panel::AlarmControlPanelCall &call) { + if (this->parent_ == nullptr) { + ESP_LOGE(TAG_ACP, "Parent not set, ignoring control call"); + return; + } + + const auto target_state = call.get_state(); + if (!target_state.has_value()) { + ESP_LOGW(TAG_ACP, "No target state in control call"); + return; + } + + switch (*target_state) { + case alarm_control_panel::ACP_STATE_ARMED_AWAY: + if (this->requires_code_to_arm_ && !call.get_code().has_value() && this->code_.empty()) { + this->status_momentary_warning("Code required to arm", 2000); + return; + } + this->parent_->arm_away(); + this->publish_state(alarm_control_panel::ACP_STATE_ARMING); + break; + case alarm_control_panel::ACP_STATE_ARMED_HOME: + if (this->requires_code_to_arm_ && !call.get_code().has_value() && this->code_.empty()) { + this->status_momentary_warning("Code required to arm", 2000); + return; + } + this->parent_->arm_stay(); + this->publish_state(alarm_control_panel::ACP_STATE_ARMING); + break; + case alarm_control_panel::ACP_STATE_DISARMED: { + std::string code = this->code_; + if (call.get_code().has_value()) { + code = call.get_code().value(); + } + if (code.empty()) { + this->status_momentary_warning("Code required to disarm", 2000); + return; + } + this->parent_->disarm(code); + this->publish_state(alarm_control_panel::ACP_STATE_DISARMING); + break; + } + case alarm_control_panel::ACP_STATE_TRIGGERED: + case alarm_control_panel::ACP_STATE_PENDING: + this->publish_state(*target_state); + break; + default: + ESP_LOGW(TAG_ACP, "Unsupported alarm control panel request"); + break; + } +} +} // namespace crow_alarm_panel +} // namespace esphome diff --git a/components/crow_alarm_panel/crow_alarm_panel.cpp b/components/crow_alarm_panel/crow_alarm_panel.cpp new file mode 100644 index 0000000..0b03699 --- /dev/null +++ b/components/crow_alarm_panel/crow_alarm_panel.cpp @@ -0,0 +1,583 @@ +#include "crow_alarm_panel.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#define HIGH 1 +#define LOW 0 + +namespace esphome { +namespace crow_alarm_panel { + +static const char *TAG = "crow_alarm_panel"; + +void CrowAlarmPanelStore::setup(InternalGPIOPin *clock_pin, InternalGPIOPin *data_pin) { + clock_pin->setup(); + data_pin->setup(); + this->clock_pin_ = clock_pin->to_isr(); + this->data_pin_ = data_pin->to_isr(); + memset(this->buffer, 0, sizeof(this->buffer)); + memset(this->buffer2, 0, sizeof(this->buffer2)); + this->data_length = 0; + this->num_bits_ = 0; + this->boundary_buffer_ = 0; + clock_pin->attach_interrupt(CrowAlarmPanelStore::interrupt, this, gpio::INTERRUPT_FALLING_EDGE); +} + +std::string binary_indices(uint8_t byte) { + std::string str; + for (uint8_t i = 0; i < 8; i++) { + if ((byte >> i) & 0x01) { + if (str.length() > 0) { + str += ","; + } + str += to_string(i + 1); + } + } + return str; +} + +void IRAM_ATTR HOT CrowAlarmPanelStore::interrupt(CrowAlarmPanelStore *arg) { + uint32_t now = micros(); + arg->last_clock_time_ = now; // Track last clock edge for bus idle detection + + // Clock glitch filtering - ignore edges that are too close together + if (now - arg->prev_falling_edge_time_us_ < MIN_FALLING_EDGE_INTERVAL_US) { + return; // Glitch detected, ignore this edge + } + arg->prev_falling_edge_time_us_ = now; + + // Handle receive mode + bool data_bit = arg->data_pin_.digital_read(); + + if (!arg->data && data_bit) + return; + + arg->data = true; + + // Check for boundary + arg->boundary_buffer_ = (uint8_t) ((arg->boundary_buffer_ << 1) | data_bit); + + if (arg->inside_) { + uint8_t idx = arg->num_bits_ / 8; + arg->buffer[idx] = (arg->buffer[idx] >> 1) | ((data_bit ? 1 : 0) << 7); + arg->num_bits_++; + + if (arg->boundary_buffer_ == BOUNDARY) { + // Save data + memcpy(arg->buffer2, arg->buffer, arg->num_bits_ / 8); + arg->data_length = arg->num_bits_ / 8; + // Reset + memset(arg->buffer, 0, BUFFER_LENGTH); + arg->boundary_buffer_ = 0; + arg->inside_ = false; + arg->num_bits_ = 0; + arg->data = false; + return; + } else if (arg->num_bits_ >= BUFFER_LENGTH * 8) { + // Wrong side of boundary. + arg->inside_ = false; + arg->num_bits_ = 0; + memset(arg->buffer, 0, BUFFER_LENGTH); + arg->boundary_buffer_ = 0; + arg->data = false; // clear data flag on overflow + } + } + + if (arg->boundary_buffer_ == BOUNDARY) { + arg->inside_ = true; + return; + } +} + +void CrowAlarmPanel::setup() { + this->store_.setup(this->clock_pin_, this->data_pin_); + + // Ensure our configured keypad address is present for logging/lookup + bool have_self = false; + for (const auto &kp : this->keypads_) { + if (kp.address == this->keypad_address_) { + have_self = true; + break; + } + } + if (!have_self) { + this->keypads_.push_back(CrowAlarmPanelKeypad{ + .name = "Virtual Keypad", + .address = this->keypad_address_, + }); + } + + if (this->armed_state_ != nullptr) { + this->armed_state_->publish_state("disarmed"); + } + if (this->alarm_control_panel_ != nullptr) { + this->alarm_control_panel_->publish_state(alarm_control_panel::ACP_STATE_DISARMED); + } +}; + +void CrowAlarmPanel::dump_config() { + ESP_LOGCONFIG(TAG, "Crow Alarm Panel:"); + LOG_PIN(" Clock Pin: ", this->clock_pin_); + LOG_PIN(" Data Pin: ", this->data_pin_); +} + +CrowAlarmPanelKeypad CrowAlarmPanel::find_keypad_(uint8_t address) { + for (CrowAlarmPanelKeypad keypad : this->keypads_) { + if (keypad.address == address) { + return keypad; + } + } + return {}; +} + +void CrowAlarmPanel::loop() { + if (this->store_.data_length) { + if (this->store_.data_length < 2) { + ESP_LOGW(TAG, "Discarding short frame (%d bytes)", this->store_.data_length); + InterruptLock lock; + memset(this->store_.buffer2, 0, BUFFER_LENGTH); + this->store_.data_length = 0; + return; + } + + uint8_t type; + std::vector data; + { + InterruptLock lock; + type = this->store_.buffer2[0]; + data.insert(data.begin(), this->store_.buffer2 + 1, this->store_.buffer2 + this->store_.data_length - 1); + memset(this->store_.buffer2, 0, BUFFER_LENGTH); + this->store_.data_length = 0; + } + + ESP_LOGD(TAG, "Received data: [%02x.%s]", type, format_hex_pretty(data).c_str()); + + switch (type) { + case OUTPUT_STATE: + if (data.size() < 1) { + ESP_LOGW(TAG, "Output state too short, discarding"); + break; + } + ESP_LOGD(TAG, "Output state [%s]", format_hex_pretty(data).c_str()); + for (CrowAlarmPanelOutput output : this->outputs_) { + bool on = ((data[0] >> (output.number - 1)) & 0x01); + output.the_switch->publish_state(on); + } + break; + case ZONE_STATE: { + if (data.size() < 6) { + ESP_LOGW(TAG, "Zone state invalid length, discarding"); + return; + } + ESP_LOGD(TAG, "Zone state received [%s]", format_hex_pretty(data).c_str()); + bool clear = true; + for (CrowAlarmPanelZone zone : this->zones_) { + bool triggered = ((data[1] >> (zone.zone - 1)) & 0x01); + bool triggered_alarmed = ((data[2] >> (zone.zone - 1)) & 0x01); + bool bypassed = ((data[3] >> (zone.zone - 1)) & 0x01); + + if (zone.motion_binary_sensor != nullptr) { + zone.motion_binary_sensor->publish_state(triggered | triggered_alarmed); + } + if (zone.bypass_binary_sensor != nullptr) { + zone.bypass_binary_sensor->publish_state(bypassed); + } + + if (triggered) { + ESP_LOGD(TAG, "Zone %d active", zone.zone); + if (this->armed_state_ != nullptr && this->armed_state_->state != "arming") { + this->armed_state_->publish_state("disarmed"); // Assume disarmed if motion detected in this byte + } + if (this->alarm_control_panel_ != nullptr && + this->alarm_control_panel_->get_state() != alarm_control_panel::ACP_STATE_ARMING) { + this->alarm_control_panel_->publish_state(alarm_control_panel::ACP_STATE_DISARMED); + } + clear = false; + } + if (triggered_alarmed) { + if (this->armed_state_ != nullptr) { + this->armed_state_->publish_state("pending"); + } + if (this->alarm_control_panel_ != nullptr) { + this->alarm_control_panel_->publish_state(alarm_control_panel::ACP_STATE_PENDING); + } + ESP_LOGD(TAG, "Alarm pending from zone %d", zone.zone); + clear = false; + } + } + if (clear) { + ESP_LOGD(TAG, "All zones clear"); + } + break; + } + case ARMED_STATE: { + if (armed_state_ != nullptr) { + if (data[0] == 0x00 && data[1] == 0x01) { + this->armed_state_->publish_state("arming"); + ESP_LOGD(TAG, "Arming [%02x.%s]", type, format_hex_pretty(data).c_str()); + if (this->alarm_control_panel_ != nullptr) { + this->alarm_control_panel_->publish_state(alarm_control_panel::ACP_STATE_ARMING); + } + } else if (data[0] == 0x01 && data[1] == 0x00) { + this->armed_state_->publish_state("armed_away"); + ESP_LOGD(TAG, "Armed Away [%02x.%s]", type, format_hex_pretty(data).c_str()); + if (this->alarm_control_panel_ != nullptr) { + this->alarm_control_panel_->publish_state(alarm_control_panel::ACP_STATE_ARMED_AWAY); + } + } else if (data[0] == 0x00 && data[1] == 0x00) { + this->armed_state_->publish_state("disarmed"); + ESP_LOGD(TAG, "Disarmed [%02x.%s]", type, format_hex_pretty(data).c_str()); + if (this->alarm_control_panel_ != nullptr) { + this->alarm_control_panel_->publish_state(alarm_control_panel::ACP_STATE_DISARMED); + } + } else { + ESP_LOGD(TAG, "Armed state unknown [%02x.%s]", type, format_hex_pretty(data).c_str()); + } + } + break; + } + case KEYPRESS: { + if (data.size() < 2) { + ESP_LOGW(TAG, "Keypress too short, discarding"); + break; + } + uint8_t key = data[1]; + if (key >= sizeof(KEYS) / sizeof(KEYS[0])) { + ESP_LOGW(TAG, "Unknown key index %d, discarding", key); + break; + } + CrowAlarmPanelKeypad keypad = this->find_keypad_(data[0]); + ESP_LOGD(TAG, "Key %s (%d) pressed on %s [%02x.%s]", KEYS[key], key, keypad.name.c_str(), type, + format_hex_pretty(data).c_str()); + break; + } + case CURRENT_TIME: { + if (data.size() < 7) { + ESP_LOGW(TAG, "Current time too short, discarding"); + break; + } + if (data[0] == 0 || data[0] > 7) { + ESP_LOGW(TAG, "Current time has invalid day index %d", data[0]); + break; + } + const char *day_of_week = DAYS[data[0] - 1]; + uint8_t mins = data[2]; + uint8_t hour = data[1]; + if (mins >= 60) { + hour = hour + (mins / 60); + mins = mins % 60; + } + ESP_LOGV(TAG, "Date/Time %s 20%02d-%02d-%02d %02d:%02d:%02d", day_of_week, data[6], data[5], data[4], hour, + mins, data[3]); + // ESP_LOGD(TAG, "[%s]", format_hex_pretty(data).c_str()); + break; + } + case RESPONSE_TIME: + if (data.size() < 3) { + ESP_LOGW(TAG, "Response time too short, discarding"); + break; + } + ESP_LOGD(TAG, "Current time setting received [%d]", (data[1] << 8) | data[2]); + break; + case KEYPAD_COMMAND: { + if (data.empty()) { + ESP_LOGW(TAG, "Keypad command too short, discarding"); + break; + } + CrowAlarmPanelKeypad keypad = this->find_keypad_(data[0]); + ESP_LOGD(TAG, "%s command [%02x.%s]", keypad.name.c_str(), type, format_hex_pretty(data).c_str()); + break; + } + case KEYPAD_STATE: { + if (data.size() < 2) { + ESP_LOGW(TAG, "Keypad state too short, discarding"); + break; + } + CrowAlarmPanelKeypad keypad = this->find_keypad_(data[0]); + if (data[1] == 00) { + ESP_LOGD(TAG, "%s in normal state [%02x.%s]", keypad.name.c_str(), type, format_hex_pretty(data).c_str()); + } else if (data[1] == 02) { + ESP_LOGD(TAG, "%s in installer mode [%02x.%s]", keypad.name.c_str(), type, format_hex_pretty(data).c_str()); + } else if (data[1] == 03) { + if (data.size() < 3) { + ESP_LOGW(TAG, "Keypad programming state too short, discarding"); + break; + } + ESP_LOGD(TAG, "%s programming %d [%02x.%s]", keypad.name.c_str(), data[2], type, + format_hex_pretty(data).c_str()); + } else { + ESP_LOGD(TAG, "%s state unknown [%02x.%s]", keypad.name.c_str(), type, format_hex_pretty(data).c_str()); + } + break; + } + case SETTING_VALUE: { + if (data.size() < 5) { + ESP_LOGW(TAG, "Setting value too short, discarding"); + break; + } + ESP_LOGD(TAG, "Address %d-%d has options: %s [%02x.%s]", data[3], data[4], binary_indices(data[2]).c_str(), + type, format_hex_pretty(data).c_str()); + break; + } + case SETTING_VALUE2: { + if (data.size() < 4) { + ESP_LOGW(TAG, "Setting value 2 too short, discarding"); + break; + } + // CrowAlarmPanelKeypad keypad = this->find_keypad_(data[0]); + ESP_LOGD(TAG, "Address %d-%d has value %d [%02x.%s]", data[2], data[3], data[1], type, + format_hex_pretty(data).c_str()); + break; + } + case SETTING_VALUE3: { + if (data.size() < 5) { + ESP_LOGW(TAG, "Setting value 3 too short, discarding"); + break; + } + // CrowAlarmPanelKeypad keypad = this->find_keypad_(data[0]); + ESP_LOGD(TAG, "Address %d-%d has value %d [%02x.%s]", data[3], data[4], (data[1] << 8) | data[2], type, + format_hex_pretty(data).c_str()); + break; + } + case MEMORY_EVENT: { + if (data.size() < 2) { + ESP_LOGW(TAG, "Memory event too short, discarding"); + break; + } + // CrowAlarmPanelKeypad keypad = this->find_keypad_(data[0]); + ESP_LOGD(TAG, "Memory event #%d ", data[1]); + break; + } + default: + ESP_LOGD(TAG, "Unknown [%02x.%s]", type, format_hex_pretty(data).c_str()); + break; + } + this->on_message_trigger_->trigger(type, data); + } + + // Send queued keypresses without blocking the caller that enqueued them + if (!this->keypress_queue_.empty() && this->is_bus_idle_()) { + const uint32_t now_ms = millis(); + if (now_ms - this->last_keypress_sent_ms_ >= 500) { + uint8_t key = this->keypress_queue_.front(); + this->keypress_queue_.erase(this->keypress_queue_.begin()); + ESP_LOGD(TAG, "Sending queued keypress: %s (%d)", KEYS[key], key); + this->keypress(key); + this->last_keypress_sent_ms_ = now_ms; + + // Clear disarm flag when ENTER is sent during a disarm operation + if (key == KEY_ENTER && this->disarm_in_progress_) { + this->disarm_in_progress_ = false; + this->arm_in_progress_ = false; // Also clear arm flag since disarm completes any arm operation + ESP_LOGD(TAG, "Disarm sequence completed, clearing disarm and arm flags"); + } + + // Clear arm flag when ARM or STAY keys are sent during an arm operation + if ((key == KEY_ARM || key == KEY_STAY) && this->arm_in_progress_) { + this->arm_in_progress_ = false; + this->disarm_in_progress_ = false; // Also clear disarm flag since arm completes any disarm operation + ESP_LOGD(TAG, "Arm sequence completed, clearing arm and disarm flags"); + } + } + } + + // Timeout protection: Clear disarm flag if it's been too long (30 seconds) + if (this->disarm_in_progress_ && (millis() - this->disarm_started_ms_ > 30000)) { + ESP_LOGW(TAG, "Disarm timeout reached, clearing disarm flag"); + this->disarm_in_progress_ = false; + } + + // Timeout protection: Clear arm flag if it's been too long (30 seconds) + if (this->arm_in_progress_ && (millis() - this->arm_started_ms_ > 30000)) { + ESP_LOGW(TAG, "Arm timeout reached, clearing arm flag"); + this->arm_in_progress_ = false; + } +} + +bool CrowAlarmPanel::is_bus_idle_() { + // Check if we're currently inside a message + if (this->store_.inside_) { + ESP_LOGV(TAG, "Bus not idle: inside message boundary"); + return false; + } + + // Check if data line is pulled low (someone is transmitting) + // Data line pulls high when idle, so low means active transmission + if (!this->data_pin_->digital_read()) { + ESP_LOGV(TAG, "Bus not idle: data line is low (active transmission)"); + return false; + } + + // Check minimum interval since last transmission (anti-spam) + uint32_t now_ms = millis(); + uint32_t time_since_tx = now_ms - this->store_.last_transmission_time_; + + if (time_since_tx < CrowAlarmPanelStore::MIN_TX_INTERVAL_MS) { + ESP_LOGV(TAG, "Bus not idle: only %ums since last TX (need %ums)", time_since_tx, + CrowAlarmPanelStore::MIN_TX_INTERVAL_MS); + return false; + } + + ESP_LOGV(TAG, "Bus is idle: not inside message, data line high, %ums since last TX", time_since_tx); + return true; +} + +/** + * @brief Wait for the clock pin to reach the desired state, with timeout. + * @return true if the desired state was reached, false on timeout. + */ +bool CrowAlarmPanel::wait_for_clock_edge_(bool wait_for_state, uint32_t timeout_us) { + const uint32_t start = micros(); + while (this->clock_pin_->digital_read() != wait_for_state) { + if (micros() - start >= timeout_us) { + return false; + } + delayMicroseconds(10); // Yield to watchdog/WiFi + } + return true; +} + +/** + * @brief Send a packet over the Crow alarm panel bus, blocking until complete. + */ +void IRAM_ATTR CrowAlarmPanel::send_packet_blocking_(const std::vector &packet) { + InterruptLock lock; + + // Convert bytes to bits (LSB first) + bool bits[200]; + uint16_t bit_count = 0; + for (uint8_t byte : packet) { + for (uint8_t j = 0; j < 8; j++) { + bits[bit_count++] = (byte >> j) & 1; + } + } + + // Wait for a full clock cycle to hopefully synchronize better + if (!this->wait_for_clock_edge_(HIGH, CrowAlarmPanelStore::TX_START_TIMEOUT_US) || + !this->wait_for_clock_edge_(LOW, CrowAlarmPanelStore::TX_START_TIMEOUT_US) || + !this->wait_for_clock_edge_(HIGH, CrowAlarmPanelStore::TX_START_TIMEOUT_US)) { + // Release data pin back to input (idle high via panel pull-up) before bailing + this->data_pin_->pin_mode(gpio::FLAG_INPUT); + return; + } + + // Transmit each bit on clock falling edge + for (uint16_t i = 0; i < bit_count; i++) { + // Drive the next bit before the falling edge so the panel samples the correct value. + // Use open-drain style: pull low for 0, release (input) for 1 to avoid fighting the panel. + if (bits[i]) { + this->data_pin_->pin_mode(gpio::FLAG_INPUT); // release line for logic high + } else { + this->data_pin_->pin_mode(gpio::FLAG_OUTPUT); + this->data_pin_->digital_write(LOW); // actively pull low for 0 + } + + // Wait for falling edge to ensure the panel has sampled the bit + if (!this->wait_for_clock_edge_(LOW, CrowAlarmPanelStore::TX_BIT_TIMEOUT_US)) { + break; + } + + // Wait for rising edge before next bit. This prevents the line from releasing high too early + if (!this->wait_for_clock_edge_(HIGH, CrowAlarmPanelStore::TX_BIT_TIMEOUT_US)) { + break; + } + } + + // Release data pin back to input (idle high via panel pull-up) + this->data_pin_->pin_mode(gpio::FLAG_INPUT); + + // Track last transmission time + this->store_.last_transmission_time_ = millis(); +} + +void CrowAlarmPanel::arm_away() { + if (this->arm_in_progress_) { + ESP_LOGW(TAG, "Arm operation already in progress, ignoring new arm request"); + return; + } + + ESP_LOGD(TAG, "Arm away"); + this->arm_in_progress_ = true; + this->arm_started_ms_ = millis(); + this->keypress(KEY_ARM); +} + +void CrowAlarmPanel::arm_stay() { + if (this->arm_in_progress_) { + ESP_LOGW(TAG, "Arm operation already in progress, ignoring new arm request"); + return; + } + + ESP_LOGD(TAG, "Arm stay"); + this->arm_in_progress_ = true; + this->arm_started_ms_ = millis(); + this->keypress(KEY_STAY); +} + +void CrowAlarmPanel::disarm(const std::string &code) { + if (!this->is_armed()) { + ESP_LOGW(TAG, "Cannot disarm - alarm is not armed (current state: %s)", + this->armed_state_ ? this->armed_state_->state.c_str() : "unknown"); + return; + } + + if (this->disarm_in_progress_) { + ESP_LOGW(TAG, "Disarm already in progress, ignoring new disarm request"); + return; + } + + ESP_LOGD(TAG, "Disarm with code (queued)"); + this->disarm_in_progress_ = true; + this->disarm_started_ms_ = millis(); + for (char c : code) { + if (c >= '0' && c <= '9') { + this->keypress_queue_.push_back(c - '0'); + } + } + this->keypress_queue_.push_back(KEY_ENTER); +} + +void CrowAlarmPanel::set_output(uint8_t output, bool state) {} + +void CrowAlarmPanel::send_packet(uint8_t type, const std::vector &data) { + // Build packet with boundaries + std::vector packet; + packet.push_back(BOUNDARY); // Start boundary + packet.push_back(type); + packet.push_back(this->keypad_address_); + packet.insert(packet.end(), data.begin(), data.end()); + packet.push_back(BOUNDARY); // End boundary + packet.push_back(PACKET_COMPLETE_MARKER); // Explicit end marker + + // Check if bus is idle + while (!this->is_bus_idle_()) { + ESP_LOGI(TAG, "Waiting for bus to become idle before sending packet..."); + delay(10); // Each packet takes around 30ms to fully send + yield(); + } + + ESP_LOGI(TAG, "Sending packet: [%s]", format_hex_pretty(packet).c_str()); + + // Send it (blocks until complete) + this->send_packet_blocking_(packet); + + ESP_LOGI(TAG, "Packet sent"); +} + +void CrowAlarmPanel::keypress(uint8_t key) { + ESP_LOGD(TAG, "Keypress: %s (%d)", KEYS[key], key); + std::vector data = {key}; + this->send_packet(KEYPRESS, data); +} + +bool CrowAlarmPanel::is_armed() const { + if (this->armed_state_ != nullptr) { + return this->armed_state_->state != "disarmed"; + } + if (this->alarm_control_panel_ != nullptr) { + return this->alarm_control_panel_->get_state() != alarm_control_panel::ACP_STATE_DISARMED; + } + return false; +} +} // namespace crow_alarm_panel +} // namespace esphome diff --git a/components/crow_alarm_panel/crow_alarm_panel.h b/components/crow_alarm_panel/crow_alarm_panel.h new file mode 100644 index 0000000..8359784 --- /dev/null +++ b/components/crow_alarm_panel/crow_alarm_panel.h @@ -0,0 +1,237 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/switch/switch.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/alarm_control_panel/alarm_control_panel.h" +#include "esphome/components/alarm_control_panel/alarm_control_panel_state.h" + +#include +#include + +namespace esphome { +namespace crow_alarm_panel { + +static const uint8_t UNKNOWN = 0x10; +static const uint8_t UNKNOWN2 = 0x20; + +static const uint8_t ARMED_STATE = 0x11; +static const uint8_t ZONE_STATE = 0x12; +static const uint8_t KEYPAD_COMMAND = 0x14; // Beeps etc +static const uint8_t KEYPAD_STATE = 0x15; + +static const uint8_t SETTING_VALUE = 0x16; // for locking LEDs +static const uint8_t SETTING_VALUE2 = 0x17; // for flashing LEDs < 255 +static const uint8_t SETTING_VALUE3 = 0x18; // for flashing LEDs > 255 + +static const uint8_t MEMORY_EVENT = 0x20; + +static const uint8_t OUTPUT_STATE = 0x50; +static const uint8_t CURRENT_TIME = 0x54; +static const uint8_t CURRENT_TEMP = 0x23; // Unconfirmed, this is just my suspicion based on observed data +static const uint8_t BOUNDARY = 0x7E; +// static const uint8_t KEYPRESS = 0xD1; // This is from upstream, but doesn't get sent by arrowhead panels that I can see +static const uint8_t KEYPRESS = 0xA1; +static const uint8_t MEMORY_CLEAR = 0xD2; +static const uint8_t PACKET_COMPLETE_MARKER = 0xFE; // This gets added after the boundary for keypad packets - signaling end of packet? + +// Keys 0 - 9 are 0-9 +static const uint8_t KEY_MEMORY = 11; +static const uint8_t KEY_BYPASS = 15; +static const uint8_t KEY_PROGRAM = 16; +static const uint8_t KEY_ENTER = 17; +static const uint8_t KEY_STAY = 0x0E; // Untested +static const uint8_t KEY_ARM = 0x0D; + +static const uint8_t BUFFER_LENGTH = 20; + +static const char *KEYS[18] = {"0", "1", "2", "3", "4", "5", "6", "7", "8", + "9", "PANIC", "MEMORY", "CONTROL", "ARM", "STAY", "BYPASS", "PROGRAM", "ENTER"}; + +static const uint8_t RESPONSE_TIME = 0x19; + +static const char *DAYS[7] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}; + +struct CrowAlarmPanelMessage { + uint8_t type; + uint8_t data_buffer[BUFFER_LENGTH]; + uint8_t data_len; +}; + +struct CrowAlarmPanelKeypad { + std::string name; + uint8_t address; +}; + +// static const CrowAlarmPanelKeypad UNKNOWN_KEYPAD{ +// .address = 0xFF, +// .name = "Unknown" +// }; + +class CrowAlarmPanelStore { + public: + void setup(InternalGPIOPin *clock_pin, InternalGPIOPin *data_pin); + static void interrupt(CrowAlarmPanelStore *arg); + + uint8_t buffer[BUFFER_LENGTH]; + uint8_t buffer2[BUFFER_LENGTH]; + uint8_t data_length{0}; + + bool data{false}; + uint32_t last_clock_time_{0}; // Track last clock edge + uint32_t last_transmission_time_{0}; // Track last TX completion + uint32_t prev_falling_edge_time_us_{0}; // Track last falling edge for glitch filtering + + bool inside_{false}; + + protected: + ISRInternalGPIOPin clock_pin_; + ISRInternalGPIOPin data_pin_; + uint8_t num_bits_{0}; + uint8_t boundary_buffer_{0}; + + public: + static const uint32_t BUS_IDLE_TIMEOUT_US = 5000; // 5ms idle = bus free + static const uint32_t MIN_TX_INTERVAL_MS = 50; // Min 50ms between TX + /** + * Minimum interval between falling edges to avoid glitches. The clock runs at ~1.2kHz, + * so we expect ~833μs between edges. Setting this to 700μs filters out most glitches while + * still allowing normal operation. + */ + static const uint32_t MIN_FALLING_EDGE_INTERVAL_US = 700; // Min 700μs between falling edges + static const uint32_t TX_START_TIMEOUT_US = 100000; // 100ms to start TX + static const uint32_t TX_BIT_TIMEOUT_US = 10000; // 10ms between bits +}; + +struct CrowAlarmPanelZone { + binary_sensor::BinarySensor *motion_binary_sensor; + binary_sensor::BinarySensor *bypass_binary_sensor; + uint8_t zone; +}; + +struct CrowAlarmPanelOutput { + switch_::Switch *the_switch; + uint8_t number; +}; + +class CrowAlarmPanel : public Component { + public: + void setup() override; + void dump_config() override; + void loop() override; + + void set_output(uint8_t output, bool state); + + void set_clock_pin(InternalGPIOPin *clock) { this->clock_pin_ = clock; } + void set_data_pin(InternalGPIOPin *data) { this->data_pin_ = data; } + void set_keypad_address(uint8_t address) { this->keypad_address_ = address; } + void add_keypad(const std::string &name, uint8_t address) { + this->keypads_.push_back(std::move(CrowAlarmPanelKeypad{ + .name = name, + .address = address, + })); + } + + void register_zone(binary_sensor::BinarySensor *binary_sensor, uint8_t zone) { + for (auto &z : this->zones_) { + if (z.zone == zone) { + z.motion_binary_sensor = binary_sensor; + return; + } + } + this->zones_.push_back(CrowAlarmPanelZone{ + .motion_binary_sensor = binary_sensor, + .bypass_binary_sensor = nullptr, + .zone = zone, + }); + } + void register_zone_bypass(binary_sensor::BinarySensor *binary_sensor, uint8_t zone) { + for (auto &z : this->zones_) { + if (z.zone == zone) { + z.bypass_binary_sensor = binary_sensor; + return; + } + } + this->zones_.push_back(CrowAlarmPanelZone{ + .motion_binary_sensor = nullptr, + .bypass_binary_sensor = binary_sensor, + .zone = zone, + }); + } + + void register_armed_state(text_sensor::TextSensor *armed_state_sensor) { this->armed_state_ = armed_state_sensor; } + void register_output_switch(switch_::Switch *output_switch, uint8_t output_number) { + this->outputs_.push_back(std::move(CrowAlarmPanelOutput{ + .the_switch = output_switch, + .number = output_number, + })); + } + void register_alarm_control_panel(alarm_control_panel::AlarmControlPanel *acp) { this->alarm_control_panel_ = acp; } + + void arm_away(); + void arm_stay(); + void disarm(const std::string &code); + bool is_disarm_in_progress() const { return this->disarm_in_progress_; } + bool is_arm_in_progress() const { return this->arm_in_progress_; } + bool is_armed() const; + + Trigger> *get_on_message_trigger() const { return this->on_message_trigger_; } + + void send_packet(uint8_t type, const std::vector &data); + + void keypress(uint8_t key); + + protected: + CrowAlarmPanelKeypad find_keypad_(uint8_t address); + bool is_bus_idle_(); + + // Transmission methods (blocking) + void send_packet_blocking_(const std::vector &packet); + bool wait_for_clock_edge_(bool wait_for_high, uint32_t timeout_us); + std::vector keypress_queue_; + uint32_t last_keypress_sent_ms_{0}; + bool disarm_in_progress_{false}; + uint32_t disarm_started_ms_{0}; + bool arm_in_progress_{false}; + uint32_t arm_started_ms_{0}; + + CrowAlarmPanelStore store_; + InternalGPIOPin *clock_pin_; + InternalGPIOPin *data_pin_; + uint8_t keypad_address_; + text_sensor::TextSensor *armed_state_; + alarm_control_panel::AlarmControlPanel *alarm_control_panel_{nullptr}; + Trigger> *on_message_trigger_{new Trigger>()}; + + std::vector zones_; + std::vector keypads_; + std::vector outputs_; +}; + +class CrowAlarmControlPanel : public alarm_control_panel::AlarmControlPanel, public Component { + public: + void dump_config() override; + uint32_t get_supported_features() const override { + return alarm_control_panel::ACP_FEAT_ARM_AWAY | alarm_control_panel::ACP_FEAT_ARM_HOME; + } + bool get_requires_code() const override { return this->requires_code_; } + bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; } + + void set_parent(CrowAlarmPanel *parent) { this->parent_ = parent; } + void set_code(const std::string &code) { this->code_ = code; } + void set_requires_code(bool requires_code) { this->requires_code_ = requires_code; } + void set_requires_code_to_arm(bool requires_code_to_arm) { this->requires_code_to_arm_ = requires_code_to_arm; } + + protected: + void control(const alarm_control_panel::AlarmControlPanelCall &call) override; + + CrowAlarmPanel *parent_{nullptr}; + std::string code_; + bool requires_code_{true}; + bool requires_code_to_arm_{false}; +}; + +} // namespace crow_alarm_panel +} // namespace esphome diff --git a/components/crow_alarm_panel/switch/__init__.py b/components/crow_alarm_panel/switch/__init__.py new file mode 100644 index 0000000..197d3fd --- /dev/null +++ b/components/crow_alarm_panel/switch/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import switch +from esphome.const import CONF_ID, CONF_TYPE, CONF_OUTPUT +from .. import crow_alarm_panel_ns, CrowAlarmPanel, CONF_CROW_ALARM_PANEL_ID + +DEPENDENCIES = ["crow_alarm_panel"] + +CrowAlarmPanelSwitch = crow_alarm_panel_ns.class_( + "CrowAlarmPanelSwitch", switch.Switch, cg.Component +) +CrowAlarmPanelOutputSwitch = crow_alarm_panel_ns.class_( + "CrowAlarmPanelOutputSwitch", CrowAlarmPanelSwitch +) + + +CROW_SWITCH_SCHEMA = switch.switch_schema(CrowAlarmPanelSwitch).extend( + { + cv.GenerateID(CONF_CROW_ALARM_PANEL_ID): cv.use_id(CrowAlarmPanel), + } +).extend(cv.COMPONENT_SCHEMA) + + +CONFIG_SCHEMA = cv.typed_schema( + { + CONF_OUTPUT: CROW_SWITCH_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(CrowAlarmPanelOutputSwitch), + cv.Required(CONF_OUTPUT): cv.positive_int, + } + ), + } +) + + +def to_code(config): + paren = yield cg.get_variable(config[CONF_CROW_ALARM_PANEL_ID]) + type = config[CONF_TYPE] + if type == "output": + var = cg.new_Pvariable(config[CONF_ID]) + cg.add(var.set_output_number(config[CONF_OUTPUT])) + cg.add(paren.register_output_switch(var, config[CONF_OUTPUT])) + + yield switch.register_switch(var, config) + yield cg.register_component(var, config) diff --git a/components/crow_alarm_panel/switch/crow_alarm_panel_switch.cpp b/components/crow_alarm_panel/switch/crow_alarm_panel_switch.cpp new file mode 100644 index 0000000..f135202 --- /dev/null +++ b/components/crow_alarm_panel/switch/crow_alarm_panel_switch.cpp @@ -0,0 +1,24 @@ +#include "esphome/core/log.h" +#include "crow_alarm_panel_switch.h" + +namespace esphome { +namespace crow_alarm_panel { + +static const char *TAG = "crow_alarm_panel.switch"; + +void CrowAlarmPanelSwitch::dump_config() { + LOG_SWITCH("", "Crow Alarm Panel Switch", this); +} + +void CrowAlarmPanelOutputSwitch::write_state(bool state) { + this->parent_->set_output(this->output_number_, state); + this->publish_state(state); +} + +void CrowAlarmPanelOutputSwitch::dump_config() { + LOG_SWITCH("", "Crow Alarm Panel Output Switch", this); + ESP_LOGCONFIG(TAG, " Output number %d", this->output_number_); +} + +} // namespace crow_alarm_panel +} // namespace esphome diff --git a/components/crow_alarm_panel/switch/crow_alarm_panel_switch.h b/components/crow_alarm_panel/switch/crow_alarm_panel_switch.h new file mode 100644 index 0000000..0b9cbd1 --- /dev/null +++ b/components/crow_alarm_panel/switch/crow_alarm_panel_switch.h @@ -0,0 +1,33 @@ +#pragma once + +#include "esphome/core/component.h" +#include "../crow_alarm_panel.h" +#include "esphome/components/switch/switch.h" + +namespace esphome { +namespace crow_alarm_panel { + +class CrowAlarmPanelSwitch : public switch_::Switch, public Component { + public: + void dump_config() override; + + void set_crow_alarm_panel_parent(CrowAlarmPanel *parent) { this->parent_ = parent; } + + protected: + virtual void write_state(bool state) = 0; + + CrowAlarmPanel *parent_; +}; + +class CrowAlarmPanelOutputSwitch : public CrowAlarmPanelSwitch { + public: + void set_output_number(uint8_t output_number) { this->output_number_ = output_number; } + void dump_config() override; + protected: + void write_state(bool state) override; + + uint8_t output_number_; +}; + +} // namespace crow_alarm_panel +} // namespace esphome diff --git a/components/crow_alarm_panel/text_sensor/__init__.py b/components/crow_alarm_panel/text_sensor/__init__.py new file mode 100644 index 0000000..6eec0ad --- /dev/null +++ b/components/crow_alarm_panel/text_sensor/__init__.py @@ -0,0 +1,30 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import text_sensor +from esphome.const import CONF_ID, CONF_TYPE +from .. import CrowAlarmPanel, CONF_CROW_ALARM_PANEL_ID + +DEPENDENCIES = ["crow_alarm_panel"] + +text_sensor_ns = cg.esphome_ns.namespace("text_sensor") +TextSensor = text_sensor_ns.class_("TextSensor", cg.EntityBase) + +TYPES = ["armed_state"] + +CONFIG_SCHEMA = text_sensor.text_sensor_schema().extend( + { + cv.GenerateID(): cv.declare_id(TextSensor), + cv.GenerateID(CONF_CROW_ALARM_PANEL_ID): cv.use_id(CrowAlarmPanel), + cv.Required(CONF_TYPE): cv.one_of(*TYPES, lower=True), + } +).extend(cv.COMPONENT_SCHEMA) + + +def to_code(config): + paren = yield cg.get_variable(config[CONF_CROW_ALARM_PANEL_ID]) + var = cg.new_Pvariable(config[CONF_ID]) + + yield text_sensor.register_text_sensor(var, config) + + if config[CONF_TYPE] == "armed_state": + cg.add(paren.register_armed_state(var)) diff --git a/crow_alarm_panel_test.yaml b/crow_alarm_panel_test.yaml new file mode 100644 index 0000000..e146b6b --- /dev/null +++ b/crow_alarm_panel_test.yaml @@ -0,0 +1,79 @@ +# Sample configuration to validate/compile the crow_alarm_panel component. +# Replace pin numbers and other values to match your hardware. +# +# Usage: +# esphome config crow_alarm_panel_test.yaml # validate config +# esphome compile crow_alarm_panel_test.yaml # compile firmware + +esphome: + name: crow-alarm-panel-test + friendly_name: Crow Alarm Panel Test + +esp32: + board: esp32dev + framework: + type: arduino + +# Enable logging +logger: + +# Enable Home Assistant API +api: + encryption: + key: !secret api_encryption_key + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +external_components: + - source: + type: local + path: components + +crow_alarm_panel: + clock_pin: GPIO18 + data_pin: GPIO19 + address: 8 + on_message: + - logger.log: + format: "type=0x%02x data=%s" + args: + - "type" + - "format_hex_pretty(data).c_str()" + +text_sensor: + - platform: crow_alarm_panel + name: "Alarm Armed State" + type: armed_state + +binary_sensor: + - platform: crow_alarm_panel + name: "Zone 1" + type: zone + zone: 1 + + - platform: crow_alarm_panel + name: "Zone 1 Bypassed" + type: bypass + zone: 1 + +alarm_control_panel: + - platform: crow_alarm_panel + name: "Crow Alarm" + requires_code: true + requires_code_to_arm: false + +button: + - platform: crow_alarm_panel + name: "Arm Away" + type: arm_away + + - platform: crow_alarm_panel + name: "Arm Stay" + type: arm_stay + + - platform: crow_alarm_panel + name: "Disarm" + type: disarm + code: !secret alarm_code diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b9a9930 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "esphome-components" +version = "0.1.0" +description = "Custom ESPHome components" +requires-python = ">=3.11" +dependencies = [ + "esphome>=2026.3.0", +] + +[tool.uv] +dev-dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" From 790b8c1c39b233a00549b078ece74213119c2d4d Mon Sep 17 00:00:00 2001 From: Daniel M Date: Wed, 1 Apr 2026 20:13:03 +1300 Subject: [PATCH 2/5] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- components/crow_alarm_panel/README.md | 6 ++--- .../binary_sensor/__init__.py | 2 +- .../crow_alarm_panel/button/__init__.py | 26 ++++++++++++++----- .../crow_alarm_panel/switch/__init__.py | 2 +- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/components/crow_alarm_panel/README.md b/components/crow_alarm_panel/README.md index 1aad4cf..5898a86 100644 --- a/components/crow_alarm_panel/README.md +++ b/components/crow_alarm_panel/README.md @@ -19,7 +19,7 @@ crow_alarm_panel: on_message: - logger.log: format: "%02x - %s" - args: - - "type" - - "format_hex_pretty(data).c_str()" + args: + - "type" + - "format_hex_pretty(data).c_str()" ``` diff --git a/components/crow_alarm_panel/binary_sensor/__init__.py b/components/crow_alarm_panel/binary_sensor/__init__.py index 58ed340..840f9d1 100644 --- a/components/crow_alarm_panel/binary_sensor/__init__.py +++ b/components/crow_alarm_panel/binary_sensor/__init__.py @@ -16,7 +16,7 @@ { cv.GenerateID(): cv.declare_id(BinarySensor), cv.GenerateID(CONF_CROW_ALARM_PANEL_ID): cv.use_id(CrowAlarmPanel), - cv.Optional(CONF_ZONE): cv.positive_int, + cv.Required(CONF_ZONE): cv.positive_int, } ).extend(cv.COMPONENT_SCHEMA) diff --git a/components/crow_alarm_panel/button/__init__.py b/components/crow_alarm_panel/button/__init__.py index 516ddb0..d627a14 100644 --- a/components/crow_alarm_panel/button/__init__.py +++ b/components/crow_alarm_panel/button/__init__.py @@ -14,15 +14,27 @@ CONF_CODE = "code" -CONFIG_SCHEMA = button.button_schema(CrowAlarmPanelButton).extend( - { - cv.GenerateID(CONF_CROW_ALARM_PANEL_ID): cv.use_id(CrowAlarmPanel), - cv.Required(CONF_TYPE): cv.one_of(*TYPES, lower=True), - cv.Optional(CONF_CODE): cv.string, # Only needed for disarm - } -).extend(cv.COMPONENT_SCHEMA) +def _validate_disarm_code(value): + """Ensure that a code is provided for disarm buttons.""" + button_type = value.get(CONF_TYPE) + if button_type == "disarm": + code = value.get(CONF_CODE) + if not code: + raise cv.Invalid("For type 'disarm', a non-empty 'code' must be provided.") + return value + +CONFIG_SCHEMA = cv.All( + button.button_schema(CrowAlarmPanelButton).extend( + { + cv.GenerateID(CONF_CROW_ALARM_PANEL_ID): cv.use_id(CrowAlarmPanel), + cv.Required(CONF_TYPE): cv.one_of(*TYPES, lower=True), + cv.Optional(CONF_CODE): cv.string, # Only needed for disarm + } + ).extend(cv.COMPONENT_SCHEMA), + _validate_disarm_code, +) async def to_code(config): paren = await cg.get_variable(config[CONF_CROW_ALARM_PANEL_ID]) var = cg.new_Pvariable(config[CONF_ID]) diff --git a/components/crow_alarm_panel/switch/__init__.py b/components/crow_alarm_panel/switch/__init__.py index 197d3fd..bd49636 100644 --- a/components/crow_alarm_panel/switch/__init__.py +++ b/components/crow_alarm_panel/switch/__init__.py @@ -26,7 +26,7 @@ CONF_OUTPUT: CROW_SWITCH_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(CrowAlarmPanelOutputSwitch), - cv.Required(CONF_OUTPUT): cv.positive_int, + cv.Required(CONF_OUTPUT): cv.int_range(1, 8), } ), } From 5184502fcb72a210bca042f23f71e65f35630e80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:18:22 +0000 Subject: [PATCH 3/5] Make address required in crow_alarm_panel config schema Agent-Logs-Url: https://github.com/dan-s-github/esphome-components/sessions/f36df2a9-98b4-4f85-b1e8-5106e6e7aad2 Co-authored-by: dan-s-github <20974454+dan-s-github@users.noreply.github.com> --- components/crow_alarm_panel/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/components/crow_alarm_panel/__init__.py b/components/crow_alarm_panel/__init__.py index ebd2673..e688f73 100644 --- a/components/crow_alarm_panel/__init__.py +++ b/components/crow_alarm_panel/__init__.py @@ -32,7 +32,7 @@ cv.GenerateID(): cv.declare_id(CrowAlarmPanel), cv.Required(CONF_CLOCK_PIN): pins.internal_gpio_input_pin_schema, cv.Required(CONF_DATA_PIN): pins.internal_gpio_input_pin_schema, - cv.Optional(CONF_ADDRESS): cv.int_range(min=0, max=8), + cv.Required(CONF_ADDRESS): cv.int_range(min=0, max=8), cv.Optional(CONF_KEYPADS, default=[]): cv.ensure_list( cv.Schema( { @@ -56,8 +56,7 @@ async def to_code(config): data_pin = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) cg.add(var.set_data_pin(data_pin)) - if CONF_ADDRESS in config: - cg.add(var.set_keypad_address(config[CONF_ADDRESS])) + cg.add(var.set_keypad_address(config[CONF_ADDRESS])) for keypad in config[CONF_KEYPADS]: cg.add(var.add_keypad(keypad[CONF_NAME], keypad[CONF_ADDRESS])) From 68097cd839cf4b65442ded6dac4c2e668b3f28ef Mon Sep 17 00:00:00 2001 From: Daniel M Date: Wed, 1 Apr 2026 20:30:18 +1300 Subject: [PATCH 4/5] Update components/crow_alarm_panel/crow_alarm_panel.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- components/crow_alarm_panel/crow_alarm_panel.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/crow_alarm_panel/crow_alarm_panel.cpp b/components/crow_alarm_panel/crow_alarm_panel.cpp index 0b03699..a765a4f 100644 --- a/components/crow_alarm_panel/crow_alarm_panel.cpp +++ b/components/crow_alarm_panel/crow_alarm_panel.cpp @@ -167,7 +167,7 @@ void CrowAlarmPanel::loop() { case ZONE_STATE: { if (data.size() < 6) { ESP_LOGW(TAG, "Zone state invalid length, discarding"); - return; + break; } ESP_LOGD(TAG, "Zone state received [%s]", format_hex_pretty(data).c_str()); bool clear = true; From 5cae8790f3e133e454c5623f4a1b3fd650a6f111 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:30:55 +0000 Subject: [PATCH 5/5] Add data size guard in ARMED_STATE handler to prevent out-of-bounds read Agent-Logs-Url: https://github.com/dan-s-github/esphome-components/sessions/789e0231-e4fd-49b3-aeab-47f24a93a9d2 Co-authored-by: dan-s-github <20974454+dan-s-github@users.noreply.github.com> --- components/crow_alarm_panel/crow_alarm_panel.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/crow_alarm_panel/crow_alarm_panel.cpp b/components/crow_alarm_panel/crow_alarm_panel.cpp index a765a4f..ca4f44a 100644 --- a/components/crow_alarm_panel/crow_alarm_panel.cpp +++ b/components/crow_alarm_panel/crow_alarm_panel.cpp @@ -211,6 +211,10 @@ void CrowAlarmPanel::loop() { break; } case ARMED_STATE: { + if (data.size() < 2) { + ESP_LOGW(TAG, "Armed state invalid length, discarding"); + break; + } if (armed_state_ != nullptr) { if (data[0] == 0x00 && data[1] == 0x01) { this->armed_state_->publish_state("arming");