Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ __pycache__/

# deployment secrets
/secrets.yaml

# uv / Python virtual environment
.venv/
uv.lock
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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 |
Comment on lines +7 to +9

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Markdown table has an extra leading | on the header/separator rows (|| ...), which prevents the table from rendering correctly. Replace || with a single | on those lines.

Copilot uses AI. Check for mistakes.

@dan-s-github dan-s-github Apr 1, 2026 •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot I don't see an extra leading |

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — the table in README.md is correctly formatted with single | delimiters. The reviewer comment was incorrect; no change is needed here.


## 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]
```
25 changes: 25 additions & 0 deletions components/crow_alarm_panel/README.md
Original file line number Diff line number Diff line change
@@ -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()"
Comment thread
dan-s-github marked this conversation as resolved.
Outdated
```
70 changes: 70 additions & 0 deletions components/crow_alarm_panel/__init__.py
Original file line number Diff line number Diff line change
@@ -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),
}
Comment thread
dan-s-github marked this conversation as resolved.
)
),
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],
)
39 changes: 39 additions & 0 deletions components/crow_alarm_panel/alarm_control_panel/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 41 additions & 0 deletions components/crow_alarm_panel/binary_sensor/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
dan-s-github marked this conversation as resolved.
Outdated
}
).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]))
37 changes: 37 additions & 0 deletions components/crow_alarm_panel/button/__init__.py
Original file line number Diff line number Diff line change
@@ -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)


Comment thread
dan-s-github marked this conversation as resolved.
Outdated
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]))
50 changes: 50 additions & 0 deletions components/crow_alarm_panel/button/crow_alarm_panel_button.h
Original file line number Diff line number Diff line change
@@ -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") {
Comment on lines +11 to +25

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parent_ is never initialized and press_action() dereferences it without a null check. If codegen ever fails to set the parent (or initialization order changes), this can crash. Initialize parent_{nullptr} and guard against null before use (log an error and return).

Copilot uses AI. Check for mistakes.
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
63 changes: 63 additions & 0 deletions components/crow_alarm_panel/crow_alarm_control_panel.cpp
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading