4040DEFAULT_TITLE_ALIGNMENT = "bottom"
4141DEFAULT_ICON_SIZE = (72 , 72 )
4242
43+ KEYPAD = "Keypad"
44+ ENCODER = "Encoder"
45+
46+ CONTROLLER_ALIASES : dict [str , str ] = {
47+ "keypad" : KEYPAD ,
48+ "key" : KEYPAD ,
49+ "button" : KEYPAD ,
50+ "encoder" : ENCODER ,
51+ "dial" : ENCODER ,
52+ }
53+
4354DEFAULT_PAGE_MANIFEST = {
4455 "Controllers" : [
4556 {
4657 "Actions" : None ,
47- "Type" : "Keypad" ,
58+ "Type" : KEYPAD ,
4859 }
4960 ],
5061 "Icon" : "" ,
5162 "Name" : "" ,
5263}
5364
54- MODEL_LAYOUTS : dict [str , tuple [int , int ]] = {
55- "20GBA9901" : (5 , 3 ),
56- "UI Stream Deck" : (4 , 2 ),
65+ MODEL_LAYOUTS : dict [str , dict [str , tuple [int , int ]]] = {
66+ # Stream Deck (Original)
67+ "20GBA9901" : {KEYPAD : (5 , 3 )},
68+ # Stream Deck + XL (32 keys, 6 dials with 1200x100 touchstrip)
69+ "20GBX9901" : {KEYPAD : (8 , 4 ), ENCODER : (6 , 1 )},
70+ # Emulator used by the Elgato desktop app
71+ "UI Stream Deck" : {KEYPAD : (4 , 2 )},
5772}
5873
5974# The Elgato Stream Deck desktop app caches every profile in memory and rewrites the
@@ -201,12 +216,48 @@ def get_profiles_dir(version: str = "auto") -> Path:
201216 )
202217
203218
204- def _controller_actions (page_manifest : dict [str , Any ]) -> dict [str , Any ]:
205- controllers = page_manifest .get ("Controllers" ) or []
206- if not controllers :
219+ def _find_controller (page_manifest : dict [str , Any ], controller_type : str ) -> dict [str , Any ] | None :
220+ for controller in page_manifest .get ("Controllers" ) or []:
221+ if controller .get ("Type" ) == controller_type :
222+ return controller
223+ return None
224+
225+
226+ def _ensure_controller (page_manifest : dict [str , Any ], controller_type : str ) -> dict [str , Any ]:
227+ controllers = page_manifest .setdefault ("Controllers" , [])
228+ for controller in controllers :
229+ if controller .get ("Type" ) == controller_type :
230+ return controller
231+ new_controller : dict [str , Any ] = {"Type" : controller_type , "Actions" : None }
232+ controllers .append (new_controller )
233+ return new_controller
234+
235+
236+ def _controller_actions (
237+ page_manifest : dict [str , Any ], controller_type : str = KEYPAD
238+ ) -> dict [str , Any ]:
239+ controller = _find_controller (page_manifest , controller_type )
240+ if not controller :
207241 return {}
208- actions = controllers [0 ].get ("Actions" )
209- return actions or {}
242+ return controller .get ("Actions" ) or {}
243+
244+
245+ def _normalize_controller (value : str | None ) -> str :
246+ if not value :
247+ return KEYPAD
248+ canonical = CONTROLLER_ALIASES .get (value .lower ())
249+ if canonical is None :
250+ raise ProfileValidationError (
251+ f"Unknown controller '{ value } '. Use one of: { sorted (set (CONTROLLER_ALIASES ))} "
252+ )
253+ return canonical
254+
255+
256+ def _total_action_count (page_manifest : dict [str , Any ]) -> int :
257+ return sum (
258+ len (controller .get ("Actions" ) or {})
259+ for controller in page_manifest .get ("Controllers" ) or []
260+ )
210261
211262
212263def _slugify (value : str ) -> str :
@@ -387,37 +438,44 @@ def read_page(
387438 directory_id = directory_id ,
388439 )
389440 page_manifest = _load_json (page_ref .manifest_path )
390- columns , rows = self ._resolve_layout (profile_manifest , page_manifest )
391-
392- buttons = []
393- for position , action in sorted (
394- _controller_actions (page_manifest ).items (),
395- key = lambda item : self ._position_sort_key (item [0 ]),
396- ):
397- col , row = [int (part ) for part in position .split ("," )]
398- key = (row * columns ) + col
399- state_index = min (
400- max (int (action .get ("State" , 0 )), 0 ), max (len (action .get ("States" , [{}])) - 1 , 0 )
401- )
402- states = action .get ("States" ) or [{}]
403- active_state = states [state_index ] if states else {}
404- buttons .append (
405- {
406- "key" : key ,
407- "position" : position ,
408- "action_id" : action .get ("ActionID" ),
409- "action_uuid" : action .get ("UUID" ),
410- "plugin_uuid" : action .get ("Plugin" , {}).get ("UUID" ),
411- "plugin_name" : action .get ("Plugin" , {}).get ("Name" ),
412- "name" : action .get ("Name" ),
413- "state" : action .get ("State" , 0 ),
414- "title" : active_state .get ("Title" ),
415- "image" : active_state .get ("Image" ),
416- "settings" : action .get ("Settings" , {}),
417- "show_title" : active_state .get ("ShowTitle" ),
418- "raw" : action ,
419- }
420- )
441+ keypad_cols , keypad_rows = self ._resolve_layout (profile_manifest , page_manifest , KEYPAD )
442+
443+ buttons : list [dict [str , Any ]] = []
444+ layouts : dict [str , dict [str , int ]] = {}
445+
446+ for controller in page_manifest .get ("Controllers" ) or []:
447+ controller_type = controller .get ("Type" , KEYPAD )
448+ cols , rows = self ._resolve_layout (profile_manifest , page_manifest , controller_type )
449+ layouts [controller_type .lower ()] = {"columns" : cols , "rows" : rows }
450+
451+ actions = controller .get ("Actions" ) or {}
452+ for position , action in sorted (
453+ actions .items (),
454+ key = lambda item : self ._position_sort_key (item [0 ]),
455+ ):
456+ col , row = [int (part ) for part in position .split ("," )]
457+ key = (row * cols + col ) if cols else col
458+ states = action .get ("States" ) or [{}]
459+ state_index = min (max (int (action .get ("State" , 0 )), 0 ), max (len (states ) - 1 , 0 ))
460+ active_state = states [state_index ] if states else {}
461+ buttons .append (
462+ {
463+ "controller" : controller_type .lower (),
464+ "key" : key ,
465+ "position" : position ,
466+ "action_id" : action .get ("ActionID" ),
467+ "action_uuid" : action .get ("UUID" ),
468+ "plugin_uuid" : action .get ("Plugin" , {}).get ("UUID" ),
469+ "plugin_name" : action .get ("Plugin" , {}).get ("Name" ),
470+ "name" : action .get ("Name" ),
471+ "state" : action .get ("State" , 0 ),
472+ "title" : active_state .get ("Title" ),
473+ "image" : active_state .get ("Image" ),
474+ "settings" : action .get ("Settings" , {}),
475+ "show_title" : active_state .get ("ShowTitle" ),
476+ "raw" : action ,
477+ }
478+ )
421479
422480 return {
423481 "profiles_root" : self .profiles_dir .name ,
@@ -430,7 +488,8 @@ def read_page(
430488 "default_page_uuid" : profile_manifest .get ("Pages" , {}).get ("Default" ),
431489 },
432490 "page" : page_ref .to_dict (),
433- "layout" : {"columns" : columns , "rows" : rows },
491+ "layout" : {"columns" : keypad_cols , "rows" : keypad_rows },
492+ "layouts" : layouts ,
434493 "buttons" : buttons ,
435494 "raw_manifest" : page_manifest ,
436495 }
@@ -501,17 +560,41 @@ def write_page(
501560 if page_name is not None :
502561 page_manifest ["Name" ] = page_name
503562
504- columns , rows = self ._resolve_layout (profile_manifest , page_manifest )
505- actions = {} if clear_existing else copy .deepcopy (_controller_actions (page_manifest ))
563+ # Group incoming buttons by the controller they target so a single write can
564+ # update the Keypad and Encoder controllers together without touching the other.
565+ buttons_by_controller : dict [str , list [dict [str , Any ]]] = {}
506566 for button in buttons :
507- position = self ._resolve_button_position (button , columns = columns , rows = rows )
508- actions [position ] = self ._materialize_action (button , page_dir )
567+ controller_type = _normalize_controller (button .get ("controller" ))
568+ buttons_by_controller .setdefault (controller_type , []).append (button )
569+
570+ # When clear_existing is requested but no buttons were supplied, default to
571+ # targeting the Keypad controller so that the caller can still clear a page
572+ # by writing an empty button list (restores pre-multi-controller behaviour).
573+ if clear_existing and not buttons_by_controller :
574+ buttons_by_controller [KEYPAD ] = []
509575
510- controllers = page_manifest .setdefault ("Controllers" , [{"Type" : "Keypad" }])
511- if not controllers :
512- controllers .append ({"Type" : "Keypad" })
513- controllers [0 ]["Type" ] = controllers [0 ].get ("Type" , "Keypad" )
514- controllers [0 ]["Actions" ] = actions or None
576+ layouts_out : dict [str , dict [str , int ]] = {}
577+
578+ for controller_type , ctl_buttons in buttons_by_controller .items ():
579+ cols , rows = self ._resolve_layout (profile_manifest , page_manifest , controller_type )
580+ if cols <= 0 or rows <= 0 :
581+ raise ProfileValidationError (
582+ f"Device model does not expose a '{ controller_type } ' controller."
583+ )
584+ controller = _ensure_controller (page_manifest , controller_type )
585+ existing = {} if clear_existing else copy .deepcopy (controller .get ("Actions" ) or {})
586+ for button in ctl_buttons :
587+ position = self ._resolve_button_position (button , columns = cols , rows = rows )
588+ existing [position ] = self ._materialize_action (button , page_dir )
589+ controller ["Actions" ] = existing or None
590+ layouts_out [controller_type .lower ()] = {"columns" : cols , "rows" : rows }
591+
592+ # New pages always carry a Keypad controller slot so the Elgato app can render them.
593+ if create_new :
594+ _ensure_controller (page_manifest , KEYPAD )
595+
596+ primary_cols , primary_rows = self ._resolve_layout (profile_manifest , page_manifest , KEYPAD )
597+ total_button_count = _total_action_count (page_manifest )
515598
516599 if create_new :
517600 pages_section = profile_manifest .setdefault ("Pages" , {})
@@ -542,8 +625,9 @@ def write_page(
542625 "page_index" : None if create_new else page_index ,
543626 "directory_id" : page_dir .name ,
544627 "page_uuid" : page_uuid ,
545- "layout" : {"columns" : columns , "rows" : rows },
546- "button_count" : len (actions ),
628+ "layout" : {"columns" : primary_cols , "rows" : primary_rows },
629+ "layouts" : layouts_out ,
630+ "button_count" : total_button_count ,
547631 "page_name" : page_manifest .get ("Name" , "" ),
548632 "manifest_path" : str (page_dir / "manifest.json" ),
549633 "app_quit" : app_stop_report ,
@@ -779,7 +863,6 @@ def _build_page_ref(
779863 is_current : bool ,
780864 ) -> PageRef :
781865 page_manifest = _load_json (manifest_path )
782- actions = _controller_actions (page_manifest )
783866 return PageRef (
784867 page_index = page_index ,
785868 directory_id = directory_id ,
@@ -790,7 +873,7 @@ def _build_page_ref(
790873 is_default = is_default ,
791874 is_current = is_current ,
792875 name = str (page_manifest .get ("Name" , "" )),
793- button_count = len ( actions ),
876+ button_count = _total_action_count ( page_manifest ),
794877 icon_count = _count_icons (manifest_path .parent ),
795878 )
796879
@@ -822,19 +905,24 @@ def _resolve_layout(
822905 self ,
823906 profile_manifest : dict [str , Any ],
824907 page_manifest : dict [str , Any ] | None = None ,
908+ controller_type : str = KEYPAD ,
825909 ) -> tuple [int , int ]:
826910 device_model = str (profile_manifest .get ("Device" , {}).get ("Model" , "" ))
827- if device_model in MODEL_LAYOUTS :
828- return MODEL_LAYOUTS [device_model ]
911+ model_entry = MODEL_LAYOUTS .get (device_model )
912+ if model_entry and controller_type in model_entry :
913+ return model_entry [controller_type ]
829914
830915 if page_manifest :
831- actions = _controller_actions (page_manifest )
916+ actions = _controller_actions (page_manifest , controller_type )
832917 if actions :
833918 cols = max (int (position .split ("," )[0 ]) for position in actions ) + 1
834919 rows = max (int (position .split ("," )[1 ]) for position in actions ) + 1
835920 if cols > 0 and rows > 0 :
836921 return cols , rows
837922
923+ if controller_type == ENCODER :
924+ return (0 , 0 )
925+
838926 return (5 , 3 )
839927
840928 def _resolve_button_position (
0 commit comments