3939DEFAULT_TITLE_ALIGNMENT = "bottom"
4040DEFAULT_ICON_SIZE = (72 , 72 )
4141
42+ KEYPAD = "Keypad"
43+ ENCODER = "Encoder"
44+
45+ CONTROLLER_ALIASES : dict [str , str ] = {
46+ "keypad" : KEYPAD ,
47+ "key" : KEYPAD ,
48+ "button" : KEYPAD ,
49+ "encoder" : ENCODER ,
50+ "dial" : ENCODER ,
51+ }
52+
4253DEFAULT_PAGE_MANIFEST = {
4354 "Controllers" : [
4455 {
4556 "Actions" : None ,
46- "Type" : "Keypad" ,
57+ "Type" : KEYPAD ,
4758 }
4859 ],
4960 "Icon" : "" ,
5061 "Name" : "" ,
5162}
5263
53- MODEL_LAYOUTS : dict [str , tuple [int , int ]] = {
54- "20GBA9901" : (5 , 3 ),
55- "UI Stream Deck" : (4 , 2 ),
64+ MODEL_LAYOUTS : dict [str , dict [str , tuple [int , int ]]] = {
65+ # Stream Deck (Original)
66+ "20GBA9901" : {KEYPAD : (5 , 3 )},
67+ # Stream Deck + XL (32 keys, 6 dials with 1200x100 touchstrip)
68+ "20GBX9901" : {KEYPAD : (8 , 4 ), ENCODER : (6 , 1 )},
69+ # Emulator used by the Elgato desktop app
70+ "UI Stream Deck" : {KEYPAD : (4 , 2 )},
5671}
5772
5873HEX_COLOR_PATTERN = re .compile (r"^#[0-9a-fA-F]{6}$" )
@@ -183,12 +198,48 @@ def get_profiles_dir(version: str = "auto") -> Path:
183198 )
184199
185200
186- def _controller_actions (page_manifest : dict [str , Any ]) -> dict [str , Any ]:
187- controllers = page_manifest .get ("Controllers" ) or []
188- if not controllers :
201+ def _find_controller (page_manifest : dict [str , Any ], controller_type : str ) -> dict [str , Any ] | None :
202+ for controller in page_manifest .get ("Controllers" ) or []:
203+ if controller .get ("Type" ) == controller_type :
204+ return controller
205+ return None
206+
207+
208+ def _ensure_controller (page_manifest : dict [str , Any ], controller_type : str ) -> dict [str , Any ]:
209+ controllers = page_manifest .setdefault ("Controllers" , [])
210+ for controller in controllers :
211+ if controller .get ("Type" ) == controller_type :
212+ return controller
213+ new_controller : dict [str , Any ] = {"Type" : controller_type , "Actions" : None }
214+ controllers .append (new_controller )
215+ return new_controller
216+
217+
218+ def _controller_actions (
219+ page_manifest : dict [str , Any ], controller_type : str = KEYPAD
220+ ) -> dict [str , Any ]:
221+ controller = _find_controller (page_manifest , controller_type )
222+ if not controller :
189223 return {}
190- actions = controllers [0 ].get ("Actions" )
191- return actions or {}
224+ return controller .get ("Actions" ) or {}
225+
226+
227+ def _normalize_controller (value : str | None ) -> str :
228+ if not value :
229+ return KEYPAD
230+ canonical = CONTROLLER_ALIASES .get (value .lower ())
231+ if canonical is None :
232+ raise ProfileValidationError (
233+ f"Unknown controller '{ value } '. Use one of: { sorted (set (CONTROLLER_ALIASES ))} "
234+ )
235+ return canonical
236+
237+
238+ def _total_action_count (page_manifest : dict [str , Any ]) -> int :
239+ return sum (
240+ len (controller .get ("Actions" ) or {})
241+ for controller in page_manifest .get ("Controllers" ) or []
242+ )
192243
193244
194245def _slugify (value : str ) -> str :
@@ -296,37 +347,44 @@ def read_page(
296347 directory_id = directory_id ,
297348 )
298349 page_manifest = _load_json (page_ref .manifest_path )
299- columns , rows = self ._resolve_layout (profile_manifest , page_manifest )
300-
301- buttons = []
302- for position , action in sorted (
303- _controller_actions (page_manifest ).items (),
304- key = lambda item : self ._position_sort_key (item [0 ]),
305- ):
306- col , row = [int (part ) for part in position .split ("," )]
307- key = (row * columns ) + col
308- state_index = min (
309- max (int (action .get ("State" , 0 )), 0 ), max (len (action .get ("States" , [{}])) - 1 , 0 )
310- )
311- states = action .get ("States" ) or [{}]
312- active_state = states [state_index ] if states else {}
313- buttons .append (
314- {
315- "key" : key ,
316- "position" : position ,
317- "action_id" : action .get ("ActionID" ),
318- "action_uuid" : action .get ("UUID" ),
319- "plugin_uuid" : action .get ("Plugin" , {}).get ("UUID" ),
320- "plugin_name" : action .get ("Plugin" , {}).get ("Name" ),
321- "name" : action .get ("Name" ),
322- "state" : action .get ("State" , 0 ),
323- "title" : active_state .get ("Title" ),
324- "image" : active_state .get ("Image" ),
325- "settings" : action .get ("Settings" , {}),
326- "show_title" : active_state .get ("ShowTitle" ),
327- "raw" : action ,
328- }
329- )
350+ keypad_cols , keypad_rows = self ._resolve_layout (profile_manifest , page_manifest , KEYPAD )
351+
352+ buttons : list [dict [str , Any ]] = []
353+ layouts : dict [str , dict [str , int ]] = {}
354+
355+ for controller in page_manifest .get ("Controllers" ) or []:
356+ controller_type = controller .get ("Type" , KEYPAD )
357+ cols , rows = self ._resolve_layout (profile_manifest , page_manifest , controller_type )
358+ layouts [controller_type .lower ()] = {"columns" : cols , "rows" : rows }
359+
360+ actions = controller .get ("Actions" ) or {}
361+ for position , action in sorted (
362+ actions .items (),
363+ key = lambda item : self ._position_sort_key (item [0 ]),
364+ ):
365+ col , row = [int (part ) for part in position .split ("," )]
366+ key = (row * cols + col ) if cols else col
367+ states = action .get ("States" ) or [{}]
368+ state_index = min (max (int (action .get ("State" , 0 )), 0 ), max (len (states ) - 1 , 0 ))
369+ active_state = states [state_index ] if states else {}
370+ buttons .append (
371+ {
372+ "controller" : controller_type .lower (),
373+ "key" : key ,
374+ "position" : position ,
375+ "action_id" : action .get ("ActionID" ),
376+ "action_uuid" : action .get ("UUID" ),
377+ "plugin_uuid" : action .get ("Plugin" , {}).get ("UUID" ),
378+ "plugin_name" : action .get ("Plugin" , {}).get ("Name" ),
379+ "name" : action .get ("Name" ),
380+ "state" : action .get ("State" , 0 ),
381+ "title" : active_state .get ("Title" ),
382+ "image" : active_state .get ("Image" ),
383+ "settings" : action .get ("Settings" , {}),
384+ "show_title" : active_state .get ("ShowTitle" ),
385+ "raw" : action ,
386+ }
387+ )
330388
331389 return {
332390 "profiles_root" : self .profiles_dir .name ,
@@ -339,7 +397,8 @@ def read_page(
339397 "default_page_uuid" : profile_manifest .get ("Pages" , {}).get ("Default" ),
340398 },
341399 "page" : page_ref .to_dict (),
342- "layout" : {"columns" : columns , "rows" : rows },
400+ "layout" : {"columns" : keypad_cols , "rows" : keypad_rows },
401+ "layouts" : layouts ,
343402 "buttons" : buttons ,
344403 "raw_manifest" : page_manifest ,
345404 }
@@ -389,17 +448,41 @@ def write_page(
389448 if page_name is not None :
390449 page_manifest ["Name" ] = page_name
391450
392- columns , rows = self ._resolve_layout (profile_manifest , page_manifest )
393- actions = {} if clear_existing else copy .deepcopy (_controller_actions (page_manifest ))
451+ # Group incoming buttons by the controller they target so a single write can
452+ # update the Keypad and Encoder controllers together without touching the other.
453+ buttons_by_controller : dict [str , list [dict [str , Any ]]] = {}
394454 for button in buttons :
395- position = self ._resolve_button_position (button , columns = columns , rows = rows )
396- actions [position ] = self ._materialize_action (button , page_dir )
455+ controller_type = _normalize_controller (button .get ("controller" ))
456+ buttons_by_controller .setdefault (controller_type , []).append (button )
457+
458+ # When clear_existing is requested but no buttons were supplied, default to
459+ # targeting the Keypad controller so that the caller can still clear a page
460+ # by writing an empty button list (restores pre-multi-controller behaviour).
461+ if clear_existing and not buttons_by_controller :
462+ buttons_by_controller [KEYPAD ] = []
397463
398- controllers = page_manifest .setdefault ("Controllers" , [{"Type" : "Keypad" }])
399- if not controllers :
400- controllers .append ({"Type" : "Keypad" })
401- controllers [0 ]["Type" ] = controllers [0 ].get ("Type" , "Keypad" )
402- controllers [0 ]["Actions" ] = actions or None
464+ layouts_out : dict [str , dict [str , int ]] = {}
465+
466+ for controller_type , ctl_buttons in buttons_by_controller .items ():
467+ cols , rows = self ._resolve_layout (profile_manifest , page_manifest , controller_type )
468+ if cols <= 0 or rows <= 0 :
469+ raise ProfileValidationError (
470+ f"Device model does not expose a '{ controller_type } ' controller."
471+ )
472+ controller = _ensure_controller (page_manifest , controller_type )
473+ existing = {} if clear_existing else copy .deepcopy (controller .get ("Actions" ) or {})
474+ for button in ctl_buttons :
475+ position = self ._resolve_button_position (button , columns = cols , rows = rows )
476+ existing [position ] = self ._materialize_action (button , page_dir )
477+ controller ["Actions" ] = existing or None
478+ layouts_out [controller_type .lower ()] = {"columns" : cols , "rows" : rows }
479+
480+ # New pages always carry a Keypad controller slot so the Elgato app can render them.
481+ if create_new :
482+ _ensure_controller (page_manifest , KEYPAD )
483+
484+ primary_cols , primary_rows = self ._resolve_layout (profile_manifest , page_manifest , KEYPAD )
485+ total_button_count = _total_action_count (page_manifest )
403486
404487 if create_new :
405488 pages_section = profile_manifest .setdefault ("Pages" , {})
@@ -430,8 +513,9 @@ def write_page(
430513 "page_index" : None if create_new else page_index ,
431514 "directory_id" : page_dir .name ,
432515 "page_uuid" : page_uuid ,
433- "layout" : {"columns" : columns , "rows" : rows },
434- "button_count" : len (actions ),
516+ "layout" : {"columns" : primary_cols , "rows" : primary_rows },
517+ "layouts" : layouts_out ,
518+ "button_count" : total_button_count ,
435519 "page_name" : page_manifest .get ("Name" , "" ),
436520 "manifest_path" : str (page_dir / "manifest.json" ),
437521 }
@@ -672,7 +756,6 @@ def _build_page_ref(
672756 is_current : bool ,
673757 ) -> PageRef :
674758 page_manifest = _load_json (manifest_path )
675- actions = _controller_actions (page_manifest )
676759 return PageRef (
677760 page_index = page_index ,
678761 directory_id = directory_id ,
@@ -683,7 +766,7 @@ def _build_page_ref(
683766 is_default = is_default ,
684767 is_current = is_current ,
685768 name = str (page_manifest .get ("Name" , "" )),
686- button_count = len ( actions ),
769+ button_count = _total_action_count ( page_manifest ),
687770 icon_count = _count_icons (manifest_path .parent ),
688771 )
689772
@@ -715,19 +798,24 @@ def _resolve_layout(
715798 self ,
716799 profile_manifest : dict [str , Any ],
717800 page_manifest : dict [str , Any ] | None = None ,
801+ controller_type : str = KEYPAD ,
718802 ) -> tuple [int , int ]:
719803 device_model = str (profile_manifest .get ("Device" , {}).get ("Model" , "" ))
720- if device_model in MODEL_LAYOUTS :
721- return MODEL_LAYOUTS [device_model ]
804+ model_entry = MODEL_LAYOUTS .get (device_model )
805+ if model_entry and controller_type in model_entry :
806+ return model_entry [controller_type ]
722807
723808 if page_manifest :
724- actions = _controller_actions (page_manifest )
809+ actions = _controller_actions (page_manifest , controller_type )
725810 if actions :
726811 cols = max (int (position .split ("," )[0 ]) for position in actions ) + 1
727812 rows = max (int (position .split ("," )[1 ]) for position in actions ) + 1
728813 if cols > 0 and rows > 0 :
729814 return cols , rows
730815
816+ if controller_type == ENCODER :
817+ return (0 , 0 )
818+
731819 return (5 , 3 )
732820
733821 def _resolve_button_position (
0 commit comments