Project: Crow/AAP Alarm IP Module for Home Assistant
Integration domain: crowipmodule
Branch: master · Version: 2.2.0 · Status: released
Last updated: 2026-10-14
This document specifies how the integration is built: modules, signatures, data shapes, protocol tables and per-entity contracts. Read the ACD first for the architectural rationale; the diagrams live in WORKFLOWS.md.
| Module | Responsibility | Depends on |
|---|---|---|
__init__.py |
Entry setup/teardown, callback marshalling, hub device registration, config-entry migration | const, device, pycrowipmodule, homeassistant.* |
device.py |
CrowRuntimeData, configuration_url(), build_device_info() |
const, homeassistant.helpers.device_registry |
entity.py |
CrowConnectionEntity - availability, connection subscription, async_entity_id_changed opt-in |
homeassistant.core |
const.py |
Domain, option keys, limits, defaults, signal topics, device identifiers | — |
config_flow.py |
Config flow + options flow | const, voluptuous |
alarm_control_panel.py |
CrowAlarmPanel |
device, const |
binary_sensor.py |
CrowZoneSensor, CrowSystemStatusSensor (+ base) |
device, const |
button.py |
CrowChimeButton, CrowRelayButton (+ base) |
device, const |
sensor.py |
CrowSystemSensor, CrowAlarmZoneSensor (+ base) |
device, const |
switch.py |
CrowOutput (+ base) |
device, const |
diagnostics.py |
Redacted config-entry diagnostics | const |
pycrowipmodule/* |
Vendored protocol driver (no Home Assistant imports) | stdlib only |
Dependency rule: pycrowipmodule/ must not import homeassistant. This keeps the protocol
layer testable on a bare interpreter (see §12).
Written once by CrowConfigFlow.async_step_user; changed later through the options flow.
| Key | Constant | Type | Default | Notes |
|---|---|---|---|---|
host |
CONF_HOST |
str |
— | IP address or hostname of the IP Module |
port |
CONF_PORT |
int |
5002 |
DEFAULT_PORT |
keepalive_interval |
CONF_KEEP_ALIVE |
int |
30 |
Seconds between STATUS probes; 15-3600 (MIN_KEEPALIVE/MAX_KEEPALIVE), normalised on migration |
timeout |
CONF_TIMEOUT |
int |
10 |
TCP connect timeout (seconds) |
firmware_version |
CONF_FW_VERSION |
str |
"Ver 2.10.3628 2017" |
Selection key from FIRMWARE_PROFILES |
firmware_date |
CONF_FW_DATE |
str |
"Oct 20 09:48:43" |
Derived from FIRMWARE_PROFILES |
number_of_areas |
CONF_NUM_AREAS |
int |
2 |
1–MAX_AREAS (2) |
number_of_zones |
CONF_NUM_ZONES |
int |
7 |
1–MAX_ZONES (16) |
number_of_outputs |
CONF_NUM_OUTPUTS |
int |
2 |
0–MAX_OUTPUTS (8) |
arm_sequence |
CONF_ARM_SEQUENCE |
str |
"command_then_keypad" |
One of ARM_SEQUENCES; see §7.1 |
Config entry version. CONFIG_ENTRY_VERSION = 2. Version 2 exists for the keep-alive
normalisation only: an entry at version 1 is migrated by async_migrate_entry() (§6.1), which raises
keepalive_interval into the supported range and maps the legacy 300 s default onto the current one.
Every other key keeps its value.
Three nested maps, all keyed by stringified ordinal to survive JSON round-tripping:
type is constrained to ZONE_TYPES = ["window","motion","door","smoke","gas","co","tamper","safety"]
(defined in const.py). Each type also maps to its own zone device — see §6.3.
Note.
code_arm_requiredis persisted but currently written asTrueunconditionally by both flows. Effective behaviour comes fromCrowAlarmPanel.code_arm_required, which returnsFalsewhenever a code is stored (so the UI does not demand one) and otherwise the stored value.
FIRMWARE_PROFILES = {
"Ver 2.10.3628 2017": "Oct 20 09:48:43",
"unsupported": "unknown",
}Selecting a version sets firmware_date from this map. The resulting string
("<version> (<date>)") becomes the device sw_version.
Declared in const.py; produced by __init__.py; consumed by platforms.
| Constant | Value | Payload | Sent when |
|---|---|---|---|
SIGNAL_ZONE_UPDATE |
crowipmodule.zones_updated |
zone number (str) or None |
ZO/ZC/ZA/ZR/ZBY/ZBYR/ZTA/ZTR parsed and changed |
SIGNAL_AREA_UPDATE |
crowipmodule.areas_updated |
"A"/"B" or None |
AA/AB/SA/SB/DA/DB/EAA/EAB/ESA/ESB parsed and changed |
SIGNAL_SYSTEM_UPDATE |
crowipmodule.system_updated |
attribute name or None |
MF/MR/BF/BR/… parsed and changed |
SIGNAL_OUTPUT_UPDATE |
crowipmodule.output_updated |
output number (str) |
OO<n> / OC<n> parsed and changed |
SIGNAL_CONNECTION_UPDATE |
crowipmodule.connection_updated |
bool |
a connection transition - see §5.2. A repeat of the current state is not sent |
SIGNAL_KEYPAD_UPDATE |
crowipmodule.keypad_updated |
- | Declared; consumed by CrowAlarmPanel for compatibility |
Payload discipline. Every consumer must treat None as "refresh unconditionally" and compare
the concrete payload against its own ordinal before writing state.
Change-only dispatch. A driver state handler returns None when the parsed value equals the
stored one, and _dispatch_line() then invokes no callback at all - the "and changed" above. This is
what keeps a STATUS dump that reports no change from rewriting every entity. The None payload is
not produced by the driver any more; it remains a valid "refresh all" payload for callers that want
one.
| Aspect | Rule |
|---|---|
| Transport | TCP, default port 5002 |
| Encoding | ASCII (send_data encodes ascii; reads decode ascii with errors="ignore") |
| Terminator | Outgoing lines end \r\n. Incoming split on \r\n, falling back to \n |
| Blank lines | Discarded |
| Read size | 1024 bytes per recv |
| Read timeout | _RECV_TIMEOUT = 5.0 s; a timeout is the keep-alive tick, not an error |
| Key | Wire command | Argument | Used by |
|---|---|---|---|
arm |
ARM |
— | arm_away() |
stay |
STAY |
— | arm_stay() |
disarm |
KEYS |
<code>E |
disarm(code) |
keys |
KEYS |
<code>E |
send_keypress(code) |
panic |
PANIC |
— | panic_alarm() |
toggle_chime |
CHIME |
— | toggle_chime(), CrowChimeButton |
relay_1_on |
RL1 |
— | relay_on(1), CrowRelayButton |
relay_2_on |
RL2 |
— | relay_on(2) |
toogle_output_x |
OO |
<n> (no space) |
command_output(n), CrowOutput |
status |
STATUS |
— | handshake + keep-alive |
quick_arm_a / quick_arm_b |
A / B |
— | defined, unused |
get_memory_events / exit_memory_events |
MEME / MEMX |
— | defined, unused |
Formatting (CrowIPModuleClient.send_command):
data == "" -> f"{cmd} " e.g. "STATUS ", "ARM ", "CHIME "
cmd == "OO" -> f"{cmd}{data}" e.g. "OO2" (no space)
otherwise -> f"{cmd} {data}" e.g. "KEYS 1234E"
Parsed with re.match against the whole line; the first match wins.
Zone messages — capture group data = zone number; callback callback_zone_state_change.
| Pattern | Event | Attribute set | Value |
|---|---|---|---|
ZO<d> |
Zone open | open |
True |
ZC<d> |
Zone closed | open |
False |
ZA<d> |
Zone alarm | alarm |
True |
ZR<d> |
Zone alarm restore | alarm |
False |
ZBY<d> |
Zone bypass | bypass |
True |
ZBYR<d> |
Bypass restore | bypass |
False |
ZTA<d> |
Zone tamper | tamper |
True |
ZTR<d> |
Tamper restore | tamper |
False |
Area messages — fixed area letter, callback callback_area_state_change.
| Literal | Event | Attribute | Area |
|---|---|---|---|
AA / AB |
Armed | armed = True |
A / B |
SA / SB |
Stay armed | stay_armed = True |
A / B |
DA / DB |
Disarmed | disarmed = True |
A / B |
EAA / EAB |
Exit delay | exit_delay = True |
A / B |
ESA / ESB |
Stay exit delay | stay_exit_delay = True |
A / B |
Area messages are exclusive: handle_area_state_change clears armed, stay_armed,
disarmed, exit_delay and stay_exit_delay before setting the new one. A D* (disarm) also
clears alarm and alarm_zone.
Output messages — capture group data = output number; callback callback_output_state_change.
| Pattern | Event | Attribute | Value |
|---|---|---|---|
OO<d> |
Output on | open |
True |
OC<d> |
Output off | open |
False |
System messages — callback callback_system_state_change.
| Literal | Attribute → value | Literal | Attribute → value |
|---|---|---|---|
MF |
mains = False |
MR |
mains = True |
BF |
battery = False |
BR |
battery = True |
TF |
tamper = True |
TR |
tamper = False |
LF |
line = False |
LR |
line = True |
DF |
dialler = False |
DR |
dialler = True |
RO |
ready = True |
NR |
ready = False |
FF |
fuse = False |
FR |
fuse = True |
ZBF |
zonebattery = False |
ZBR |
zonebattery = True |
PBF |
pendantbattery = False |
PBR |
pendantbattery = True |
CTF |
codetamper = True |
CTR |
codetamper = False |
Note the asymmetric polarity:
TF(tamper fail) storesTrue, matching theBinarySensorDeviceClass.TAMPERsemantics where "on" means sabotage detected.
_dispatch_line(line):
_parse_line(line)walksRESPONSE_FORMATS; on the first match it returns{attribute, name, status, handler, callback, [area], data}. No match → return (line ignored).handleris called ashandle_<fmt.handler>; it mutatespanel.<x>_stateand returns the payload for the callback:handle_zone_state_change→ zone number (str)handle_area_state_change→"A"/"B"handle_output_state_change→ output number (str)handle_system_state_change→ attribute name (str)
callback(callback_<fmt.handler>on the panel) is invoked with that payload.
Both handler and callback are wrapped in try/except and only log — a malformed line can never
kill the worker thread.
A ZA<n> (zone alarm) fan-out is special-cased: it sets alarm = True and alarm_zone = "<n>" on
both areas and then invokes the area callback for A and B, so alarm panels refresh even
though the event was a zone event.
zone_state[1..16] = {"status": {"open": False, "bypass": False, "alarm": False, "tamper": False},
"last_fault": 0}
area_state[1..2] = {"status": {"alarm": False, "armed": False, "stay_armed": False,
"disarmed": False, "exit_delay": False, "stay_exit_delay": False,
"alarm_zone": "", "last_disarmed_by_user": "",
"last_armed_by_user": ""}}
output_state[1..8] = {"status": {"open": False}}
system_state = {"status": {"mains": True, "battery": True, "tamper": False, "line": True,
"dialler": True, "ready": True, "fuse": True,
"zonebattery": True, "pendantbattery": True,
"codetamper": False}}Note the default polarity: mains/line/dialler/battery are True (healthy), tamper flags are
False. CrowSystemStatusSensor adapts this to Home Assistant's "on = problem" convention (§7.2).
| Member | Contract |
|---|---|
start() |
Idempotent. Spawns daemon thread CrowIPClientThread running _run_loop. |
stop() |
Clears the running flag, sets the wakeup event, closes the socket. Safe to call twice. |
is_connected (property) |
The raw socket state: True only between a successful connect and the next failure. |
connected (property) |
The connection state most recently reported (_reported_connected). This is what entity availability follows; it does not dip while a dropped link is retried. |
recv timeout |
_RECV_TIMEOUT = 5.0 - the loop wakes at least this often. |
CrowIPAlarmPanel exposes the same pair (is_connected, connected); connected is False while
no client exists.
while running:
connected = _connect()
if connected:
_notify_connection(True) # forwarded only if it differs from the last one
_reconnect_delay = 10 # reset backoff
request_status() # handshake: ask for a full dump
_listen_loop() # blocks until disconnect or stop
if not running: break
_is_connected = False; _close_socket()
if connected and uptime >= _STABLE_CONNECTION: # 5 s
continue # absorb the blip: retry at once, report nothing
_notify_connection(False) # the outage is real - announce it once
_wakeup.wait(_reconnect_delay) # interruptible sleep
_reconnect_delay = min(_reconnect_delay * 2, 60)
Backoff: 10 s → 20 → 40 → 60 (cap), reset to 10 on a successful connect. _wakeup makes
stop() immediate instead of waiting out the delay.
Two rules keep the churn down and must be preserved:
- A blip is retried before it is reported. A link that had held for
_STABLE_CONNECTION(5 s) is reconnected once, immediately. If that succeeds,_notify_connection(False)never runs, no entity is rewritten and no automation sees anunavailablestate. A link that flaps faster than 5 s is treated as a real failure (no immediate retry), which is what stops a panel that accepts and immediately drops from being hammered. _notify_connection()is idempotent. It compares against_reported_connected(guarded by_state_lock) and returns without invoking the callback when the state is unchanged, so a failed reconnect attempt cannot re-announce an outage that was already announced.
| Guard | Protects |
|---|---|
_send_lock (threading.Lock) |
Serialises sendall so a command from the loop cannot interleave with a keep-alive |
_state_lock (threading.Lock) |
Guards thread start/stop, the running flag, and the _reported_connected transition check |
_wakeup (threading.Event) |
Interruptible backoff |
panel.<x>_state is intentionally not locked: the panel thread is the only writer and the
event loop only reads. See ACD RISK-3.
send_data() returning False sets _is_connected = False and closes the socket. The supervisor
then reconnects (via the blip retry if the link had been stable). Writing into a silently-dead socket
is never attempted.
| Step | Rule |
|---|---|
| Parse | _parse_line() matches the line against RESPONSE_FORMATS; no match returns {} and the line is dropped |
| Handle | handle_*_state_change() mutates panel.<x>_state and returns the payload to dispatch |
| Unchanged | A handler whose value equals the stored one returns None; _dispatch_line() then calls no callback, so no signal and no entity write |
| Dispatch | The callback named in the format table (callback_<handler>) is invoked with the payload |
| Failure | A handler or callback exception is logged and swallowed; the listen loop keeps running |
handle_zone_state_change() is the one handler with a side effect: a zone alarm transition also
updates every area's alarm/alarm_zone and invokes the area callback for "A" and "B", so the
alarm panel entities refresh alongside the zone sensor.
| Aspect | Rule |
|---|---|
| Purpose | The STATUS probe is what keeps the module's socket open; the module closes an idle connection on its own |
| Default | DEFAULT_KEEPALIVE = 30 s - the panel driver's own interval. The 300 s shipped before 2.2.0 is longer than the module's idle timeout, which made the module drop the socket first |
| Bounds | MIN_KEEPALIVE = 15, MAX_KEEPALIVE = 3600; const.normalise_keepalive() maps the legacy 300 s onto the default and clamps anything else into range |
| Enforced at | the config/options flow (vol.Range), the driver (_MIN_KEEPALIVE/_MAX_KEEPALIVE clamp), and the config-entry migration |
| Cost | One short line every 30 s. The reply is a full status dump, but a dump whose values are unchanged dispatches nothing (see §5.5), so it costs no entity writes |
PLATFORMS = [ALARM_CONTROL_PANEL, BINARY_SENSOR, BUTTON, SENSOR, SWITCH]async_migrate_entry(hass, entry) -> bool - config entry version 1 → 2 normalises
keepalive_interval through const.normalise_keepalive() (the legacy 300 s default becomes the 30 s
default, anything else is clamped into 15-3600), writes entry.data and the version back with
hass.config_entries.async_update_entry() and logs the change. An entry newer than
CONFIG_ENTRY_VERSION returns False (no downgrade).
async_setup_entry(hass, entry) -> bool, in order:
- Read
host,port,keepalive_interval(throughnormalise_keepalive()),connection_timeoutfromentry.data. - Construct
CrowIPAlarmPanel(host, port, "0000", keep_alive, None, connection_timeout). A constructor failure logs and returnsFalse. - Build
entry.runtime_data = CrowRuntimeData(controller, firmware=f"{fw} ({date})"). - Install callbacks (each wrapping a
_thread_safe_send):callback_zone_state_change,callback_area_state_change,callback_system_state_change,callback_output_state_change,callback_connected,callback_login_timeout. - Register the hub device with
dr.async_get(hass).async_get_or_create(...), then storehub_device.idintoruntime_data.device_id(required before any platform runs - DEC-5). await asyncio.sleep(2.0)- let the panel release a socket left over from a reload.await hass.async_add_executor_job(controller.start).await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS).- Register
async_on_unload: anEVENT_HOMEASSISTANT_STOPlistener that callscontroller.stop()in the executor, andentry.add_update_listener(update_listener).
_thread_safe_send(signal, data) - the only bridge from the panel thread:
hass.loop.call_soon_threadsafe(async_dispatcher_send, hass, signal, data)connected_callback(data) - publishes SIGNAL_CONNECTION_UPDATE. The driver calls it only on a
real transition (see §5.2), so nothing has to be deduplicated here. It does not broadcast a
refresh: request_status() on connect already asks for a full status dump, and only the values that
changed are dispatched (§5.5), so a second broadcast would rewrite every entity for nothing.
connection_fail_callback(data) - logs at debug when a connect attempt fails. The driver keeps
retrying on its own; if the outage is real it is reported through connected_callback once the
immediate retry has failed.
async_unload_entry - unloads platforms, then stops the controller in the executor if
entry.runtime_data exists (guards a failed setup), then sleeps 2 s to let the single TCP socket be
released before a potential re-setup. Returns the platform-unload result.
update_listener - async_reload(entry.entry_id), so option changes (names, codes, zone
types, counts) take effect immediately.
@dataclass
class CrowRuntimeData:
controller: CrowIPAlarmPanel
device_id: str | None = None
firmware: str | None = None
def configuration_url(host: str | None) -> str | None
def build_device_info(*, name, host=None, identifier=IDENTIFIER_HUB,
model=MODEL_IP_MODULE, sw_version=None,
via_device_id=None) -> DeviceInfoconfiguration_url() accepts 192.168.1.50, crow.local or a full https://… URL and returns a
value Home Assistant will accept; it returns None for empty input, a non-http(s) scheme, or
anything without a hostname. Home Assistant raises ValueError for a bad value, which previously
aborted device registration outright.
build_device_info() emits only the keys that have values, always using the shared MANUFACTURER
and identifier constants. All six device-info call sites use it — see §7.0.
entity.py holds CrowConnectionEntity, the mixin every platform base class mixes in first
(class CrowBaseEntity(CrowConnectionEntity, BinarySensorEntity)):
| Member | Contract |
|---|---|
available (property) |
self._controller.connected - the reported connection state, so a link the driver is retrying does not change availability |
_connection_callback(_connected) |
async_write_ha_state(), subscribed to SIGNAL_CONNECTION_UPDATE by each platform in async_added_to_hass |
async_entity_id_changed(old_entity_id) |
Calls super().async_entity_id_changed(old) when the base class has it (getattr(super(), …, None)) and does nothing otherwise. Present so the integration opts in to the in-place entity_id change that becomes the default in Home Assistant 2026.11 instead of being removed and added again on every rename. Nothing here keys on entity_id, so super() is all that has to run. |
| Device | Identifier | Model | Linked to hub |
|---|---|---|---|
| Crow Alarm System | crow_alarm_panel |
IP Module |
— (it is the hub) |
| Crow Alarm Sensors | crow_sensors |
IP Module Zone |
via_device_id |
All zone entities live on Crow Alarm Sensors. The zone type selects the binary-sensor device
class (the icon), never the device: Home Assistant links an entity to a device only at first
registration, so a per-type device layout would leave an entity behind on the device of a type the
zone no longer has, with no way to move it from the UI.
The identifiers crow_windows / crow_doors / crow_motion / crow_smoke / crow_gas / crow_co /
crow_tamper / crow_safety (listed in const.LEGACY_ZONE_IDENTIFIERS) are retired: releases
up to 2.1.4 created a device for each of them, and binary_sensor._remove_retired_zone_devices()
deletes such a device as soon as nothing is registered on it any more. crow_alarm_panel and
crow_sensors are frozen — changing one would duplicate an existing device.
| Aspect | Rule |
|---|---|
_attr_should_poll |
False (push only) |
_attr_has_entity_name |
True |
available |
self._controller.connected (via CrowConnectionEntity) - the reported state, not the raw socket |
| Subscriptions | async_dispatcher_connect in async_added_to_hass, removed via async_on_remove |
async_entity_id_changed |
inherited from CrowConnectionEntity; opts in to in-place entity_id changes (HA 2026.11+) |
device_info |
build_device_info(...), hub identifier unless stated |
sw_version |
runtime_data.firmware |
Every platform base class mixes CrowConnectionEntity in first, so available, the connection
callback and the entity_id hook come from one place:
class CrowBaseEntity(CrowConnectionEntity, BinarySensorEntity),
class CrowBaseSwitch(CrowConnectionEntity, SwitchEntity), and so on.
classDiagram
class CrowBaseEntity { <<abstract>> +available bool +device_info DeviceInfo }
class CrowZoneSensor { +is_on bool +extra_state_attributes }
class CrowSystemStatusSensor { +is_on bool }
class CrowAlarmPanel { +alarm_state +supported_features }
class CrowSystemSensor { +native_value str }
class CrowAlarmZoneSensor { +native_value str }
class CrowOutput { +is_on bool }
class CrowChimeButton { +async_press() }
class CrowRelayButton { +async_press() }
CrowBaseEntity <|-- CrowZoneSensor
CrowBaseEntity <|-- CrowSystemStatusSensor
| Property | Value |
|---|---|
unique_id |
{entry_id}_crow_area_{n} — entry-scoped |
name |
area name from options (_attr_name = None + has_entity_name) |
icon |
mdi:shield-home |
code_format |
CodeFormat.NUMBER (always numeric keypad) |
code_arm_required |
False when a code is stored, else the configured flag |
supported_features |
ARM_HOME | ARM_AWAY | TRIGGER |
available |
connection state |
State mapping (alarm_state, priority order):
Condition on area_state[n]["status"] |
AlarmControlPanelState |
|---|---|
alarm |
TRIGGERED |
armed |
ARMED_AWAY |
stay_armed |
ARMED_HOME |
exit_delay or stay_exit_delay |
ARMING |
disarmed |
DISARMED |
| otherwise | None (unknown) |
extra_state_attributes returns the raw status dict for debugging.
Arming. async_alarm_arm_away() / async_alarm_arm_home() delegate to the shared
CrowAlarmPanel._async_arm(*, stay, code), which honours the arm_sequence option:
arm_sequence |
Wire sequence | Rationale |
|---|---|---|
command_then_keypad (default) |
ARM (or STAY ) then KEYS <code>E |
The panel only arms once the code and Enter have been entered. Matches 2.0.0. |
command_only |
ARM (or STAY ) only |
The panel arms immediately, and a following code press would be read as a disarm. |
<code> is the code argument when the caller supplies one, otherwise the area code from the area
options. send_keypress(code) writes KEYS <code>E, where the trailing E is the ENTER key, so a
single keypress line is the keypad equivalent of typing the code and pressing Enter. That is why the
same wire format serves both "finish arming" and "disarm" — the panel decides from its current
state. If command_then_keypad is selected but no code is available, the arm command is still sent
and a warning is logged.
Disarming is always keypad style, independent of arm_sequence: async_alarm_disarm(code)
prefers the supplied code, else the stored one, and issues KEYS <code>E followed by STATUS. With
no code available it logs an error and sends nothing, rather than emitting a digit-less KEYS E.
async_alarm_trigger() sends PANIC.
CrowZoneSensor — one per configured zone.
| Aspect | Value |
|---|---|
unique_id |
crow_zone_{n} (zone_unique_id(n)) |
name |
zone name from options |
device_class |
from _ZONE_DEVICE_CLASSES (below) |
is_on |
zone_state[n]["status"]["open"] |
extra_state_attributes |
the zone status dict (open, bypass, alarm, tamper) |
device_info |
zone_device_info(): the single zone device, with via_device_id |
| Zone type | BinarySensorDeviceClass |
|---|---|
window |
WINDOW |
door |
DOOR |
motion |
MOTION |
smoke |
SMOKE |
gas |
GAS |
co |
CO (wire value carbon_monoxide) |
tamper |
TAMPER |
safety |
SAFETY |
| anything else | ✱ no device class, warning logged |
The device class above comes from the zone type (what the entity is); the device every zone
entity is registered on is the one const.zone_device() returns - a single Crow Alarm Sensors device
for all of them. ✱ An unrecognised type gets no device class. Comparing against the enum (not a bare
string) is deliberate: BinarySensorDeviceClass is a StrEnum, so the comparison holds either way,
but the enum form survives refactors.
Home Assistant links an entity to a device only when the entity is registered for the first time;
a later device_info change moves it, but an entity that was created while the zones still had their
own devices would keep that device for good, and re-configuring the zone does not move it.
Before the zone entities are created, async_setup_entry therefore walks options["zones"] and, for
every existing registry entry (unique_id = zone_unique_id(n), scoped to the entry via
runtime_data.entry_id), writes the single zone device (zone_device_info()), corrected name
included, as the entry's device_id. Entity ids are not touched: they are the handles dashboards
and automations use. Each move is logged at info level, everything else at debug level.
Directly afterwards _remove_retired_zone_devices() deletes every device in
const.LEGACY_ZONE_IDENTIFIERS that has no entity left on it, so the per-type devices of releases up
to 2.1.4 disappear from the device list without any manual cleanup. A device that still has entities is
never touched.
The flow logs the zone → type map it is working from (Zone types: 1 'Eingangstür'=door, ...), so a
zone that is grouped unexpectedly can be traced back to the type actually stored for it. The same facts
- plus the device the entity is actually linked to, which no Home Assistant view shows - are part of
the diagnostics download (
zone_registry:name,type,expected_device,entity_id,device_name,device_identifiers; seediagnostics._zone_registry_report).
CrowSystemStatusSensor — six diagnostic sensors.
unique_id |
Name | Device class | is_on |
|---|---|---|---|
crow_sys_mains |
Mains Power | POWER |
status["mains"] |
crow_sys_battery |
System Battery | BATTERY |
not status["battery"] |
crow_sys_tamper |
System Tamper | TAMPER |
status["tamper"] |
crow_sys_line |
Phone Line | CONNECTIVITY |
status["line"] |
crow_sys_dialler |
Dialler | CONNECTIVITY |
status["dialler"] |
crow_sys_zonebattery |
Zone Battery | BATTERY |
not status["zonebattery"] |
entity_category = EntityCategory.DIAGNOSTIC. Missing keys default to True (healthy). Battery
classes are inverted because the protocol reports battery OK, while Home Assistant expects
on = low/problem.
CrowSystemSensor — unique_id crow_system_status_text, name "System Status", icon
mdi:shield-home, diagnostic. native_value is a human summary, evaluated in this order:
| Condition | Value |
|---|---|
alarm |
ALARM |
armed |
Armed Away |
stay_armed |
Armed Stay |
exit_delay |
Exit Delay |
stay_exit_delay |
Stay Exit Delay |
mains false |
Power Failure |
battery false |
Low Battery |
| else (or empty state) | Ready |
CrowAlarmZoneSensor — one per area: unique_id crow_alarm_zone_area_{n}, name
"<Area> Last Alarm", icon mdi:alert-circle-check-outline. native_value returns
"Zone <n>" from area_state[n]["status"]["alarm_zone"], else "None".
| Aspect | Value |
|---|---|
unique_id |
crow_output_{n} |
name |
output name from options |
is_on |
cached _is_on, seeded from output_state[n]["status"]["open"] |
async_turn_on() / async_turn_off() both issue the toggle command OO<n> and are guarded by
_is_on, so a repeated call is a no-op. _update_callback only writes state when the panel's value
actually differs, avoiding entity-registry capability churn.
| Entity | unique_id |
Icon | Command |
|---|---|---|---|
| Toggle Chime | crow_toggle_chime |
mdi:bell-ring |
CHIME |
| Relay 1 | crow_relay_1 |
mdi:electric-switch |
RL1 |
| Relay 2 | crow_relay_2 |
mdi:electric-switch |
RL2 |
Relays are created unconditionally by the platform (unlike switches, which follow
number_of_outputs).
VERSION = CONFIG_ENTRY_VERSION (2, see §2.1). Four steps; the zone step holds all zones at once.
stateDiagram-v2
[*] --> user
user --> areas : TCP probe OK + unique_id set
areas --> outputs
outputs --> zones : count == 0
outputs --> zones
zones --> [*] : async_create_entry
| Step | Fields |
|---|---|
user |
host, port, keepalive_interval, timeout, firmware_version, arm_sequence, number_of_areas, number_of_outputs, number_of_zones |
areas |
area_{i}_name (required), area_{i}_code (optional) for i = 1..number_of_areas |
outputs |
output_{i}_name for i = 1..number_of_outputs (skipped entirely when the count is 0) |
zones |
zone_{i}_name, zone_{i}_type for i = 1..number_of_zones |
Zone step. zone_schema(count, zones) renders one name/type pair per zone in zone order and
merge_zones(target, user_input) writes the submitted form back, mapping every field through
_ZONE_FIELD (^zone_(\d+)_(name|type)$) - never through the position of the field on the form.
zone_{i}_type is a SelectSelector (translation_key="zone_type"), so the dropdown shows
Fenster / Tür / Bewegung while the stored value stays the plain type string from
ZONE_TYPES that the device classes are keyed by. Earlier releases split the
zones over pages of four and tracked the current page in the flow handler; the page then depended on
hidden state instead of on the form the user had in front of them, so a step back could skip a page
or mis-assign a type. There is no page state any more.
Unique ID is f"{host}_{port}"; a duplicate aborts with already_configured.
Connectivity probe. _test_connection(host, port, timeout) does a blocking
socket.create_connection and is run via hass.async_add_executor_job. Failure surfaces
errors["base"] = "cannot_connect" and the form is re-rendered — the entry is only created once the
module is reachable.
Firmware date is derived on submit:
self._data[CONF_FW_DATE] = FIRMWARE_PROFILES.get(selected_version, "unknown").
arm_sequence is rendered by _arm_sequence_selector() — a SelectSelector with
translation_key="arm_sequence" — so the option labels come from the
selector.arm_sequence.options section of the translation files rather than from raw identifiers.
There is no async_step_import: no CONFIG_SCHEMA is defined, so a YAML block cannot trigger an
import (ACD RISK-4).
Uses the framework-provided self.config_entry and takes no constructor arguments (the
supported pattern since the earlier OptionsFlow(config_entry) deprecation). Steps init → areas
→ outputs → zones mirror the config flow, pre-filled from the existing entry. init also
re-derives firmware_date when the firmware selection changes. Finishing the flow triggers
update_listener → async_reload.
strings.json is the English source and is byte-for-byte mirrored into translations/en.json
(asserted by tests/verify_config_flow.py). de, es, fr, it cover every step.
tests/verify_config_flow.py also asserts that every field the flows render has a label, which
is how the earlier strings.json/en.json drift was found.
Every selector that carries a translation_key needs a matching top-level selector block. The
arm_sequence dropdown is the first one, and the harness asserts that all five languages translate
every value of ARM_SEQUENCES, not just the block itself.
diagnostics.py::async_get_config_entry_diagnostics returns:
{
"entry_data": { "host": "**REDACTED**", "port": 5002, ... }, // host, code redacted
"options": { "areas": { "1": { "code": "**REDACTED**", ... } } },
"connected": false,
"state": { "system": …, "areas": …, "zones": …, "outputs": … }
}Redaction uses async_redact_data with TO_REDACT = {"code", "host"} and a second pass over each
area's code. The controller is read from entry.runtime_data and the state block is omitted if
setup never completed.
| Logger | Content |
|---|---|
custom_components.crowipmodule |
Setup/unload, commands, connection transitions |
custom_components.crowipmodule.pycrowipmodule.crow_base_client |
TX: / RX: raw lines (debug) |
Recommended user configuration:
logger:
default: info
logs:
custom_components.crowipmodule: debug| Layer | Policy |
|---|---|
| Parser | Unknown lines are ignored silently at debug level; never raise |
| Handlers / callbacks | Wrapped in try/except, logged, never propagate (protects the worker thread) |
| Command send | Failure closes the socket so the supervisor reconnects (immediately if the link had been stable) |
| Entity commands | Wrapped in try/except with _LOGGER.error; entities never raise to the service call |
| Arming without a code | Keypad mode still sends the arm command and logs a warning; arm_sequence resolution never raises |
| Disarming without a code | Logs an error and sends nothing (a digit-less KEYS E is meaningless) |
| Setup | Constructor failure → False. Probe failure → form error, no entry created |
| Unload | Must not raise; guards a missing runtime_data |
Home Assistant 2026.10 requires Python 3.14.2+, so the test story is deliberately layered:
| Layer | Artefact | Runs with | Covers |
|---|---|---|---|
| Protocol | test_crow_protocol.py |
bare Python | Command wire formats, RX parsing, state handlers, change-only dispatch (a repeated line dispatches nothing), the connection-state machine (deduplicated transitions, blip absorption, backoff growth), keep-alive clamping, and a real loopback socket end-to-end |
| HA contract | tests/verify_ha_2026_contract.py |
bare Python + stubs | Every platform's async_setup_entry; asserts no via_device in any device_info, zone via_device_id == hub id, alarm_state for all seven states, the emitted wire sequence for both arm_sequence modes (including the KEYS <code>E follow-up and the disarm path), option precedence over entry.data, configuration_url sanitising, availability wiring (connected, not is_connected), the async_entity_id_changed opt-in, keep-alive normalisation and the config-entry migration |
| Config flow | tests/verify_config_flow.py |
bare Python + voluptuous |
Full 5-step flow, pagination, options flow, translation coverage for all fields and languages, the arm_sequence selector (options offered, translation key, rejection of unknown values), the keep-alive range and the stored entry version |
| Static | python -m compileall custom_components |
bare Python | Syntax across the integration |
The stubs reproduce the real 2026.10 definitions that matter (DeviceInfo as total=False with
via_device_id; BinarySensorDeviceClass as StrEnum; EntityCategory in homeassistant.const;
Entity.async_entity_id_changed).
They found two real defects during development, which is why they are kept in-tree.
| Requirement | Implementation | Verified by |
|---|---|---|
| G1 HA 2026.10.1 conformance | ACD §7 table | tests/verify_ha_2026_contract.py |
| G2 No pip dependency | vendored driver, no requirements |
HACS install; manifest inspection |
| G3 Availability correctness | available follows connected; driver reports transitions only and absorbs blips |
test_crow_protocol.py; tests/verify_ha_2026_contract.py |
| G4 Full signal coverage | §4.3 tables | test_crow_protocol.py |
| G5 Diagnosability | diagnostics.py, System Status sensor |
tests/verify_ha_2026_contract.py |
| G6 First-class presentation | brand/, strings.json, 5 translations |
tests/verify_config_flow.py; release archive listing |
pycrowipmodule/must stay free ofhomeassistantimports.- Never call Home Assistant APIs from the panel thread — go through
_thread_safe_sendorrun_coroutine_threadsafe. - Build
device_infoonly viabuild_device_info(). - Do not change
IDENTIFIER_*constants or existingunique_ids without a migration plan. - Every new user-visible string needs an entry in
strings.jsonand all five translations. - Run all four verification commands from §12 before opening a pull request.
{ "areas": { "1": { "name": "House", "code": "1234", "code_arm_required": true }, "2": { "name": "Garage", "code": "", "code_arm_required": true } }, "outputs": { "1": { "name": "Gate" }, "2": { "name": "Siren" } }, "zones": { "1": { "name": "Front Door", "type": "door" }, "2": { "name": "Kitchen", "type": "motion" } } }