Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 46 additions & 8 deletions concord232/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import Any

from concord232 import concord
from concord232.mqtt_events import PanelMqttPublisher
from concord232.mqtt_events import PanelMqttPublisher, make_zone_handler
from concord232.server import api

try:
Expand All @@ -29,6 +29,8 @@ def _setup_mqtt(
topic_prefix: str,
client_id: str,
publish_touchpad: bool,
publish_zones: bool,
discovery_prefix: str,
tls: bool,
logger: logging.Logger,
) -> None:
Expand Down Expand Up @@ -58,27 +60,39 @@ def _setup_mqtt(
client.username_pw_set(username, password)
if tls:
client.tls_set(tls_version=ssl.PROTOCOL_TLS_CLIENT)
try:
client.connect(host, port, 60)
except Exception:
logger.exception("MQTT connect failed; continuing without MQTT")
return
client.loop_start()
publisher = PanelMqttPublisher(
client,
topic_prefix,
publish_touchpad=publish_touchpad,
publish_zones=publish_zones,
discovery_prefix=discovery_prefix,
logger=logger,
)
# Last will marks us offline (retained) so HA availability tracks reality.
client.will_set(
publisher.status_topic, publisher.offline_payload(), qos=1, retain=True
)
try:
client.connect(host, port, 60)
except Exception:
logger.exception("MQTT connect failed; continuing without MQTT")
return
client.loop_start()
publisher.publish_online()
ctrl.register_message_handler("ALARM", publisher.publish_alarm)
if publish_touchpad:
ctrl.register_message_handler("TOUCHPAD", publisher.publish_touchpad)
if publish_zones:
zone_handler = make_zone_handler(publisher, ctrl.zones)
ctrl.register_message_handler("ZONE_STATUS", zone_handler)
ctrl.register_message_handler("ZONE_DATA", zone_handler)
logger.info(
"MQTT panel events enabled prefix=%s host=%s:%s",
"MQTT panel events enabled prefix=%s host=%s:%s zones=%s discovery=%s",
topic_prefix,
host,
port,
publish_zones,
discovery_prefix or "off",
)


Expand Down Expand Up @@ -172,6 +186,22 @@ def main() -> None:
action="store_true",
help="Do not publish TOUCHPAD messages to MQTT",
)
parser.add_argument(
"--mqtt-zones",
default=False,
action="store_true",
help="Publish retained per-zone states to MQTT (or use [mqtt] zones in config)",
)
parser.add_argument(
"--mqtt-discovery-prefix",
default=None,
metavar="PREFIX",
help=(
"Publish Home Assistant MQTT discovery configs for zones under this "
"prefix (typically 'homeassistant'); requires --mqtt-zones "
"(or use [mqtt] discovery_prefix in config)"
),
)
parser.add_argument(
"--mqtt-tls",
default=False,
Expand Down Expand Up @@ -218,6 +248,12 @@ def main() -> None:
and config.has_option("mqtt", "tls")
):
mqtt_tls = config.getboolean("mqtt", "tls")
mqtt_zones = args.mqtt_zones
if not mqtt_zones and "mqtt" in config and config.has_option("mqtt", "zones"):
mqtt_zones = config.getboolean("mqtt", "zones")
mqtt_discovery_prefix = args.mqtt_discovery_prefix
if mqtt_discovery_prefix is None:
mqtt_discovery_prefix = mqtt_cfg.get("discovery_prefix") or ""

LOG = logging.getLogger()
LOG.setLevel(logging.DEBUG)
Expand Down Expand Up @@ -277,6 +313,8 @@ def main() -> None:
topic_prefix=str(mqtt_topic_prefix),
client_id=str(mqtt_client_id),
publish_touchpad=mqtt_publish_touchpad,
publish_zones=mqtt_zones,
discovery_prefix=str(mqtt_discovery_prefix),
tls=mqtt_tls,
logger=LOG,
)
Expand Down
113 changes: 111 additions & 2 deletions concord232/mqtt_events.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""
MQTT publishing for Concord panel events (ALARM / TOUCHPAD), schema version 1.
MQTT publishing for Concord panel events (ALARM / TOUCHPAD) and retained
zone states, schema version 1.
"""

from __future__ import annotations

import json
import logging
from collections.abc import Callable, Mapping
from datetime import datetime, timezone
from typing import Any, Mapping, Optional
from typing import Any, Optional

LOG = logging.getLogger(__name__)

Expand Down Expand Up @@ -68,6 +70,49 @@ def build_touchpad_payload(
return body


def build_zone_payload(
zone: Mapping[str, Any],
*,
received_at: datetime | None = None,
) -> dict[str, Any]:
"""Mirror of the ``/zones`` REST field names, plus a ``tripped`` bool."""
when = received_at if received_at is not None else _utc_now()
state = list(zone.get("zone_state") or [])
return {
"schema_version": SCHEMA_VERSION,
"partition": zone.get("partition_number"),
"area": zone.get("area_number"),
"group": zone.get("group_number"),
"number": zone.get("zone_number"),
"name": zone.get("zone_text") or "",
"state": state,
"type": zone.get("zone_type"),
"tripped": "Tripped" in state,
"received_at": _iso(when),
}


def make_zone_handler(
publisher: PanelMqttPublisher,
zones: Mapping[str, Mapping[str, Any]],
) -> Callable[[Mapping[str, Any]], None]:
"""Build a ZONE_STATUS / ZONE_DATA message handler.

The decoded ZONE_STATUS message lacks name/type/group, so publish the
merged record from the panel interface's live ``zones`` store (updated
by the command parsers before handlers run), falling back to the
decoded message for zones not yet in the store.
"""

def _handle(decoded: Mapping[str, Any]) -> None:
identifier = (
f"p{decoded.get('partition_number')}z{decoded.get('zone_number')}"
)
publisher.publish_zone(zones.get(identifier, decoded))

return _handle


class PanelMqttPublisher:
"""Publishes panel decode dicts to MQTT topics under a prefix."""

Expand All @@ -77,17 +122,26 @@ def __init__(
topic_prefix: str,
*,
publish_touchpad: bool = True,
publish_zones: bool = False,
discovery_prefix: str | None = None,
logger: Optional[logging.Logger] = None,
) -> None:
self._client = client
self._prefix = topic_prefix.strip().strip("/")
# Must not shadow method publish_touchpad (used as a message handler callback).
self._touchpad_enabled = publish_touchpad
self._zones_enabled = publish_zones
self._discovery_prefix = (discovery_prefix or "").strip().strip("/")
self._discovered_zones: set[int] = set()
self._log = logger or LOG

def _topic(self, *parts: str) -> str:
return "/".join((self._prefix,) + parts)

@property
def status_topic(self) -> str:
return self._topic("status")

def publish_online(self) -> None:
payload = json.dumps(
{"schema_version": SCHEMA_VERSION, "state": "online"},
Expand All @@ -110,6 +164,61 @@ def publish_touchpad(self, decoded: Mapping[str, Any]) -> None:
payload = build_touchpad_payload(decoded)
self._publish_json(self._topic("event", "touchpad"), payload, retain=False)

def publish_zone(self, zone: Mapping[str, Any]) -> None:
"""Publish a retained per-zone state (and discovery config, once)."""
if not self._zones_enabled:
return
payload = build_zone_payload(zone)
number = payload.get("number")
if number is None:
return
self._publish_json(
self._topic("zone", str(number), "state"), payload, retain=True
)
if self._discovery_prefix:
self._publish_zone_discovery(payload)

def _publish_zone_discovery(self, payload: Mapping[str, Any]) -> None:
"""Publish a Home Assistant MQTT discovery config for a zone, once."""
number = payload["number"]
if number in self._discovered_zones:
return
name = str(payload.get("name") or "").strip() or f"Zone {number}"
config = {
"name": name.title() if name.isupper() else name,
"unique_id": f"concord232_zone_{number}",
"state_topic": self._topic("zone", str(number), "state"),
"value_template": "{{ 'ON' if value_json.tripped else 'OFF' }}",
"json_attributes_topic": self._topic("zone", str(number), "state"),
"availability": [
{
"topic": self._topic("status"),
"value_template": "{{ value_json.state }}",
"payload_available": "online",
"payload_not_available": "offline",
}
],
"device": {
"identifiers": ["concord232"],
"name": "Concord232",
"manufacturer": "GE Interlogix",
"model": "Concord 4",
},
}
topic = (
f"{self._discovery_prefix}/binary_sensor/concord232/"
f"zone_{number}/config"
)
self._publish_json(topic, config, retain=True)
self._discovered_zones.add(number)

def offline_payload(self) -> str:
"""Retained last-will payload marking the publisher offline."""
return json.dumps(
{"schema_version": SCHEMA_VERSION, "state": "offline"},
separators=(",", ":"),
)

def _publish_json(self, topic: str, payload: Mapping[str, Any], retain: bool) -> None:
try:
body = json.dumps(payload, default=str, separators=(",", ":"))
Expand Down
2 changes: 1 addition & 1 deletion docs-site/docs/mqtt-panel-events-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Use a configurable **topic prefix** (default suggestion: `concord232` or `home/c
| `{prefix}/status` | yes | JSON: `online` / connection state; optional `last_error` (broker LWT can mirror `offline`). |
| `{prefix}/event/alarm` | no | One message per decoded `ALARM` / trouble frame. |
| `{prefix}/event/touchpad` | no | One message per `TOUCHPAD` update (may be frequent; consider optional throttle later). |
| `{prefix}/event/zone` | no | *Optional phase:* zone snapshot deltas if we want parity with HTTP without polling. |
| `{prefix}/zone/{number}/state` | yes | *Implemented (opt-in `--mqtt-zones` / `[mqtt] zones`):* retained per-zone state mirroring the `/zones` REST fields plus a `tripped` boolean, published on every ZONE_STATUS / ZONE_DATA message (bootstrap publishes all zones). With `--mqtt-discovery-prefix` (typically `homeassistant`) a retained Home Assistant MQTT discovery config is published once per zone, so zone binary sensors appear automatically with availability tied to `{prefix}/status` (LWT marks `offline`). HTTP-parity zone state without polling. |

Version the payload with a top-level **`schema_version`** integer (start at `1`).

Expand Down
107 changes: 107 additions & 0 deletions tests/concord/test_mqtt_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,110 @@ def test_publish_online_retained_status() -> None:
assert args[0] == "concord232/status"
assert json.loads(args[1])["state"] == "online"
assert kwargs.get("retain") is True


def test_build_zone_payload_mirrors_rest_fields() -> None:
from concord232.mqtt_events import build_zone_payload

zone = {
"partition_number": 1,
"area_number": 0,
"group_number": 10,
"zone_number": 1,
"zone_type": "Hardwired",
"zone_state": ["Tripped"],
"zone_text": "FRONT DOOR",
}
body = build_zone_payload(
zone, received_at=datetime(2026, 4, 13, 12, 0, 0, tzinfo=timezone.utc)
)
assert body["schema_version"] == 1
assert body["partition"] == 1
assert body["group"] == 10
assert body["number"] == 1
assert body["name"] == "FRONT DOOR"
assert body["state"] == ["Tripped"]
assert body["type"] == "Hardwired"
assert body["tripped"] is True
assert body["received_at"] == "2026-04-13T12:00:00+00:00"


def test_build_zone_payload_tripped_with_multiple_states() -> None:
from concord232.mqtt_events import build_zone_payload

body = build_zone_payload({"zone_number": 2, "zone_state": ["Trouble", "Tripped"]})
assert body["tripped"] is True
body = build_zone_payload({"zone_number": 2, "zone_state": ["Normal"]})
assert body["tripped"] is False


def test_publish_zone_retained_state_and_single_discovery() -> None:
client = MagicMock()
pub = PanelMqttPublisher(
client,
"concord232",
publish_zones=True,
discovery_prefix="homeassistant",
)
zone = {
"partition_number": 1,
"zone_number": 1,
"zone_state": ["Tripped"],
"zone_text": "FRONT DOOR",
"zone_type": "Hardwired",
}
pub.publish_zone(zone)
pub.publish_zone(zone)

calls = client.publish.call_args_list
state_calls = [c for c in calls if c.args[0] == "concord232/zone/1/state"]
config_calls = [
c
for c in calls
if c.args[0] == "homeassistant/binary_sensor/concord232/zone_1/config"
]
assert len(state_calls) == 2
assert all(c.kwargs["retain"] is True for c in state_calls)
assert len(config_calls) == 1
config = json.loads(config_calls[0].args[1])
assert config["unique_id"] == "concord232_zone_1"
assert config["name"] == "Front Door"
assert config["state_topic"] == "concord232/zone/1/state"
assert "value_json.tripped" in config["value_template"]
assert config["availability"][0]["topic"] == "concord232/status"
state = json.loads(state_calls[0].args[1])
assert state["tripped"] is True


def test_publish_zone_noop_when_disabled() -> None:
client = MagicMock()
pub = PanelMqttPublisher(client, "concord232", publish_zones=False)
pub.publish_zone({"zone_number": 1, "zone_state": ["Normal"]})
client.publish.assert_not_called()


def test_make_zone_handler_prefers_merged_record() -> None:
from concord232.mqtt_events import make_zone_handler

client = MagicMock()
pub = PanelMqttPublisher(client, "concord232", publish_zones=True)
zones = {
"p1z1": {
"partition_number": 1,
"zone_number": 1,
"zone_state": ["Tripped"],
"zone_text": "FRONT DOOR",
"zone_type": "Hardwired",
}
}
handler = make_zone_handler(pub, zones)
# ZONE_STATUS decode carries no name; the merged record must win.
handler({"partition_number": 1, "zone_number": 1, "zone_state": ["Tripped"]})
state = json.loads(client.publish.call_args_list[0].args[1])
assert state["name"] == "FRONT DOOR"

# Unknown zone falls back to the decoded message.
handler({"partition_number": 1, "zone_number": 9, "zone_state": ["Normal"]})
fallback = json.loads(client.publish.call_args_list[-1].args[1])
assert fallback["number"] == 9
assert fallback["name"] == ""
Loading