Skip to content

Latest commit

 

History

History
813 lines (637 loc) · 40.2 KB

File metadata and controls

813 lines (637 loc) · 40.2 KB

Software Design Document (SDD)

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.


1. Module inventory

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


2. Configuration data model

2.1 entry.data — connection and device inventory

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.

2.2 entry.options — user-facing names and codes

Three nested maps, all keyed by stringified ordinal to survive JSON round-tripping:

{
  "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" }
  }
}

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_required is persisted but currently written as True unconditionally by both flows. Effective behaviour comes from CrowAlarmPanel.code_arm_required, which returns False whenever a code is stored (so the UI does not demand one) and otherwise the stored value.

2.3 Firmware profiles

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.


3. Signal contract

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.


4. Protocol layer specification

4.1 Framing

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

4.2 Command table (crow_defs.COMMANDS)

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"

4.3 Response table (crow_defs.RESPONSE_FORMATS)

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) stores True, matching the BinarySensorDeviceClass.TAMPER semantics where "on" means sabotage detected.

4.4 Parse → dispatch pipeline

_dispatch_line(line):

  1. _parse_line(line) walks RESPONSE_FORMATS; on the first match it returns {attribute, name, status, handler, callback, [area], data}. No match → return (line ignored).
  2. handler is called as handle_<fmt.handler>; it mutates panel.<x>_state and 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)
  3. 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.

4.5 State shapes (status_state.py)

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


5. Driver internals — CrowIPModuleClient

5.1 Lifecycle API

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.

5.2 _run_loop() - supervised connect/listen/backoff

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 an unavailable state. 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.

5.3 Thread safety

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.

5.4 Send failure policy

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.

5.5 RX dispatch policy

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.

5.6 Keep-alive

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

6. Integration layer

6.1 __init__.py

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:

  1. Read host, port, keepalive_interval (through normalise_keepalive()), connection_timeout from entry.data.
  2. Construct CrowIPAlarmPanel(host, port, "0000", keep_alive, None, connection_timeout). A constructor failure logs and returns False.
  3. Build entry.runtime_data = CrowRuntimeData(controller, firmware=f"{fw} ({date})").
  4. 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.
  5. Register the hub device with dr.async_get(hass).async_get_or_create(...), then store hub_device.id into runtime_data.device_id (required before any platform runs - DEC-5).
  6. await asyncio.sleep(2.0) - let the panel release a socket left over from a reload.
  7. await hass.async_add_executor_job(controller.start).
  8. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS).
  9. Register async_on_unload: an EVENT_HOMEASSISTANT_STOP listener that calls controller.stop() in the executor, and entry.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.

6.2 device.py and entity.py

@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) -> DeviceInfo

configuration_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.

6.3 Device registry layout

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.


7. Entity specifications

7.0 Shared behaviour (all platforms)

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
Loading

7.1 alarm_control_panel.CrowAlarmPanel

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.

7.2 binary_sensor.py

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.

7.2.1 Zone registry alignment (_async_align_zone_entities)

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; see diagnostics._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.

7.3 sensor.py

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".

7.4 switch.py — CrowOutput

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.

7.5 button.py

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


8. Config and options flow

8.1 Config flow — CrowConfigFlow

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

8.2 Options flow — CrowOptionsFlowHandler

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.

8.3 Translations

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.


9. Diagnostics

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.


10. Logging and observability

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

11. Error-handling policy

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

12. Verification strategy

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.


13. Traceability

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

14. Conventions for contributors

  1. pycrowipmodule/ must stay free of homeassistant imports.
  2. Never call Home Assistant APIs from the panel thread — go through _thread_safe_send or run_coroutine_threadsafe.
  3. Build device_info only via build_device_info().
  4. Do not change IDENTIFIER_* constants or existing unique_ids without a migration plan.
  5. Every new user-visible string needs an entry in strings.json and all five translations.
  6. Run all four verification commands from §12 before opening a pull request.