Skip to content

Commit a6b5f59

Browse files
committed
Add Combus module PGM control (MODULE_PGM_ADDRESSES)
Implements control of PGMs on Paradox expansion modules. (PGM4, zone extenders with PGM outputs, etc.) using the 0xA4 command class. Paradox handles "Module" PGMs differently to "Panel" PGMs which are already supported in pai. Protocol: - TX: A4 17 00 <module_addr> 00 00 <pgm1> <pgm2> <pgm3> <pgm4> 00*12 <cs> - RX: A2 07 00 <module_addr> 00 00 <cs> - Module address = Babyware enrollment slot index (1-254) - No RAM status block; state tracked optimistically after each command Changes: - parsers.py: PerformModulePGMAction + PerformModulePGMActionResponse structs, MODULE_PGM_OUTPUTS_PER_MODULE constant - panel.py (evo): parse_message dispatch for 0xA4/0xA, control_module_pgm_outputs() - panel.py (base): abstract control_module_pgm_outputs stub - config.py: MODULE_PGM_ADDRESSES dict config (address -> pgm_count) - paradox.py: _init_module_pgms(), extended control_output() for module_pgm container - helpers.py: module_pgm topic mapping - homeassistant.py: _publish_module_pgm_configs() for HA autodiscovery - switch.py: ModulePGMSwitch with on_override/off_override payloads - factory.py: make_module_pgm_switch() - test_module_pgm_action.py: 16 tests covering build and parse against captured frames
1 parent 636b4a5 commit a6b5f59

11 files changed

Lines changed: 353 additions & 25 deletions

File tree

paradox/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class Config:
5757
"KEEP_ALIVE_INTERVAL": 10, # Interval between status updates
5858
"IO_TIMEOUT": 0.5, # Timeout for IO operations
5959
"LIMITS": {}, # By default all zones will be monitored
60+
"MODULE_PGM_ADDRESSES": ({}, dict, None), # Map of bus module address -> pgm count, e.g. {4: 4}
6061
"LABEL_ENCODING": "paradox-en", # Encoding to use when decoding labels. paradox-* or https://docs.python.org/3/library/codecs.html#standard-encodings
6162
"LABEL_REFRESH_INTERVAL": (
6263
15 * 60,

paradox/hardware/evo/panel.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ def parse_message(
128128
return parsers.PerformPartitionAction.parse(message)
129129
elif message[0] == 0xD0:
130130
return parsers.PerformZoneAction.parse(message)
131+
elif message[0] == 0xA4:
132+
return parsers.PerformModulePGMAction.parse(message)
131133
else:
132134
if message[0] >> 4 == 0x7:
133135
return parsers.ErrorMessage.parse(message)
@@ -139,6 +141,8 @@ def parse_message(
139141
return parsers.PerformActionResponse.parse(message)
140142
elif message[0] >> 4 == 0xD:
141143
return parsers.PerformZoneActionResponse.parse(message)
144+
elif message[0] >> 4 == 0xA:
145+
return parsers.PerformModulePGMActionResponse.parse(message)
142146
# elif message[0] == 0x50 and message[2] == 0x80:
143147
# return PanelStatus.parse(message)
144148
# elif message[0] == 0x50 and message[2] < 0x80:
@@ -304,6 +308,32 @@ async def control_outputs(self, outputs, command) -> bool:
304308
logger.info('PGM command: "%s" failed' % command)
305309
return reply is not None
306310

311+
async def control_module_pgm_outputs(self, module_address: int, pgm_index: int, command: str) -> bool:
312+
"""
313+
Control PGM module outputs
314+
:param int module_address: bus address of the PGM module
315+
:param int pgm_index: 1-4 index of the PGM output
316+
:param str command: textual command
317+
:return: True if accepted
318+
"""
319+
pgm_commands = ["release"] * parsers.MODULE_PGM_OUTPUTS_PER_MODULE
320+
pgm_commands[pgm_index - 1] = command
321+
322+
args = {"module_address": module_address, "pgm_commands": pgm_commands}
323+
try:
324+
reply = await self.core.send_wait(
325+
parsers.PerformModulePGMAction, args, reply_expected=0xA
326+
)
327+
except MappingError:
328+
logger.error('Module PGM command: "%s" is not supported' % command)
329+
return False
330+
331+
if reply:
332+
logger.info('Module PGM command: "%s" succeeded' % command)
333+
else:
334+
logger.info('Module PGM command: "%s" failed' % command)
335+
return reply is not None
336+
307337
async def control_doors(self, doors, command) -> bool:
308338
"""
309339
Control Doors

paradox/hardware/evo/parsers.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,42 @@ def _parse(self, stream, context, path):
823823
"checksum" / PacketChecksum(Bytes(1)),
824824
)
825825

826+
MODULE_PGM_OUTPUTS_PER_MODULE = 4
827+
828+
PerformModulePGMAction = Struct(
829+
"fields"
830+
/ RawCopy(
831+
Struct(
832+
"po" / Struct("command" / Const(0xA4, Int8ub)),
833+
"packet_length" / PacketLength(Int8ub),
834+
"_not_used0" / Padding(1),
835+
"module_address" / Default(Int8ub, 0),
836+
"_not_used1" / Padding(2),
837+
"pgm_commands" / Array(MODULE_PGM_OUTPUTS_PER_MODULE, Default(_PGMCommandEnum, "release")),
838+
"_not_used2" / Padding(12),
839+
)
840+
),
841+
"checksum" / PacketChecksum(Bytes(1)),
842+
)
843+
844+
PerformModulePGMActionResponse = Struct(
845+
"fields"
846+
/ RawCopy(
847+
Struct(
848+
"po"
849+
/ BitStruct(
850+
"command" / Const(0xA, Nibble),
851+
"_not_used" / Nibble,
852+
),
853+
"packet_length" / PacketLength(Int8ub),
854+
"_not_used0" / Padding(1),
855+
"module_address" / Int8ub,
856+
"_not_used1" / Padding(2),
857+
)
858+
),
859+
"checksum" / PacketChecksum(Bytes(1)),
860+
)
861+
826862
_PGMBroadcastCommandEnum = Enum(
827863
Int8ub,
828864
no_change=0,

paradox/hardware/panel.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,10 @@ def control_partitions(self, partitions, command) -> bool:
357357
def control_outputs(self, outputs, command) -> bool:
358358
raise NotImplementedError("override control_outputs in a subclass")
359359

360+
@abstractmethod
361+
def control_module_pgm_outputs(self, module_address, pgm_index, command) -> bool:
362+
raise NotImplementedError("override control_module_pgm_outputs in a subclass")
363+
360364
@abstractmethod
361365
def control_doors(self, doors, command) -> bool:
362366
raise NotImplementedError("override control_doors in a subclass")

paradox/interfaces/mqtt/entities/factory.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from paradox.interfaces.mqtt.entities.binary_sensors import ZoneStatusBinarySensor, \
33
SystemBinarySensor, PartitionBinarySensor
44
from paradox.interfaces.mqtt.entities.sensor import PAIStatusSensor, SystemStatusSensor, ZoneNumericSensor
5-
from paradox.interfaces.mqtt.entities.switch import ZoneBypassSwitch, PGMSwitch
5+
from paradox.interfaces.mqtt.entities.switch import ZoneBypassSwitch, PGMSwitch, ModulePGMSwitch
66

77

88
class MQTTAutodiscoveryEntityFactory:
@@ -34,6 +34,9 @@ def make_zone_status_numeric_sensor(self, zone, status):
3434
def make_pgm_switch(self, pgm):
3535
return PGMSwitch(pgm, self.device, self.availability_topic)
3636

37+
def make_module_pgm_switch(self, module_pgm):
38+
return ModulePGMSwitch(module_pgm, self.device, self.availability_topic)
39+
3740
def make_system_status(self, system_key, status):
3841
if system_key == 'troubles':
3942
return SystemBinarySensor(system_key, status, self.device, self.availability_topic)

paradox/interfaces/mqtt/entities/switch.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,23 @@ def __init__(self, pgm, device, availability_topic: str):
4747
self.property = "on"
4848

4949
self.pai_entity_type = "pgm"
50+
51+
52+
class ModulePGMSwitch(Switch):
53+
def __init__(self, module_pgm, device, availability_topic: str):
54+
super().__init__(device, availability_topic)
55+
self.module_pgm = module_pgm
56+
57+
self.key = sanitize_key(module_pgm["key"])
58+
self.label = module_pgm["label"]
59+
self.property = "on"
60+
61+
self.pai_entity_type = "pgm"
62+
63+
def serialize(self):
64+
config = super().serialize()
65+
config.update(dict(
66+
payload_on="on_override",
67+
payload_off="off_override",
68+
))
69+
return config

paradox/interfaces/mqtt/helpers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
zone=cfg.MQTT_ZONE_TOPIC,
66
output=cfg.MQTT_OUTPUT_TOPIC,
77
pgm=cfg.MQTT_OUTPUT_TOPIC,
8+
module_pgm=cfg.MQTT_OUTPUT_TOPIC,
89
repeater=cfg.MQTT_REPEATER_TOPIC,
910
bus=cfg.MQTT_BUS_TOPIC,
1011
module=cfg.MQTT_MODULE_TOPIC,

paradox/interfaces/mqtt/homeassistant.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def __init__(self, alarm):
2525
self.partitions = {}
2626
self.zones = {}
2727
self.pgms = {}
28+
self.module_pgms = {}
2829

2930
self.entity_factory = MQTTAutodiscoveryEntityFactory(
3031
self.mqtt.availability_topic
@@ -69,6 +70,7 @@ def _handle_labels_loaded(self, data):
6970

7071
self.zones = data.get("zone", {})
7172
self.pgms = data.get("pgm", {})
73+
self.module_pgms = data.get("module_pgm", {})
7274

7375
def _publish_when_ready(self, panel: DetectedPanel, status):
7476
self.entity_factory.set_device(Device(panel))
@@ -80,6 +82,9 @@ def _publish_when_ready(self, panel: DetectedPanel, status):
8082
self._publish_zone_configs(status["zone"])
8183
if "pgm" in status:
8284
self._publish_pgm_configs(status["pgm"])
85+
module_pgms = dict(self.alarm.storage.get_container("module_pgm"))
86+
if module_pgms:
87+
self._publish_module_pgm_configs(module_pgms)
8388
if "system" in status:
8489
self._publish_system_property_configs(status["system"])
8590

@@ -162,6 +167,11 @@ def _publish_pgm_configs(self, pgm_statuses):
162167
pgm_switch_config = self.entity_factory.make_pgm_switch(pgm)
163168
self._publish_config(pgm_switch_config)
164169

170+
def _publish_module_pgm_configs(self, module_pgms):
171+
for module_pgm_key, module_pgm in module_pgms.items():
172+
module_pgm_switch_config = self.entity_factory.make_module_pgm_switch(module_pgm)
173+
self._publish_config(module_pgm_switch_config)
174+
165175
def _publish_system_property_configs(self, system_statuses):
166176
for system_key, system_status in system_statuses.items():
167177
for property_name in system_status:

paradox/paradox.py

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ async def full_connect(self) -> bool:
216216

217217
logger.info("Loading data from panel memory")
218218
await self.panel.load_memory()
219+
self._init_module_pgms()
219220

220221
logger.info("Running")
221222
self.run_state = RunState.RUN
@@ -489,33 +490,61 @@ async def control_partition(self, partition: str, command: str) -> bool:
489490

490491
return accepted
491492

493+
def _init_module_pgms(self):
494+
for addr, pgm_count in cfg.MODULE_PGM_ADDRESSES.items():
495+
for pgm_index in range(1, pgm_count + 1):
496+
key = f"module{addr}_pgm{pgm_index}"
497+
self.storage.get_container("module_pgm")[key] = {
498+
"id": pgm_index,
499+
"key": key,
500+
"label": f"Module {addr} PGM {pgm_index}",
501+
"module_address": addr,
502+
"pgm_index": pgm_index,
503+
}
504+
self.storage.update_container_object("module_pgm", key, {"on": False})
505+
492506
async def control_output(self, output, command) -> bool:
493507
command = command.lower()
494508
logger.debug(f"Control Output: {output} - {command}")
495509

496510
outputs_selected = self.storage.get_container("pgm").select(output)
497-
498-
# Not Found
499-
if len(outputs_selected) == 0:
500-
logger.error("No outputs selected")
501-
return False
502-
503-
# Apply state changes
504-
accepted = False
505-
try:
506-
accepted = await self.panel.control_outputs(outputs_selected, command)
507-
except NotImplementedError:
508-
logger.error("control_output is not implemented for this alarm type")
509-
except asyncio.CancelledError:
510-
logger.error("control_output canceled")
511-
except asyncio.TimeoutError:
512-
logger.error("control_output timeout")
513-
# Apply state changes
514-
515-
# Refresh status
516-
self.request_status_refresh() # Trigger status update
517-
518-
return accepted
511+
if outputs_selected:
512+
accepted = False
513+
try:
514+
accepted = await self.panel.control_outputs(outputs_selected, command)
515+
except NotImplementedError:
516+
logger.error("control_output is not implemented for this alarm type")
517+
except asyncio.CancelledError:
518+
logger.error("control_output canceled")
519+
except asyncio.TimeoutError:
520+
logger.error("control_output timeout")
521+
self.request_status_refresh()
522+
return accepted
523+
524+
module_pgm_selected = self.storage.get_container("module_pgm").select(output)
525+
if module_pgm_selected:
526+
accepted = False
527+
module_pgm_container = self.storage.get_container("module_pgm")
528+
for key in module_pgm_selected:
529+
out = module_pgm_container[key]
530+
try:
531+
accepted = await self.panel.control_module_pgm_outputs(
532+
out["module_address"], out["pgm_index"], command
533+
)
534+
except NotImplementedError:
535+
logger.error("control_module_pgm_outputs is not implemented for this alarm type")
536+
except asyncio.CancelledError:
537+
logger.error("control_output canceled")
538+
except asyncio.TimeoutError:
539+
logger.error("control_output timeout")
540+
if accepted:
541+
is_on = command in ("on", "on_override")
542+
self.storage.update_container_object("module_pgm", out["key"], {"on": is_on})
543+
self.request_status_refresh()
544+
return accepted
545+
546+
logger.error("No outputs selected")
547+
return False
519548

520549
async def send_panic(self, partition_id, panic_type, user_id) -> bool:
521550
logger.debug(

0 commit comments

Comments
 (0)