#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2021, Nils Hilbricht, Germany ( https://www.hilbricht.net ) This file is part of the Laborejo Software Suite ( https://www.laborejo.org ) This application is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ import logging; logger = logging.getLogger(__name__); logger.info("import") #Standard Library import datetime #Third Party #Our Modules from engine.start import PATHS from engine.config import METADATA #includes METADATA only. No other environmental setup is executed. from engine.jackclient import AgordejoJackClient from .nsmservercontrol import NsmServerControl from .watcher import Watcher from .findprograms import programDatabase class Callbacks(object): """GUI methods register themselves here. These methods get called by us, the engine. None of these methods produce any return value. The lists may be unordered. We need the lists for audio feedbacks in parallel to GUI updates. Or whatever parallel representations we run.""" def __init__(self): global jackClient self.message = [] #Session Management self.sessionOpenReady = [] self.sessionOpenLoading = [] self.sessionClosed = [] self.sessionsChanged = [] #update in the file structure. redraw list of sessions. self.sessionLocked = [] # incremental update. Sends the name of the session project and a bool if locked self.sessionFileChanged = [] #incremental update. Reports the session name, not the session file self.clientStatusChanged = [] #every status including GUI and dirty self.singleInstanceActivateWindow = [] #this is for the single-instance feature. Show the GUI window and activate it when this signal comes. self.dataClientNamesChanged = [] self.dataClientDescriptionChanged = [] #JackClient Callbacks. For the GUI they are mirrored here. These are mutable, shared lists. #The callback functions are in jackClient directly and api functions can call them. self.setPlaybackSeconds = jackClient.callback_setPlaybackSeconds def _dataClientNamesChanged(self, data): """If there is a dataclient in the session it will allow us to read and write metadata. The GUI instructs us to send a write instruction over OSC, we wait for the OSC answer and forward that to the GUI. If the dataClient joins the session it will trigger an unrequested callback (from the GUIs perspectvive). If the client leaves the callback will send a single None. This is a sign for the GUI to reset all data to the nsmd state. We do not mix nsmd data and dataClient. A dataclient can join and leave at every time, we keep the GUI informed. """ for func in self.dataClientNamesChanged: func(data) def _dataClientDescriptionChanged(self, data): """see _dataClientNamesChanged. In short: str for data, None if nsm-data leaves session""" for func in self.dataClientDescriptionChanged: func(data) def _singleInstanceActivateWindow(self): for func in self.singleInstanceActivateWindow: func() def _sessionOpenReady(self, nsmSessionExportDict): """A project got opened, most likely by ourselves, but also by another party. This will also fire if we start and detect that a session is already open and running. """ for func in self.sessionOpenReady: func(nsmSessionExportDict) def _sessionOpenLoading(self, nsmSessionExportDict): """ A session begins loading. Show a spinning clock or so... """ for func in self.sessionOpenLoading: func(nsmSessionExportDict) def _sessionClosed(self): """The current session got closed. Present a list of sessions to choose from again. This is also send at GUI start if there is no session open presently.""" for func in self.sessionClosed: func() def _sessionsChanged(self): """The project list changed. This can happen through internal changes like deletion or duplication but also through external changes through a filemanager. Always sends a full update of everything, with no indication of what changed.""" listOfProjectDicts = nsmServerControl.exportSessionsAsDicts() for func in self.sessionsChanged: func(listOfProjectDicts) return listOfProjectDicts def _sessionLocked(self, name:str, status:bool): """Called by the Watcher through the event loop Name is "nsmSessionName" from _nsmServerControl.exportSessionsAsDicts Sends True if project is locked""" for func in self.sessionLocked: func(name, status) def _sessionFileChanged(self, name:str, timestamp:str): """ This happens everytime the session gets saved. Called by the Watcher through the event loop. Name is "nsmSessionName" from _nsmServerControl.exportSessionsAsDicts. timestamp has same format as nsmServerControl.exportSessionsAsDicts""" for func in self.sessionFileChanged: func(name, timestamp) def _clientStatusChanged(self, clientInfoDict:dict): """A single function for all client changes. Adding and deletion is also included. GUI hide/show and dirty is also here. A GUI needs to check if it already knows the clientId or not.""" for func in self.clientStatusChanged: func(clientInfoDict) def startEngine(): logger.info("Start Engine") global eventLoop assert eventLoop global nsmServerControl nsmServerControl = NsmServerControl( sessionOpenReadyHook=callbacks._sessionOpenReady, sessionOpenLoadingHook=callbacks._sessionOpenLoading, sessionClosedHook=callbacks._sessionClosed, clientStatusHook=callbacks._clientStatusChanged, singleInstanceActivateWindowHook=callbacks._singleInstanceActivateWindow, dataClientNamesHook=callbacks._dataClientNamesChanged, dataClientDescriptionHook=callbacks._dataClientDescriptionChanged, parameterNsmOSCUrl=PATHS["url"], sessionRoot=PATHS["sessionRoot"], startupSession=PATHS["startupSession"], ) #Watch session tree for changes. global sessionWatcher sessionWatcher = Watcher(nsmServerControl) sessionWatcher.timeStampHook = callbacks._sessionFileChanged sessionWatcher.lockFileHook = callbacks._sessionLocked sessionWatcher.sessionsChangedHook = callbacks._sessionsChanged #This is the main callback that informs of new or updated sessions callbacks.sessionClosed.append(sessionWatcher.resume) #Watcher only active in "Choose a session mode" callbacks.sessionOpenReady.append(sessionWatcher.suspend) eventLoop.slowConnect(sessionWatcher.process) #Start Event Loop Processing eventLoop.fastConnect(nsmServerControl.process) eventLoop.fastConnect(jackClient._setPlaybackSeconds) eventLoop.slowConnect(nsmServerControl.processSingleInstance) #Send initial data #The decision if we are already in a session on startup or in "choose a session mode" is handled by callbacks #This is not to actually gather the data, but only to inform the GUI. logger.info("Send initial cached data to GUI.") callbacks._sessionsChanged() #send session list c = currentSession() #sessionName if c: callbacks._sessionOpenReady(nsmServerControl.sessionAsDict(c)) #Send client list. This is only necessary when attaching to an URL or using NSM-URL Env var #When we do --load-session we receive client updates live. #But this little redundancy doesn't hurt, we just sent them. Better safe than sorry. for clientId, clientDict in nsmServerControl.internalState["clients"].items(): callbacks._clientStatusChanged(clientDict) else: callbacks._sessionClosed() #explicit is better than implicit. Otherwise a GUI might start in the wrong state #nsmServerControl blocks until it has a connection to nsmd. That means at this point we are ready to send commands. #Until we return from startEngine a GUI will also not create its mainwindow. logger.info("Engine start complete") #Info def ourOwnServer(): """Report if we started nsmd on our own. If not we will not kill it when we quit""" return nsmServerControl.ourOwnServer def sessionRoot(): return nsmServerControl.sessionRoot def currentSession(): return nsmServerControl.internalState["currentSession"] def sessionList()->list: """Updates the list each call. Use only this from a GUI for active query. Otherwise sessionRemove and sessionCopy will not have updated the list""" r = nsmServerControl.exportSessionsAsDicts() return [s["nsmSessionName"] for s in r] def requestSessionList(): """For the rare occasions where that is needed""" callbacks._sessionsChanged() #send session list def buildSystemPrograms(progressHook=None): """Build a list of dicts with the .desktop files (or similar) of all NSM compatible programs present on the system""" programDatabase.build(progressHook) def systemProgramsSetWhitelist(executableNames:tuple): """will replace the current list""" programDatabase.userWhitelist = tuple(executableNames) #Needs rebuild through the GUI. We have no callback for this. def systemProgramsSetBlacklist(executableNames:tuple): """will replace the current list""" programDatabase.userBlacklist = tuple(executableNames) #Needs rebuild through the GUI. We have no callback for this. def getCache()->dict: """Returns the cached database from buildProgramDatabase. No automatic update. Empty on program start db = { "programs" : list of dicts iconPaths : list of strings } """ return programDatabase.getCache() def setCache(cache:dict): programDatabase.setCache(cache) def getNsmExecutables()->set: """Cached access fort fast membership tests. Is this program in the PATH?""" return programDatabase.nsmExecutables def getUnfilteredExecutables()->list: """Return a list of unique names without paths or directories of all exectuables in users $PATH. This is intended for a program starter prompt. GUI needs to supply tab completition or search itself""" programDatabase.unfilteredExecutables = programDatabase.buildCache_unfilteredExecutables() #quick call return programDatabase.unfilteredExecutables #Session Control #No project running #There is no callback for _sessionsChanged because we poll that in the event loop. def sessionNewTimestamped(): """convenience function. Create a new session without requiring a name and add suggested infrastructure clients""" nsmExecutables = getNsmExecutables() #type set, cached, very fast. connectionSaver = METADATA["preferredClients"]["data"] dataMeta = METADATA["preferredClients"]["connections"] startclients = [] if connectionSaver in nsmExecutables: startclients.append(connectionSaver) if dataMeta in nsmExecutables: startclients.append(dataMeta) #now = datetime.datetime.now().replace(second=0, microsecond=0).isoformat()[:-3] now = datetime.datetime.now().replace(microsecond=0).isoformat() name = now sessionNew(name, startclients) def sessionNew(newName:str, startClients:list=[]): nsmServerControl.new(newName, startClients) def sessionRename(nsmSessionName:str, newName:str): """only for non-open sessions""" nsmServerControl.renameSession(nsmSessionName, newName) def sessionCopy(nsmSessionName:str, newName:str, progressHook=None): """Create a copy of the session. Removes the lockfile, if any. Has some safeguards inside so it will not crash. If progressHook is provided (e.g. by a GUI) it will be called at regular intervals to inform of the copy process, or at least that it is still running. """ nsmServerControl.copySession(nsmSessionName, newName, progressHook) def sessionOpen(nsmSessionName:str): """Saves the current session and loads a different existing session.""" nsmServerControl.open(nsmSessionName) def sessionQuery(nsmSessionName:str): """For the occasional out-of-order information query. Exports a single session project in the format of nsmServerControl.exportSessionsAsDicts""" return nsmServerControl.sessionAsDict(nsmSessionName) def sessionForceLiftLock(nsmSessionName:str): nsmServerControl.forceLiftLock(nsmSessionName) callbacks._sessionLocked(nsmSessionName, False) def sessionDelete(nsmSessionName:str): nsmServerControl.deleteSession(nsmSessionName) #While Project is open def sessionSave(): """Saves the current session.""" nsmServerControl.save() def sessionClose(blocking=False): """Saves and closes the current session.""" nsmServerControl.close(blocking) def sessionAbort(blocking=False): """Close without saving the current session.""" nsmServerControl.abort(blocking) def sessionSaveAs(nsmSessionName:str): """Duplicate in NSM terms. Make a copy, close the current one, open the new one. However, it will NOT send the session clossed signal, just a session changed one.""" nsmServerControl.duplicate(nsmSessionName) def setDescription(text:str): nsmServerControl.setDescription(text) #Client Handling def clientAdd(executableName): nsmServerControl.clientAdd(executableName) #status hook triggers clientStatusChanged callback def clientStop(clientId:str): nsmServerControl.clientStop(clientId) def clientResume(clientId:str): """Opposite of clientStop""" nsmServerControl.clientResume(clientId) def clientRemove(clientId:str): """Client must be already stopped! We will do that without further question. Remove from the session. Will not delete the save-files, but make them inaccesible""" nsmServerControl.clientRemove(clientId) def clientSave(clientId:str): """Saves only the given client""" nsmServerControl.clientSave(clientId) def clientToggleVisible(clientId:str): """Works only if client announced itself with this feature""" nsmServerControl.clientToggleVisible(clientId) def clientHideAll(): nsmServerControl.allClientsHide() def clientShowAll(): nsmServerControl.allClientsShow() def clientNameOverride(clientId:str, name:str): """An agordejo-specific function that requires the client nsm-data in the session. If nsm-data is not present this function will write nothing, not touch any data. It will still send a callback to revert any GUI changes back to the original name. We accept empty string as a name to remove the name override """ nsmServerControl.clientNameOverride(clientId, name) def executableInSession(executable:str)->dict: """Returns None if no client with this executable is in the session, else returns a dict with its export-data. If multiple clients with this exe are in the session only one is returned, whatever Python thinks is good""" for clientId, dic in nsmServerControl.internalState["clients"].items(): if executable == dic["executable"]: return dic else: return None #Global Datastructures, set in startEngine nsmServerControl = None eventLoop = None sessionWatcher = None jackClient = AgordejoJackClient() #Create before callbacks callbacks = Callbacks() #This needs to be defined before startEngine() so a GUI can register its callbacks. The UI will then startEngine and wait to reveice the initial round of callbacks