This post is part of a series.

The keypad, or alarm panel, is an important part of a security alarm system. When I first got started building mine — I settled for a cheap and simple Zigbee keypad.

I’ve since replaced it with a better, and more advanced device. Let’s have a look…

This post describes the alarm panel logic as implemented at the time of writing. I am actively developing, so the implementation may change — check the repository for the latest version.

Code blocks have been simplified for clarity, while code links reference a specific commit and show the complete implementation.

Table of contents

Introduction

To provide some context; we first need to examine the alarm panel implementation. Let’s have a look at the Raspberry Pi Python script that is my security alarm system.

Alarm Panel class

AlarmPanel is a class — and all panel, hardware and software, are objects of this class:

class AlarmPanel:
    def __init__(self, topic: str, fields: dict[str, str], actions: dict[AlarmPanelAction, str],
                 label: str, set_states: dict[AlarmState, str] = None, timeout: int = 0):
        self.topic = topic
        self.fields = fields
        self.actions = actions
        self.label = label
        self.set_states = set_states or {}
        self.timeout = timeout
        self.timestamp = time.time()
        self.linkquality = []

    def __str__(self):
        return self.label

    def __repr__(self):
        return f"p:{self.label}"

    def set(self, alarm_state: AlarmState):
        if alarm_state not in self.set_states:
            return

        data = {"arm_mode": {"mode": self.set_states[alarm_state]}}
        mqtt_client.publish(f"{self.topic}/set", json.dumps(data), retain=False)

    def validate(self, transaction: str, alarm_action: AlarmPanelAction):
        if transaction is None or alarm_action not in self.actions:
            return

        data = {"arm_mode": {"transaction": int(transaction), "mode": self.actions[alarm_action]}}
        mqtt_client.publish(f"{self.topic}/set", json.dumps(data), retain=False)

It contains two methods: set and validate.

set changes the mode, or state, of a panel. Valid states are defined in the alarm panel object.

validate confirms the latest panel action back to the panel. To support this; the panel must send a transaction number together with the action. This number is returned to the panel, along with a confirmation, or denial, of the requested action.

If the following action is received from the panel:

{
    "action": "arm_all_zones",
    "action_code": "123",
    "action_zone": 23,
    "action_transaction": 99
}

A confirming response would be:

{
    "arm_mode": {
        "transaction": 99,
        "mode": "arm_all_zones"
    }
}

Receiving actions

When an MQTT message is received, the following code checks if it is from an alarm panel:

for key, panel in alarm_panels.items():
    if msg.topic == panel.topic and panel.fields["action"] in y:
        action = y[panel.fields["action"]]
        code = y.get(panel.fields["code"])
        code_str = str(code).lower()
        action_transaction = y.get("action_transaction")

        if code_str in codes:
            user = codes[code_str]
            logging.info("Panel action, %s: %s by %s (%s)", panel, action, user, action_transaction)

            if action == panel.actions[AlarmPanelAction.Disarm]:
                if state.system == "disarmed":
                    panel.validate(action_transaction, AlarmPanelAction.AlreadyDisarmed)
                else:
                    panel.validate(action_transaction, AlarmPanelAction.Disarm)
                    threading.Thread(target=disarmed, args=(user,)).start()

            elif action == panel.actions[AlarmPanelAction.ArmAway]:
                panel.validate(action_transaction, AlarmPanelAction.ArmAway)
                threading.Thread(target=arming, args=(user,)).start()

            elif action == panel.actions[AlarmPanelAction.ArmHome]:
                if any([o.get() for o in home_zones]):
                    panel.validate(action_transaction, AlarmPanelAction.NotReady)
                else:
                    panel.validate(action_transaction, AlarmPanelAction.ArmHome)
                    threading.Thread(target=armed_home, args=(user,)).start()

            else:
                logging.warning("Unknown action: %s, from alarm panel: %s", action, panel)

        elif code is not None:
            state.code_attempts += 1
            logging.warning("Invalid code: %s, attempt: %d", code, state.code_attempts)
            panel.validate(action_transaction, AlarmPanelAction.InvalidCode)
To get the Zigbee messages from the keypads — into my alarm system; I’m using Zigbee2MQTT.

If the MQTT message topic matches the topic of an alarm panel and contains an action; the code is matches against configured users. All users must have unique codes, as they are used to identify the user.

An invalid code will deny the panel action and return InvalidCode. Multiple failed codes will trigger a fault.

Trying to disarm when the state is already disarmed will also deny the panel action and return AlreadyDisarmed.

Same with trying to arm home while home zones are active — deny action and return NotReady. For arm away the logic is different; the system will enter arming mode, and evaluate the zone status once the exit delay has passed.

Changing state

A successful disarm, arm away, or arm home action will send a confirmation back to the panel and initiate the requested system action.

Each system state change will trigger the following code, sending state change messages to all alarm panels that supports it:

for panel in [v for k, v in alarm_panels.items() if v.set_states]:
    panel.set(AlarmState(alarm_state))

The keypads

Now we are getting to the main topic of this post — the keypads themselves.

Old keypad

Old keypad — Climax KP-23EL-ZBS-ACE
  • Type: Climax KP-23EL-ZBS-ACE
  • Exposes: battery_low, tamper, action, action_code
  • Actions: emergency, panic, disarm, arm_all_zones, arm_day_zones

Alarm panel definition (no longer in the code base):

 "climax": AlarmPanel(
     topic="zigbee2mqtt/Alarm panel",
     fields={"action": "action", "code": "action_code"},
     actions={
         AlarmPanelAction.Disarm: "disarm",
         AlarmPanelAction.ArmAway: "arm_all_zones",
         AlarmPanelAction.ArmHome: "arm_day_zones"
     },
     label="Climax"
 )

The sound signals produced by the keypad are:

  • long buzzer signal, arm
  • two short buffer signals, disarm
  • three short buzzer signals, arm home

The panel does not know if the action has been confirmed or denied, and thus provides no feedback of this. The sounds heard in the background is the response of the new keypad, and my mobile phone receiving notifications.

Video demonstration of the old keypad

New keypad

New keypad — Develco KEYZB-110
  • Type: Develco KEYZB-110
  • Exposes: battery_low, tamper, action_code, action_transaction, action_zone, battery, voltage, action
  • Actions: disarm, arm_day_zones, arm_night_zones, arm_all_zones, emergency
  • Modes (set states): disarm, arm_day_zones, arm_night_zones, arm_all_zones, exit_delay, entry_delay, not_ready, in_alarm, arming_stay, arming_night, arming_away, invalid_code, not_ready, already_disarmed
The specs above is a mix of the Zigbee2MQTT device documentation and my own experience. I have two panels, and have not gotten tamper to work on any of them.

Alarm panel definition:

"develco": AlarmPanel(
    topic="zigbee2mqtt/Panel entrance",
    fields={"action": "action", "code": "action_code"},
    actions={
        AlarmPanelAction.Disarm: "disarm",
        AlarmPanelAction.ArmAway: "arm_all_zones",
        AlarmPanelAction.ArmHome: "arm_day_zones",
        AlarmPanelAction.InvalidCode: "invalid_code",
        AlarmPanelAction.NotReady: "not_ready",
        AlarmPanelAction.AlreadyDisarmed: "not_ready"
    },
    label="Entrance alarm panel",
    set_states={
        AlarmState.Disarmed: "disarm",
        AlarmState.ArmedHome: "arm_day_zones",
        AlarmState.ArmedAway: "arm_all_zones",
        AlarmState.Triggered: "in_alarm",
        AlarmState.Pending: "entry_delay",
        AlarmState.Arming: "exit_delay"
    }
)

In contrast to the old keypad — we can observe that changes in the alarm state affects the panel:

  • red LED; armed, or arming
  • green LED; disarmed
  • yellow LED; invalid

This provides a much better user experience, and communicates clearly to the user what is happening and changes in the alarm state.

The notification sounds in the background is my mobile phone receiving push notifications on changing alarm state, or wrong PIN entered.

When using RFID; a hex identifier, unique to the RFID chip, in the format if +00000000 is returned as the action code.

Video demonstration of the new keypad

Emergency from keypad

These keypads often have an emergency, or panic, input. It is handled like a regular sensor by the system, the following sensor definition listens for an emergency action from the panel:

"emergency1": Sensor(
    key="emergency1",
    topic="zigbee2mqtt/Panel entrance",
    field="action",
    value=SensorValue.Emergency,
    label="Emergency button entrance",
    dev_class=DevClass.Generic,
    arm_modes=[ArmMode.Direct]
)

Notice that ArmMode.Direct is listed as the arm mode; meaning the alarm will be triggered by this sensor regardless of the armed state of the system.

Home Assistant

I wrote earlier that software alarm panels are also objects of the AlarmPanel class — Home Assistant is one such software panel, and has the following definition:

"home_assistant": AlarmPanel(
    topic="home/rpi_alarm/set",
    fields={"action": "action", "code": "code"},
    actions={
        AlarmPanelAction.Disarm: "DISARM",
        AlarmPanelAction.ArmAway: "ARM_AWAY",
        AlarmPanelAction.ArmHome: "ARM_HOME"
    },
    label="Home Assistant"
)

This communicates with the Home Assistant MQTT Alarm control panel integration.

Wrapping it up

It’s hard to define what is sufficient, and what is too deep when it comes to these kinds of posts. Which is probably the main reason for my procrastination in writing them.

The alarm logic is pretty complex, and consists of many pieces — some independent and some relying on others. The trick is finding the right balance; splitting it up into logical pieces, which can be communicated and understood.

The best is the mortal enemy of the good — Wikipedia

This post has been in my drafts folder for almost two years… Finally publishing it is very rewarding, but at the same time there is this sense of never going deep enough.

I do hope this is useful to anyone, if anything is missing or unclear; let me know.

🖖

DIY security alarm series
All posts in DIY security alarm series
  1. Making a manual security alarm in Home Assistant
  2. Raspberry Pi security alarm — the basics
  3. A short update on my Raspberry Pi security alarm project
  4. New keypad for my RPi DIY security alarm