#!/usr/bin/env python3 """Install a VoicePult folder/profile without replacing existing OpenDeck keys.""" import argparse import copy from datetime import datetime import json import os from pathlib import Path import shutil import signal import subprocess import time import urllib.request ROOT=Path(__file__).resolve().parents[1] PLUGIN='de.voicepult.control.sdPlugin' STARTER='com.amansprojects.starterpack.sdPlugin' def build(default, audio, device, parent, profile): keys=default['keys'] free=next((i for i,key in enumerate(keys) if key is None),None) if free is None: raise ValueError('Im Ausgangsprofil ist keine freie Taste für den VoicePult-Ordner.') items=[dict(c,kind='channel') for c in audio['channels']]+[dict(t,kind='target') for t in audio['targets']] if len(items)>len(keys)-1: raise ValueError('Zu viele Audiokanäle für eine einzelne Stream-Deck-Seite.') base={'alignment':'middle','background_colour':'#1d281f','colour':'#ffffff','family':'Liberation Sans','image_scale':100,'name':'','show':True,'size':13,'stroke_colour':'#000000','stroke_size':1,'style':'Bold','text':'','underline':False} def key(index,name,uuid,plugin,settings,images,text,automatic=False,inspector=None): states=[dict(base,image=f'plugins/{plugin}/{image}',text=text) for image in images] action={'controllers':['Keypad'],'disable_automatic_states':not automatic,'encoder':None,'icon':states[0]['image'],'name':name,'plugin':plugin,'property_inspector':inspector,'states':copy.deepcopy(states),'supported_in_multi_actions':True,'tooltip':name,'uuid':uuid,'visible_in_action_list':True} return {'action':action,'children':None,'context':f'Keypad.{index}.0','current_state':0,'settings':settings,'states':states} def switch(index,destination,title): result=key(index,'Switch Profile','com.amansprojects.starterpack.switchprofile',STARTER,{'device':device,'profile':destination},['icons/switchProfile.png'],title,True,f'plugins/{STARTER}/propertyInspector/switchProfile.html') result['states'][0]['image']=f'plugins/{PLUGIN}/folder.svg' return result updated=copy.deepcopy(default) updated['keys'][free]=switch(free,profile,'VoicePult') page=copy.deepcopy(default) page['keys']=[None]*len(keys) # A profile page must not inherit encoder or touchscreen actions. for field in ('sliders','encoders','touchscreens','infobars'): if field in page:page[field]=[None]*len(page[field]) if field!='infobars' else [] for index,item in enumerate(items): label=('Mic: ' if item['kind']=='target' else '')+item['name'] page['keys'][index]=key(index,'Kanal ein/aus','de.voicepult.control.toggle',PLUGIN,{'kind':item['kind'],'id':item['id'],'name':label},['on.svg','off.svg'],label+'\n'+('AUS' if item['muted'] else 'AN'),inspector=f'plugins/{PLUGIN}/inspector.html') page['keys'][index]['current_state']=int(item['muted']) page['keys'][-1]=switch(len(keys)-1,parent,'Zurück') return updated,page,free def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--device',required=True) parser.add_argument('--parent',default='Default') parser.add_argument('--profile',default='VoicePult') parser.add_argument('--apply',action='store_true') args=parser.parse_args() for value in (args.device,args.parent,args.profile): if not value or '/' in value or '\\' in value or value in ('.','..'): parser.error('Ungültiger Profil- oder Gerätename') config=Path(os.environ.get('XDG_CONFIG_HOME',Path.home()/'.config'))/'opendeck' directory=config/'profiles'/args.device parent=directory/(args.parent+'.json');profile=directory/(args.profile+'.json') if profile.exists() or (config/'plugins'/PLUGIN).exists(): raise SystemExit('VoicePult-Profil oder Plugin bereits vorhanden; bestehende Einrichtung wird nicht überschrieben.') if not (config/'plugins'/STARTER/'manifest.json').exists(): raise SystemExit('OpenDeck Starter Pack wird für die Ordnernavigation benötigt.') version=subprocess.check_output(['node','--version'],text=True).strip() if int(version.lstrip('v').split('.')[0])<22:raise SystemExit('Node.js 22 oder neuer benötigt.') opener=urllib.request.build_opener(urllib.request.ProxyHandler({})) audio=json.load(opener.open('http://127.0.0.1:8765/api/state',timeout=10)) updated,page,slot=build(json.loads(parent.read_text()),audio,args.device,args.parent,args.profile) print(f'Ordner {args.profile}: Taste {slot+1}, {len(audio["channels"])} Kanäle und {len(audio["targets"])} Mikrofonziele; Zurück auf Taste {len(page["keys"])}.') if not args.apply:return pids=subprocess.run(['pgrep','-u',str(os.getuid()),'-x','opendeck'],capture_output=True,text=True).stdout.split() for pid in pids:os.kill(int(pid),signal.SIGTERM) for _ in range(80): running=subprocess.run(['pgrep','-u',str(os.getuid()),'-x','opendeck'],capture_output=True).returncode==0 if not running:break time.sleep(.1) else:raise SystemExit('OpenDeck hat sich noch nicht beendet; keine Dateien verändert.') try: # Read again after shutdown, so the last user changes are preserved. updated,page,slot=build(json.loads(parent.read_text()),audio,args.device,args.parent,args.profile) backup=config/'voicepult-backups'/datetime.now().strftime('%Y%m%d-%H%M%S') backup.mkdir(parents=True) shutil.copy2(parent,backup/parent.name) shutil.copy2(config/'profiles'/(args.device+'.json'),backup/'device.json') shutil.copytree(ROOT/'integrations/opendeck'/PLUGIN,config/'plugins'/PLUGIN) for path,data in ((profile,page),(parent,updated)): temp=path.with_suffix('.voicepult.tmp');temp.write_text(json.dumps(data,ensure_ascii=False,indent=2));temp.replace(path) print('Sicherung:',backup) finally: subprocess.Popen(['/usr/bin/opendeck','--hide'],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True) print('OpenDeck mit VoicePult-Ordner gestartet.') if __name__=='__main__':main()