A custom integration that polls the keypad web interface (keypad.cgx) and
lets you send key presses (also via keypad.cgx — see Protocol notes
below) from Home Assistant.
- Sensor: Display — the current keypad display text (all
<text>lines joined). Each line is also available individually in thelinesattribute (keyed byrtm500,rtm501, etc.). - Sensor: Alarm Status —
ok,alarm, orunknown, derived from the<bgcol>nav-key colour. Raw colour and the raw LED bitmask are exposed as attributes. - Binary sensors: Zone A–D — one per zone, decoded from the
vkledsbitmask (2 bits per zone). See Remaining assumptions below — you'll likely want to tune the on/off logic once you've seen real values. - Services:
ion40_alarm.send_key— send a single key.ion40_alarm.send_keys— send a sequence of keys (e.g. a PIN + accept), with a configurable delay between presses.
This repo isn't in the default HACS store, so add it as a custom repository:
- In Home Assistant, open HACS → Integrations.
- Click the ⋮ menu (top right) → Custom repositories.
- Add your repo URL (e.g.
https://github.com/nvvliet/ION-console), set the category to Integration, and click Add. - Find "i-ON40 Alarm Panel" in HACS and click Download.
- Restart Home Assistant.
- Go to Settings → Devices & Services → Add Integration, search for "i-ON40 Alarm Panel", and enter the panel's IP/host, port (default 80), and desired polling interval (default 5s).
Once installed via HACS, updates show up automatically when you push new tagged releases to the repo.
- Copy the
custom_components/ion40_alarmfolder into your Home Assistantconfig/custom_components/directory, so you end up with:config/custom_components/ion40_alarm/__init__.py config/custom_components/ion40_alarm/manifest.json ... - Restart Home Assistant.
- Go to Settings → Devices & Services → Add Integration, search for "i-ON40 Alarm Panel", and enter the panel's IP/host, port (default 80), and desired polling interval (default 5s).
- A device with the Display sensor, Alarm Status sensor, and four zone binary sensors will be created.
The 5-digit client UID is generated once during setup and stored in the config entry, so it stays stable across restarts (matching "generated once per client" in the panel's protocol).
Find your panel's config entry via the device picker in the service call UI (Developer Tools → Actions → search "i-ON40"). Example to unset/disarm with a 4-digit PIN:
action: ion40_alarm.send_keys
data:
entry_id: <your entry id>
keys: ["1", "2", "3", "4", "N"]
delay: 0.3To operate zone A–D keys, use lowercase: "a", "b", "c", "d"
(uppercase D is reserved for the nav "Down" key — see below).
A full example dashboard: a display card with a glowing gradient background, a nav cluster (arrows + cancel/accept/menu/unset), zone A–D indicators that turn red when active, and a full keypad grid (numbers + zones A–D).
Requires card-mod (install via HACS → Frontend → search "card-mod") for the display card's gradient/glow background and the red/gray zone-indicator colouring. The layout still works without it, just without those visual effects.
Every button below needs your panel's config entry ID in place of
YOUR_ENTRY_ID. To find it:
- Go to Developer Tools → Actions.
- Pick
ion40_alarm.send_keyin the action picker. - Select your panel in the Panel field.
- Switch to YAML mode (the
</>icon, top-right of that field/card). It will show something like:entry_id: 01HXXXXXXXXXXXXXXXXXXXXX key: "1"
- Copy that
entry_idvalue.
Then either paste it in place of every YOUR_ENTRY_ID below, or (easier for
future edits) use your editor's find-and-replace across the whole YAML block
before pasting it into Lovelace.
The YAML below uses placeholder entity IDs containing YOUR_PANEL_ID, e.g.
sensor.alarm_panel_YOUR_PANEL_ID_display,
sensor.alarm_panel_YOUR_PANEL_ID_alarm_status, and
binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_a (…_zone_b, _zone_c,
_zone_d). YOUR_PANEL_ID is a stand-in for whatever Home Assistant
actually slugified your panel's name into (often something derived from its
IP, e.g. 192_168_1_50, but it depends on the device/config entry name you
gave it) — it is not a value you type in verbatim.
To find your real entity IDs:
- Go to Developer Tools → States.
- In the entity filter box, type
alarm_panel(orion40). - You'll see a list of matching entities — note the exact ID for each:
- the Display sensor (
sensor.alarm_panel_..._display) - the Alarm Status sensor (
sensor.alarm_panel_..._alarm_status) - the four Zone binary sensors (
binary_sensor.alarm_panel_..._zone_athrough_zone_d)
- the Display sensor (
- Find-and-replace every
YOUR_PANEL_IDin the YAML below with the exact segment your entities use (copy it precisely — don't guess the slug).
If your entity IDs don't follow this alarm_panel_..._display pattern at
all (e.g. you renamed the device), just replace each full entity ID in the
YAML with the correct one from Developer Tools → States instead.
Edit your dashboard → ⋮ → Edit Dashboard → ⋮ → Raw configuration editor, and add this as a card:
type: vertical-stack
cards:
- type: markdown
content: >
<div>{{ states('sensor.alarm_panel_YOUR_PANEL_ID_display')
}}</div>
card_mod:
style: |
ha-card {
/* Glowing LED-style multi-color gradient background */
background: radial-gradient(circle, #00eecc 0%, #00bbcc 70%, #0099cc 100%);
box-shadow: 0 0 5px rgba(0, 0, 85, 0.6);
border: 2px solid #0000ff;
border-radius: 12px;
padding: 20px;
font-weight: bold;
font-size: 14pt;
font-type: "Menlo";
text-align: center
}
- type: horizontal-stack
cards:
- square: true
type: grid
cards:
- type: markdown
content: " "
text_only: true
- type: button
icon: mdi:chevron-up
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: U
- type: markdown
content: " "
text_only: true
- type: button
icon: mdi:chevron-left
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: L
- type: button
icon: mdi:checkbox-blank-circle-outline
name: " "
tap_action:
action: none
- type: button
icon: mdi:chevron-right
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: R
- type: markdown
content: " "
text_only: true
- type: button
icon: mdi:chevron-down
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: D
- type: markdown
content: " "
text_only: true
columns: 3
- type: vertical-stack
cards:
- type: markdown
content: >-
**Status:** {{ states('sensor.alarm_panel_YOUR_PANEL_ID_alarm_status') | upper
}}
- show_name: true
show_icon: true
show_state: false
type: glance
entities:
- entity: binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_a
card_mod:
style: |
:host {
{% if states('binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_a') == 'on' %}
--paper-item-icon-color: red;
--card-mod-icon-color: red;
color: red;
{% elif states('binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_a') == 'off' %}
--paper-item-icon-color: gray;
--card-mod-icon-color: gray;
color: darkblue;
{% else %}
--paper-item-icon-color: orange;
color: orange;
{% endif %}
}
name: A
icon: mdi:circle-slice-8
- entity: binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_b
card_mod:
style: |
:host {
{% if states('binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_b') == 'on' %}
--paper-item-icon-color: red;
--card-mod-icon-color: red;
color: red;
{% elif states('binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_b') == 'off' %}
--paper-item-icon-color: gray;
--card-mod-icon-color: gray;
color: darkblue;
{% else %}
--paper-item-icon-color: orange;
color: orange;
{% endif %}
}
name: B
icon: mdi:circle-slice-8
- entity: binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_c
card_mod:
style: |
:host {
{% if states('binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_c') == 'on' %}
--paper-item-icon-color: red;
--card-mod-icon-color: red;
color: red;
{% elif states('binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_c') == 'off' %}
--paper-item-icon-color: gray;
--card-mod-icon-color: gray;
color: darkblue;
{% else %}
--paper-item-icon-color: orange;
color: orange;
{% endif %}
}
name: C
icon: mdi:circle-slice-8
- entity: binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_d
card_mod:
style: |
:host {
{% if states('binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_d') == 'on' %}
--paper-item-icon-color: red;
--card-mod-icon-color: red;
color: red;
{% elif states('binary_sensor.alarm_panel_YOUR_PANEL_ID_zone_d') == 'off' %}
--paper-item-icon-color: gray;
--card-mod-icon-color: gray;
color: darkblue;
{% else %}
--paper-item-icon-color: orange;
color: orange;
{% endif %}
}
name: D
icon: mdi:circle-slice-8
state_color: true
- square: true
type: grid
columns: 4
cards:
- show_name: false
show_icon: true
type: button
icon: mdi:close
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: X
- show_name: false
show_icon: true
type: button
icon: mdi:check
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: T
- show_name: false
show_icon: true
type: button
icon: mdi:menu
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: E
- show_name: false
show_icon: true
type: button
icon: mdi:lock-open-variant
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "N"
- square: false
type: grid
cards:
- type: button
name: A
show_state: false
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: a
- type: button
name: "1"
show_state: false
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "1"
- type: button
name: "2"
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "2"
- type: button
name: "3"
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "3"
- show_name: true
show_icon: true
type: button
name: B
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: b
- type: button
name: "4"
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "4"
- type: button
name: "5"
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "5"
- type: button
name: "6"
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "6"
- show_name: true
show_icon: true
type: button
name: C
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: c
- type: button
name: "7"
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "7"
- type: button
name: "8"
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "8"
- type: button
name: "9"
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "9"
- show_name: true
show_icon: true
type: button
name: D
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: d
- type: button
name: "*"
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "*"
- type: button
name: "0"
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "0"
- type: button
name: "#"
tap_action:
action: call-service
service: ion40_alarm.send_key
service_data:
entry_id: YOUR_ENTRY_ID
key: "#"
columns: 4The display card only styles ha-card itself (background gradient, border,
glow) via card_mod. It does not attempt to style the text/font of the
display value.
The reason: the markdown card's rendered text sits two shadow-roots deep
(ha-card → ha-markdown → ha-markdown-element), and a flat card_mod: style: | string can only reach the first one (ha-card) — which is exactly
why the card's background/border/glow apply but text styling doesn't. In
principle card-mod supports piercing further in via a nested-selector style:
dict (see card-mod's README
for the $ shadow-root syntax), but the exact internal element names differ
across Home Assistant frontend versions, which made it unreliable here.
If you want to try it yourself: install card-mod, add debug_cardmod: true
to the card's card_mod: block, and check your browser console (F12) — it
logs each shadow-root-traversal step, showing you the actual element names
to target in your specific HA version. Alternatively, a
template card or a
custom type: custom:... LED/LCD card from HACS may get you a real
7-segment/LED look more reliably than fighting shadow-DOM boundaries with
card-mod.
Comparing against the panel's real keypad.cgi page (JS extracted from a
Safari web archive of it) confirmed two details:
-
Key presses go to
keypad.cgx, notvk.cgi. The page's ownkp(key)function never submits the<form action="vk.cgi">— that form is only a no-JS fallback. The real client appendssvkkey=<key><uid>&to a buffer that gets folded into the body of the next POST tokeypad.cgx, along withsvkp_uid=<uid>. This integration replicates that:async_send_keyPOSTs directly tokeypad.cgxwith both fields, and updates all entities from the state returned in that same response — no extra poll needed. -
Zone keys are lowercase:
kp('a'),kp('b'),kp('c'),kp('d')in the real markup — not uppercase. UppercaseA–Dare not used; uppercaseDis the nav "Down" key, distinct from lowercased(zone D). -
Alarm/ok status is NOT a
<bgcol>colour at all. A second web archive (captured with a forced telephone-line-fault condition active) let us pull the actual client-side logic, and it's different from the original spec's assumption:function dovkleds(){ ... if ((vkleds & 1024) || (vkleds & 2048)) navKeyColour(1); else navKeyColour(0); ... } function navKeyColour(col){ if (col == 0) { nk8.style.backgroundPosition = "0px 0px"; } // ok else { nk8.style.backgroundPosition = "-95px 0px"; } // alarm }
The nav key's ok/alarm appearance is driven by bits 1024 (
0x400) and 2048 (0x800) of the samevkledsinteger that also encodes the zone A–D states — it just flips a sprite-image position, not a CSS colour. This integration now uses that bitmask as the primary, protocol-confirmed signal for the Alarm Status sensor (alarmif either bit is set, otherwiseok). The<bgcol>/nk8colour-matching fromconst.py(OK_COLORS/ALARM_COLORS) is kept only as a fallback for firmware variants that might not exposevkleds, or for diagnostics — it's exposed as thenav_colorattribute on the Alarm Status sensor either way.
Most of the original guesses were confirmed against a real web archive of
the panel's keypad.cgi page (see Protocol notes above). What's left
unverified:
- XML root element: the panel's response snippets didn't show a wrapping
root tag, so the parser wraps whatever is returned in
<root>...</root>before parsing. If the real response already has a root element, this is harmless (just adds a level), but if it turns out to be a different shape you may need to adjustapi.py::_parse_state. - Zone bitmask meaning: the 2-bit value per zone (0–3) isn't documented,
so each
Zone A–Dbinary sensor currently just reports "on" if its 2-bit value is non-zero, and exposes the raw 0–3 value as an attribute. Once you observe what each of the 4 possible values actually means (e.g. ready / open / bypassed / alarm) you can refine theis_onlogic inbinary_sensor.py, or split each zone into a proper 4-state sensor.
.
├── custom_components/
│ └── ion40_alarm/
│ ├── __init__.py # setup/teardown, service registration
│ ├── api.py # HTTP + XML client for keypad.cgx
│ ├── binary_sensor.py # zone A-D binary sensors
│ ├── config_flow.py # UI setup flow (host/port/scan interval)
│ ├── const.py # constants, key list, colour assumptions
│ ├── coordinator.py # polling coordinator
│ ├── manifest.json
│ ├── sensor.py # display + alarm status sensors
│ ├── services.yaml # send_key / send_keys service schemas
│ ├── strings.json
│ └── translations/
│ └── en.json
├── .github/
│ └── workflows/
│ └── validate.yml # hassfest + HACS validation on push/PR
├── hacs.json # HACS repository metadata
├── LICENSE
└── README.md