VoicePult/scripts/install_opendeck.py
2026-10-03 11:07:47 +02:00

97 lines
6.1 KiB
Python

#!/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()