Skip to content

Add support for module PGM control#591

Merged
yozik04 merged 7 commits intoParadoxAlarmInterface:devfrom
CamW:dev
Mar 25, 2026
Merged

Add support for module PGM control#591
yozik04 merged 7 commits intoParadoxAlarmInterface:devfrom
CamW:dev

Conversation

@CamW
Copy link
Copy Markdown
Contributor

@CamW CamW commented Mar 13, 2026

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. State is tracked optimistically after each
command as no RAM status block is available for module PGMs.

Protocol:

  • TX: A4 17 00 <module_addr> 00 00 00*12
  • RX: A2 07 00 <module_addr> 00 00
  • Module address = module number as available in Babyware

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

   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
@arnaudpn
Copy link
Copy Markdown

Hi @CamW,
Would this nice new Combus module PGM control feature also cover this ?
#580
Thanks !
a.

Comment on lines +318 to +320
"""
pgm_commands = ["release"] * parsers.MODULE_PGM_OUTPUTS_PER_MODULE
pgm_commands[pgm_index - 1] = command
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""
pgm_commands = ["release"] * parsers.MODULE_PGM_OUTPUTS_PER_MODULE
pgm_commands[pgm_index - 1] = command
"""
assert 1 <= pgm_index <= parsers.MODULE_PGM_OUTPUTS_PER_MODULE, "pgm_index must be between 1 and %d" % parsers.MODULE_PGM_OUTPUTS_PER_MODULE
pgm_commands = ["release"] * parsers.MODULE_PGM_OUTPUTS_PER_MODULE
pgm_commands[pgm_index - 1] = command

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll include this change but I'm also renaming MODULE_PGM_OUTPUTS_PER_MODULE for clarity so I won't accept this suggestion exactly.

Comment on lines +66 to +67
payload_on="on_override",
payload_off="off_override",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does not work with on and off? Not sure if *_override is required. Can you test?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works for me

> $ cat switch_fence.sh                                                                                                                                                     
mosquitto_pub -t "paradox/control/outputs/module4_pgm4" -m "off_override"
mosquitto_pub -t "paradox/control/outputs/module4_pgm4" -m "on_override"

switching "*_override" out for "off" and "on" - I get nothing. No relay click, no triggering of the attached device. This is for a relay on a PGM4.

Comment on lines 506 to +549
async def control_output(self, output, command) -> bool:
command = command.lower()
logger.debug(f"Control Output: {output} - {command}")

outputs_selected = self.storage.get_container("pgm").select(output)

# Not Found
if len(outputs_selected) == 0:
logger.error("No outputs selected")
return False

# Apply state changes
accepted = False
try:
accepted = await self.panel.control_outputs(outputs_selected, command)
except NotImplementedError:
logger.error("control_output is not implemented for this alarm type")
except asyncio.CancelledError:
logger.error("control_output canceled")
except asyncio.TimeoutError:
logger.error("control_output timeout")
# Apply state changes

# Refresh status
self.request_status_refresh() # Trigger status update

return accepted
if outputs_selected:
accepted = False
try:
accepted = await self.panel.control_outputs(outputs_selected, command)
except NotImplementedError:
logger.error("control_output is not implemented for this alarm type")
except asyncio.CancelledError:
logger.error("control_output canceled")
raise
except asyncio.TimeoutError:
logger.error("control_output timeout")
self.request_status_refresh()
return accepted

module_pgm_selected = self.storage.get_container("module_pgm").select(output)
if module_pgm_selected:
accepted = False
module_pgm_container = self.storage.get_container("module_pgm")
for key in module_pgm_selected:
out = module_pgm_container[key]
try:
accepted = await self.panel.control_module_pgm_outputs(
out["module_address"], out["pgm_index"], command
)
except NotImplementedError:
logger.error("control_module_pgm_outputs is not implemented for this alarm type")
except asyncio.CancelledError:
logger.error("control_output canceled")
raise
except asyncio.TimeoutError:
logger.error("control_output timeout")
if accepted:
is_on = command in ("on", "on_override")
self.storage.update_container_object("module_pgm", out["key"], {"on": is_on})
self.request_status_refresh()
return accepted

logger.error("No outputs selected")
return False
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this function requires a unit test. It is quite complicated. Especially how will it distinguish if it needs to trigger PGM on the main module or external module.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some tests for this.

"checksum" / PacketChecksum(Bytes(1)),
)

MODULE_PGM_OUTPUTS_PER_MODULE = 4
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see here you have strictly 4 PGMs per external module, but in the config it is possible to specify PGM count. Which is correct? If it is always 4 then in the config should be just an array of module addresses.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll rename MODULE_PGM_OUTPUTS_PER_MODULE to MODULE_PGM_PACKET_SLOTS and add a comment to make the distinction clear. The 0xA4 packet always carries exactly 4 command slots (protocol-fixed), so this is a wire-format constant. The values in MODULE_PGM_ADDRESSES are separate — they control how many PGMs PAI exposes for a given module. My PGM4 has 4 but a keypad might only have 1.

CamW and others added 3 commits March 24, 2026 21:23
Co-authored-by: Jevgeni Kiski <yozik04@gmail.com>
Co-authored-by: Jevgeni Kiski <yozik04@gmail.com>
Co-authored-by: Jevgeni Kiski <yozik04@gmail.com>
@CamW
Copy link
Copy Markdown
Contributor Author

CamW commented Mar 24, 2026

Hi @CamW, Would this nice new Combus module PGM control feature also cover this ? #580 Thanks ! a.

Hi @arnaudpn this won't help with that. This provides for "Module" PGMs not the PGMs on the panel. I'm surprised that this doesn't already work, pai does provide for panel PGMs. You may need to configure the Panel PGMs for them to be available in PAI.

@CamW CamW requested a review from yozik04 March 24, 2026 20:40
@sonarqubecloud
Copy link
Copy Markdown

@yozik04 yozik04 merged commit bd25594 into ParadoxAlarmInterface:dev Mar 25, 2026
9 checks passed
@yozik04
Copy link
Copy Markdown
Collaborator

yozik04 commented Mar 25, 2026

Thank you @CamW!

@CamW
Copy link
Copy Markdown
Contributor Author

CamW commented Mar 25, 2026

It's been a pleasure, thank you for your help getting it in @yozik04

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants