Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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()"
```
69 changes: 69 additions & 0 deletions components/crow_alarm_panel/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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.Required(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))

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.Required(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]))
49 changes: 49 additions & 0 deletions components/crow_alarm_panel/button/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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"


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])

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
Loading
Loading