Repository navigation
Software Design Document SDD
Audience: implementers
Applies to: branch HA2026_09_dev, version 2.1.0-beta.2
Canonical file. This page is a summary for the wiki. The full, versioned document lives in the repository and is the source of truth:
docs/SDD.md. Companion: Architectural Design Document · Workflows.
A Software Design Document answers how each part is built. The ADD answers what the system is made of and why.
| Module | Responsibility |
|---|---|
__init__.py |
Entry setup/teardown, callback marshalling, hub device registration |
device.py |
CrowRuntimeData, configuration_url(), build_device_info()
|
const.py |
Domain, option keys, limits, defaults, signal topics, device identifiers |
config_flow.py |
Config flow (5 steps) + options flow (4 steps) |
alarm_control_panel.py |
CrowAlarmPanel |
binary_sensor.py |
CrowZoneSensor, CrowSystemStatusSensor
|
button.py |
CrowChimeButton, CrowRelayButton
|
sensor.py |
CrowSystemSensor, CrowAlarmZoneSensor
|
switch.py |
CrowOutput |
diagnostics.py |
Redacted config-entry diagnostics |
pycrowipmodule/ |
Vendored protocol driver — must not import homeassistant
|
| Key | Default | Notes |
|---|---|---|
host |
— | IP address or hostname of the IP Module |
port |
5002 |
|
keepalive_interval |
300 |
Seconds between STATUS probes |
timeout |
10 |
TCP connect timeout |
firmware_version / firmware_date
|
Ver 2.10.3628 2017 / Oct 20 09:48:43
|
Date derived from FIRMWARE_PROFILES
|
number_of_areas |
2 |
max 2 |
number_of_zones |
7 |
max 16 |
number_of_outputs |
2 |
max 8, 0 allowed |
arm_sequence |
command_then_keypad |
command_then_keypad · command_only — see §3.6 |
All keys are stringified ordinals to survive JSON round-tripping.
type ∈ window · motion · door · smoke · gas · co · tamper · safety.
| Aspect | Rule |
|---|---|
| Transport | TCP, default port 5002 |
| Encoding | ASCII |
| Terminator | Outgoing \r\n; incoming split on \r\n, falling back to \n
|
| Read size / timeout | 1024 bytes; _RECV_TIMEOUT = 5.0 s (a timeout is the keep-alive tick, not an error) |
| Key | Wire | 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(), button |
relay_1_on / relay_2_on
|
RL1 / RL2
|
— |
relay_on(n), buttons |
toogle_output_x |
OO |
<n> |
command_output(n), switch |
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 |
Wire formatting:
data == "" -> "<CMD> " "STATUS ", "ARM ", "CHIME "
cmd == "OO" -> "OO<n>" no space
otherwise -> "<CMD> <data>" "KEYS 1234E"
First re.match wins. The handler mutates panel.<x>_state and returns the payload passed to the
callback.
Zones — data = zone number, callback callback_zone_state_change
| Pattern | Event | Attribute | Value |
|---|---|---|---|
ZO<d> / ZC<d>
|
Zone open / closed | open |
True / False
|
ZA<d> / ZR<d>
|
Alarm / restore | alarm |
True / False
|
ZBY<d> / ZBYR<d>
|
Bypass / restore | bypass |
True / False
|
ZTA<d> / ZTR<d>
|
Tamper / restore | tamper |
True / False
|
Areas — fixed letter, callback callback_area_state_change
| Literal | Attribute | Area |
|---|---|---|
AA / AB
|
armed = True |
A / B |
SA / SB
|
stay_armed = True |
A / B |
DA / DB
|
disarmed = True |
A / B |
EAA / EAB
|
exit_delay = True |
A / B |
ESA / ESB
|
stay_exit_delay = True |
A / B |
Area messages are exclusive: the other flags are cleared first. D* also clears alarm and
alarm_zone.
Outputs — callback callback_output_state_change: OO<d> sets open = True, OC<d> sets
open = False.
System — callback callback_system_state_change
| Fail | Restore | Attribute |
|---|---|---|
MF |
MR |
mains |
BF |
BR |
battery |
TF |
TR |
tamper (fail → True) |
LF |
LR |
line |
DF |
DR |
dialler |
FF |
FR |
fuse |
ZBF |
ZBR |
zonebattery |
PBF |
PBR |
pendantbattery |
CTF |
CTR |
codetamper (fail → True) |
| — |
RO / NR
|
ready |
TFstoresTrue, matchingBinarySensorDeviceClass.TAMPERsemantics where "on" means sabotage detected. Note the asymmetric polarity versus the fail/restore pattern.
zone_state[n] = {"status": {"open": False, "bypass": False, "alarm": False, "tamper": False},
"last_fault": 0}
area_state[n] = {"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[n] = {"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}}Default polarity: mains/line/dialler/battery are True (healthy); tamper flags are False.
OO<n> is a toggle, not "set on". The switch platform caches the last known state and guards its
own calls so a repeated turn_on is a no-op, which prevents an accidental double-toggle.
There is no "arm with code" command. Depending on the firmware and how the panel is programmed,
either the bare ARM / STAY completes the arming, or the panel then waits for the user code
followed by Enter. Both behaviours exist in the field, so the integration makes it a per-entry option:
arm_sequence |
Wire sequence | Rationale |
|---|---|---|
command_then_keypad (default) |
ARM (or STAY ) then KEYS <code>E
|
The panel arms only 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. |
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 line is
byte-identical to the one disarm() sends, which is why the same wire format serves both "finish
arming" and "disarm": the panel decides from its current state.
<code> is the code argument when the caller supplies one, otherwise the area code from the area
options. 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; with no
code available it logs an error and sends nothing.
arm_sequence is rendered by a SelectSelector with translation_key="arm_sequence", so its option
labels live in the top-level selector.arm_sequence.options block of strings.json and all five
translations rather than in the step's data section.
| Platform | Class | unique_id |
Device | Key state mapping |
|---|---|---|---|---|
alarm_control_panel |
CrowAlarmPanel |
{entry_id}_crow_area_{n} |
hub |
alarm→TRIGGERED, armed→ARMED_AWAY, stay_armed→ARMED_HOME, exit delays →ARMING, disarmed→DISARMED; arming follows arm_sequence (§3.6) |
binary_sensor |
CrowZoneSensor |
crow_zone_{n} |
Windows / Doors / Sensors sub-device | zone_state[n].status.open |
binary_sensor |
CrowSystemStatusSensor |
crow_sys_{key} |
hub | see below |
sensor |
CrowSystemSensor |
crow_system_status_text |
hub |
ALARM · Armed Away · Armed Stay · Exit Delay · Stay Exit Delay · Power Failure · Low Battery · Ready
|
sensor |
CrowAlarmZoneSensor |
crow_alarm_zone_area_{n} |
hub |
"Zone <n>" from alarm_zone, else "None"
|
switch |
CrowOutput |
crow_output_{n} |
hub | output_state[n].status.open |
button |
CrowChimeButton |
crow_toggle_chime |
hub | — (CHIME) |
button |
CrowRelayButton |
crow_relay_{n} |
hub | — (RL1 / RL2) |
System status sensors (entity_category = DIAGNOSTIC):
unique_id |
Name | Device class | is_on |
|---|---|---|---|
crow_sys_mains |
Mains Power | POWER |
mains |
crow_sys_battery |
System Battery | BATTERY |
not battery |
crow_sys_tamper |
System Tamper | TAMPER |
tamper |
crow_sys_line |
Phone Line | CONNECTIVITY |
line |
crow_sys_dialler |
Dialler | CONNECTIVITY |
dialler |
crow_sys_zonebattery |
Zone Battery | BATTERY |
not zonebattery |
Battery classes are inverted because the protocol reports battery OK while Home Assistant expects
on = problem. Missing keys default to True (healthy).
Zone type → device class: window→WINDOW, door→DOOR, motion→MOTION, smoke→SMOKE,
gas→GAS, co→CO, tamper→TAMPER, safety→SAFETY. Unknown types log a warning and get no device
class.
Shared behaviour: should_poll = False, has_entity_name = True,
available = controller.is_connected, and device_info always built by build_device_info().
| Device | Identifier | Model | Linked to hub |
|---|---|---|---|
| Crow Alarm System | crow_alarm_panel |
IP Module |
— (the hub) |
| Crow Alarm Windows | crow_windows |
IP Module Zone |
via_device_id |
| Crow Alarm Doors | crow_doors |
IP Module Zone |
via_device_id |
| Crow Alarm Sensors | crow_sensors |
IP Module Zone |
via_device_id |
Identifier strings are frozen. Changing one duplicates every existing device.
| Step | Fields |
|---|---|
user |
host, port, keepalive_interval, timeout, firmware_version, number_of_areas, number_of_outputs, number_of_zones
|
areas |
area_{i}_name (required), area_{i}_code (optional) |
outputs |
output_{i}_name — skipped entirely when the output count is 0 |
zones |
zone_{i}_name, zone_{i}_type — 4 zones per page (PAGE_SIZE = 4) |
- Unique ID is
"{host}_{port}"; a duplicate aborts withalready_configured. - A blocking TCP probe (
socket.create_connection, run in the executor) runs before the entry is created. Failure renderscannot_connect; no entry is created. - The options flow uses the framework-provided
self.config_entryand takes no constructor arguments (the pattern supported sinceOptionsFlow(config_entry)was deprecated). Finishing it reloads the entry. -
strings.jsonis mirrored byte-for-byte intotranslations/en.json;de,es,fr,itcover every step.
| Layer | Policy |
|---|---|
| Parser | Unknown lines ignored at debug level; never raises |
| Handlers / callbacks | Wrapped in try/except, logged, never propagate (protects the worker thread) |
| Command send | Failure closes the socket so the supervisor reconnects |
| Entity commands |
try/except + _LOGGER.error; never raise into the service call |
| Setup | Constructor failure → False; probe failure → form error |
| Unload | Must not raise; guards a missing runtime_data
|
| Layer | Artefact | Covers |
|---|---|---|
| Protocol | test_crow_protocol.py |
Command wire formats, RX parsing, state handlers, loopback socket |
| HA contract | tests/verify_ha_2026_contract.py |
Every platform's setup; no via_device; via_device_id linkage; all seven alarm_state values; configuration_url sanitising |
| Config flow | tests/verify_config_flow.py |
Full 5-step flow, pagination, options flow, translation coverage |
| Static | python -m compileall custom_components |
Syntax |
python -m compileall custom_components
python test_crow_protocol.py
python tests/verify_ha_2026_contract.py
python tests/verify_config_flow.pyInstall voluptuous into tests/vendor (or any importable location) to run the config-flow harness.
-
pycrowipmodule/must stay free ofhomeassistantimports. - Never call Home Assistant APIs from the panel thread — use
_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
strings.jsonand all five translations. - Run all four verification commands before opening a pull request.
{ "areas": { "1": { "name": "House", "code": "1234", "code_arm_required": true } }, "outputs": { "1": { "name": "Gate" } }, "zones": { "1": { "name": "Front Door", "type": "door" } } }