4141from paradox .config import config as cfg
4242from paradox .hardware .panel import Panel
4343from paradox .hardware .prt3 import adapter , encoder
44- from paradox .hardware .prt3 .event import EVENT_MAP , PRT3Event
4544from paradox .hardware .prt3 .parser import (
4645 PRT3AreaStatus ,
4746 PRT3BufferFull ,
6261# Maps PAI command string → (encoder_fn, arm_mode_char)
6362# All arm variants that don't require a user code use quick-arm.
6463_QUICK_ARM_MODES = {
65- "arm" : "A" , # default arm → away
66- "arm_away" : "A" ,
67- "arm_stay" : "S" ,
68- "arm_force" : "F" ,
64+ "arm" : "A" , # default arm → away
65+ "arm_away" : "A" ,
66+ "arm_stay" : "S" ,
67+ "arm_force" : "F" ,
6968 "arm_instant" : "I" ,
70- "arm_sleep" : "I" , # instant arm (no entry delay) — PRT3 closest to HA arm_night
69+ "arm_sleep" : "I" , # instant arm (no entry delay) — PRT3 closest to HA arm_night
7170}
7271
7372# Panic type → encoder function
7473_PANIC_ENCODERS = {
7574 "emergency" : encoder .encode_panic_emergency ,
76- "medical" : encoder .encode_panic_medical ,
77- "fire" : encoder .encode_panic_fire ,
75+ "medical" : encoder .encode_panic_medical ,
76+ "fire" : encoder .encode_panic_fire ,
7877}
7978
8079
@@ -109,6 +108,24 @@ def __init__(self, core):
109108 cfg .PRT3_MAX_ZONES * cfg .IO_TIMEOUT ,
110109 cfg .KEEP_ALIVE_INTERVAL ,
111110 )
111+ # Labels are loaded serially at startup; with PRT3_MAX_USERS=999 and
112+ # IO_TIMEOUT=0.5 the worst-case (all timeouts) is ~8 min just for users.
113+ # Warn when the combined worst-case load time exceeds 2 min so it
114+ # doesn't fire on the default 8+96+32 config.
115+ label_load_seconds = (
116+ cfg .PRT3_MAX_AREAS + cfg .PRT3_MAX_ZONES + cfg .PRT3_MAX_USERS
117+ ) * cfg .IO_TIMEOUT
118+ if label_load_seconds > 120 :
119+ logger .warning (
120+ "PRT3: startup label load may take up to %.0f s "
121+ "(%d areas + %d zones + %d users at %.1f s timeout) — "
122+ "consider reducing PRT3_MAX_USERS / PRT3_MAX_ZONES" ,
123+ label_load_seconds ,
124+ cfg .PRT3_MAX_AREAS ,
125+ cfg .PRT3_MAX_ZONES ,
126+ cfg .PRT3_MAX_USERS ,
127+ cfg .IO_TIMEOUT ,
128+ )
112129
113130 # ------------------------------------------------------------------
114131 # Message parsing
@@ -160,7 +177,10 @@ async def _prt3_send_wait(
160177 timed out or hit buffer-full.
161178 """
162179 async with self .core .request_lock :
163- buffer_full_or_match = lambda m : predicate (m ) or isinstance (m , PRT3BufferFull )
180+
181+ def buffer_full_or_match (m ):
182+ return predicate (m ) or isinstance (m , PRT3BufferFull )
183+
164184 for attempt in range (1 , retries + 1 ):
165185 self .core .connection .write (command_bytes )
166186 try :
@@ -170,15 +190,19 @@ async def _prt3_send_wait(
170190 if isinstance (result , PRT3BufferFull ):
171191 logger .warning (
172192 "PRT3: buffer full on attempt %d/%d, retrying: %s" ,
173- attempt , retries , command_bytes [:5 ], # [:5] excludes user code
193+ attempt ,
194+ retries ,
195+ command_bytes [:5 ], # [:5] excludes user code
174196 )
175197 continue
176198 return result
177199 except asyncio .TimeoutError :
178200 if attempt < retries :
179201 logger .warning (
180202 "PRT3: timeout on attempt %d/%d, retrying: %s" ,
181- attempt , retries , command_bytes [:5 ], # [:5] excludes user code
203+ attempt ,
204+ retries ,
205+ command_bytes [:5 ], # [:5] excludes user code
182206 )
183207 return None
184208
@@ -210,10 +234,18 @@ async def initialize_communication(self, password) -> bool:
210234 """
211235 from paradox .hardware .prt3 .parser import PRT3SystemEvent
212236
213- _any_prt3_msg = lambda m : isinstance (
214- m , (PRT3CommStatus , PRT3AreaStatus , PRT3ZoneStatus , PRT3LabelReply ,
215- PRT3CommandEcho , PRT3SystemEvent )
216- )
237+ def _any_prt3_msg (m ):
238+ return isinstance (
239+ m ,
240+ (
241+ PRT3CommStatus ,
242+ PRT3AreaStatus ,
243+ PRT3ZoneStatus ,
244+ PRT3LabelReply ,
245+ PRT3CommandEcho ,
246+ PRT3SystemEvent ,
247+ ),
248+ )
217249
218250 logger .info ("PRT3: checking serial link liveness" )
219251
@@ -242,11 +274,14 @@ async def initialize_communication(self, password) -> bool:
242274 if isinstance (msg , PRT3CommStatus ) and not msg .ok :
243275 logger .error ("PRT3: panel communication failure (COMM&fail)" )
244276 return False
245- logger .info ("PRT3: serial link live (probe response: %s)" , type (msg ).__name__ )
277+ logger .info (
278+ "PRT3: serial link live (probe response: %s)" , type (msg ).__name__
279+ )
246280 return True
247281 except asyncio .TimeoutError :
248282 logger .error (
249- "PRT3: serial link unresponsive after %.0fs probe" , cfg .PRT3_COMM_TIMEOUT
283+ "PRT3: serial link unresponsive after %.0fs probe" ,
284+ cfg .PRT3_COMM_TIMEOUT ,
250285 )
251286 return False
252287
@@ -273,7 +308,11 @@ async def _load_label_range(
273308 msg = await self ._prt3_send_wait (
274309 cmd ,
275310 lambda m , ec = expected_cmd , et = element_type , idx = i : (
276- (isinstance (m , PRT3LabelReply ) and m .element_type == et and m .index == idx )
311+ (
312+ isinstance (m , PRT3LabelReply )
313+ and m .element_type == et
314+ and m .index == idx
315+ )
277316 or (isinstance (m , PRT3CommandEcho ) and m .cmd == ec )
278317 ),
279318 )
@@ -297,9 +336,15 @@ async def load_labels(self) -> dict:
297336 """
298337 logger .info ("PRT3: loading labels" )
299338 replies = (
300- await self ._load_label_range ("area" , cfg .PRT3_MAX_AREAS , encoder .encode_area_label_request , "AL" )
301- + await self ._load_label_range ("zone" , cfg .PRT3_MAX_ZONES , encoder .encode_zone_label_request , "ZL" )
302- + await self ._load_label_range ("user" , cfg .PRT3_MAX_USERS , encoder .encode_user_label_request , "UL" )
339+ await self ._load_label_range (
340+ "area" , cfg .PRT3_MAX_AREAS , encoder .encode_area_label_request , "AL"
341+ )
342+ + await self ._load_label_range (
343+ "zone" , cfg .PRT3_MAX_ZONES , encoder .encode_zone_label_request , "ZL"
344+ )
345+ + await self ._load_label_range (
346+ "user" , cfg .PRT3_MAX_USERS , encoder .encode_user_label_request , "UL"
347+ )
303348 )
304349 labels = adapter .labels_dict_from_replies (replies )
305350 logger .info (
@@ -407,7 +452,10 @@ def _build_partition_cmd(
407452 mode = _QUICK_ARM_MODES [command ]
408453 if user_code :
409454 try :
410- return encoder .encode_arm (partition , mode , user_code ), f"AA{ partition :03d} "
455+ return (
456+ encoder .encode_arm (partition , mode , user_code ),
457+ f"AA{ partition :03d} " ,
458+ )
411459 except ValueError as exc :
412460 logger .error ("PRT3: invalid PRT3_USER_CODE for arm: %s" , exc )
413461 return None
@@ -439,7 +487,7 @@ async def control_partitions(self, partitions: list, command: str) -> bool:
439487 lambda m , ec = expected_echo : (
440488 isinstance (m , PRT3CommandEcho ) and m .cmd == ec
441489 ),
442- retries = 2 ,
490+ retries = 1 , # non-idempotent: retry can double-arm if echo was lost
443491 )
444492 if msg is None :
445493 logger .warning ("PRT3: timeout on %s partition %d" , command , partition )
@@ -448,7 +496,9 @@ async def control_partitions(self, partitions: list, command: str) -> bool:
448496 accepted = True
449497 else :
450498 logger .warning (
451- "PRT3: %s partition %d rejected by panel (&fail)" , command , partition
499+ "PRT3: %s partition %d rejected by panel (&fail)" ,
500+ command ,
501+ partition ,
452502 )
453503
454504 return accepted
@@ -498,10 +548,12 @@ async def send_panic(self, partitions: list, panic_type: str, _code) -> bool:
498548 lambda m , ec = expected_echo : (
499549 isinstance (m , PRT3CommandEcho ) and m .cmd == ec
500550 ),
501- retries = 2 ,
551+ retries = 1 , # non-idempotent: retry can re-trigger panic if echo was lost
502552 )
503553 if msg is None :
504- logger .warning ("PRT3: timeout on %s panic area %d" , panic_type , partition )
554+ logger .warning (
555+ "PRT3: timeout on %s panic area %d" , panic_type , partition
556+ )
505557 continue
506558 if not msg .ok :
507559 logger .warning (
0 commit comments