Skip to content

Commit bd25594

Browse files
authored
Merge pull request #591 from CamW/dev
Add support for module PGM control
2 parents 636b4a5 + 7326036 commit bd25594

12 files changed

Lines changed: 377 additions & 27 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: 31 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,33 @@ 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+
assert 1 <= pgm_index <= parsers.MODULE_PGM_PACKET_SLOTS, "pgm_index must be between 1 and %d" % parsers.MODULE_PGM_PACKET_SLOTS
320+
pgm_commands = ["release"] * parsers.MODULE_PGM_PACKET_SLOTS
321+
pgm_commands[pgm_index - 1] = command
322+
323+
args = {"module_address": module_address, "pgm_commands": pgm_commands}
324+
try:
325+
reply = await self.core.send_wait(
326+
parsers.PerformModulePGMAction, args, reply_expected=0xA
327+
)
328+
except MappingError:
329+
logger.error('Module PGM command: "%s" is not supported' % command)
330+
return False
331+
332+
if reply:
333+
logger.info('Module PGM command: "%s" succeeded' % command)
334+
else:
335+
logger.info('Module PGM command: "%s" failed' % command)
336+
return reply is not None
337+
307338
async def control_doors(self, doors, command) -> bool:
308339
"""
309340
Control Doors

paradox/hardware/evo/parsers.py

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

826+
# Fixed number of PGM command slots in the 0xA4 packet — a protocol constant,
827+
# not the number of PGMs a specific module exposes (which is configured separately).
828+
MODULE_PGM_PACKET_SLOTS = 4
829+
830+
PerformModulePGMAction = Struct(
831+
"fields"
832+
/ RawCopy(
833+
Struct(
834+
"po" / Struct("command" / Const(0xA4, Int8ub)),
835+
"packet_length" / PacketLength(Int8ub),
836+
"_not_used0" / Padding(1),
837+
"module_address" / Default(Int8ub, 0),
838+
"_not_used1" / Padding(2),
839+
"pgm_commands" / Array(MODULE_PGM_PACKET_SLOTS, Default(_PGMCommandEnum, "release")),
840+
"_not_used2" / Padding(12),
841+
)
842+
),
843+
"checksum" / PacketChecksum(Bytes(1)),
844+
)
845+
846+
PerformModulePGMActionResponse = Struct(
847+
"fields"
848+
/ RawCopy(
849+
Struct(
850+
"po"
851+
/ BitStruct(
852+
"command" / Const(0xA, Nibble),
853+
"_not_used" / Nibble,
854+
),
855+
"packet_length" / PacketLength(Int8ub),
856+
"_not_used0" / Padding(1),
857+
"module_address" / Int8ub,
858+
"_not_used1" / Padding(2),
859+
)
860+
),
861+
"checksum" / PacketChecksum(Bytes(1)),
862+
)
863+
826864
_PGMBroadcastCommandEnum = Enum(
827865
Int8ub,
828866
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 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: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
async_loop_unhandled_exception_handler,
2323
)
2424
from paradox.hardware import Panel, create_panel
25+
from paradox.hardware.evo.parsers import MODULE_PGM_PACKET_SLOTS
2526
from paradox.lib import ps
2627
from paradox.lib.async_message_manager import ErrorMessageHandler, EventMessageHandler
2728
from paradox.lib.handlers import PersistentHandler
@@ -216,6 +217,7 @@ async def full_connect(self) -> bool:
216217

217218
logger.info("Loading data from panel memory")
218219
await self.panel.load_memory()
220+
self._init_module_pgms()
219221

220222
logger.info("Running")
221223
self.run_state = RunState.RUN
@@ -489,33 +491,82 @@ async def control_partition(self, partition: str, command: str) -> bool:
489491

490492
return accepted
491493

494+
def _init_module_pgms(self):
495+
for addr, pgm_count in cfg.MODULE_PGM_ADDRESSES.items():
496+
if not isinstance(addr, int) or not (1 <= addr <= 254):
497+
logger.warning(
498+
"MODULE_PGM_ADDRESSES: invalid module address %r (expected int 1-254), skipping",
499+
addr,
500+
)
501+
continue
502+
if not isinstance(pgm_count, int) or not (
503+
1 <= pgm_count <= MODULE_PGM_PACKET_SLOTS
504+
):
505+
logger.warning(
506+
"MODULE_PGM_ADDRESSES: invalid pgm_count %r for address %d (expected int 1-%d), skipping",
507+
pgm_count,
508+
addr,
509+
MODULE_PGM_PACKET_SLOTS,
510+
)
511+
continue
512+
for pgm_index in range(1, pgm_count + 1):
513+
key = f"module{addr}_pgm{pgm_index}"
514+
self.storage.get_container("module_pgm")[key] = {
515+
"id": pgm_index,
516+
"key": key,
517+
"label": f"Module {addr} PGM {pgm_index}",
518+
"module_address": addr,
519+
"pgm_index": pgm_index,
520+
}
521+
self.storage.update_container_object("module_pgm", key, {"on": False})
522+
492523
async def control_output(self, output, command) -> bool:
493524
command = command.lower()
494525
logger.debug(f"Control Output: {output} - {command}")
495526

496527
outputs_selected = self.storage.get_container("pgm").select(output)
528+
if outputs_selected:
529+
accepted = False
530+
try:
531+
accepted = await self.panel.control_outputs(outputs_selected, command)
532+
except NotImplementedError:
533+
logger.error("control_output is not implemented for this alarm type")
534+
except asyncio.CancelledError:
535+
logger.error("control_output canceled")
536+
raise
537+
except asyncio.TimeoutError:
538+
logger.error("control_output timeout")
539+
self.request_status_refresh()
540+
return accepted
541+
542+
module_pgm_selected = self.storage.get_container("module_pgm").select(output)
543+
if module_pgm_selected:
544+
accepted = False
545+
module_pgm_container = self.storage.get_container("module_pgm")
546+
for key in module_pgm_selected:
547+
out = module_pgm_container[key]
548+
try:
549+
accepted = await self.panel.control_module_pgm_outputs(
550+
out["module_address"], out["pgm_index"], command
551+
)
552+
except NotImplementedError:
553+
logger.error(
554+
"control_module_pgm_outputs is not implemented for this alarm type"
555+
)
556+
except asyncio.CancelledError:
557+
logger.error("control_module_pgm_output canceled")
558+
raise
559+
except asyncio.TimeoutError:
560+
logger.error("control_output timeout")
561+
if accepted:
562+
is_on = command in ("on", "on_override")
563+
self.storage.update_container_object(
564+
"module_pgm", out["key"], {"on": is_on}
565+
)
566+
return accepted
497567

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
568+
logger.error("No outputs selected")
569+
return False
519570

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

0 commit comments

Comments
 (0)