Browse Source

Attaching Argodejo to a running nsmd GUI, including --load-session, should work now. Maybe some corner cases, but this is a good basis for testing. FYI There is a known bug with our own nsm-data client that needs further work.

master
Nils 4 years ago
parent
commit
ad546321aa
  1. 27
      engine/api.py
  2. 147
      engine/nsmservercontrol.py
  3. 6
      engine/start.py
  4. 38
      qtgui/mainwindow.py
  5. 11
      qtgui/opensessioncontroller.py
  6. 7
      qtgui/quickopensessioncontroller.py
  7. 2
      qtgui/sessiontreecontroller.py

27
engine/api.py

@ -153,6 +153,7 @@ def startEngine():
dataClientDescriptionHook=callbacks._dataClientDescriptionChanged,
parameterNsmOSCUrl=PATHS["url"],
sessionRoot=PATHS["sessionRoot"],
startupSession=PATHS["startupSession"],
)
#Watch session tree for changes.
@ -172,19 +173,31 @@ def startEngine():
#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.
callbacks._sessionsChanged()
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. This can be used to create the appearance
#of a session-on-load:
if PATHS["startupSession"]:
logger.info(f"Got start-session as command line parameter. Opening: {PATHS['startupSession']}")
sessionOpen(PATHS["startupSession"])
PATHS["continueLastSession"] = None #just in case
#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

147
engine/nsmservercontrol.py

@ -40,6 +40,9 @@ from uuid import uuid4
from datetime import datetime
from sys import exit as sysexit
def nothing(*args, **kwargs):
pass
class _IncomingMessage(object):
"""Representation of a parsed datagram representing an OSC message.
@ -295,7 +298,8 @@ class NsmServerControl(object):
http://non.tuxfamily.org/nsm/API.html
"""
def __init__(self, sessionOpenReadyHook, sessionOpenLoadingHook, sessionClosedHook, clientStatusHook, singleInstanceActivateWindowHook, dataClientNamesHook, dataClientDescriptionHook, parameterNsmOSCUrl=None, sessionRoot=None, useCallbacks=True):
def __init__(self, sessionOpenReadyHook, sessionOpenLoadingHook, sessionClosedHook, clientStatusHook, singleInstanceActivateWindowHook, dataClientNamesHook, dataClientDescriptionHook,
parameterNsmOSCUrl=None, sessionRoot=None, startupSession=None, useCallbacks=True):
"""If useCallbacks is False you will see every message in the log.
This is just a development mode to see all messages, unfiltered.
@ -303,6 +307,10 @@ class NsmServerControl(object):
show in the logs"""
#Deactivate hooks for now. During init no hooks may be called,
#but some functions want to do that already. We setup the true hooks at the end of init
self.sessionOpenReadyHook= self.sessionOpenLoadingHook= self.sessionClosedHook= self.clientStatusHook= self.singleInstanceActivateWindowHook= self.dataClientNamesHook= self.dataClientDescriptionHook= nothing
self._queue = list() #Incoming OSC messages are buffered here.
#Status variables that are set by our callbacks
@ -320,15 +328,6 @@ class NsmServerControl(object):
self.dataStorage = None #Becomes DataStorage() every time a datastorage client does a broadcast announce.
self._addToNextSession = [] #A list of executables in PATH. Filled by new, waits for reply that session is created and then will send clientNew and clear the list.
#Hooks for api callbacks
self.sessionOpenReadyHook = sessionOpenReadyHook #nsmSessionName as parameter
self.sessionOpenLoadingHook = sessionOpenLoadingHook #nsmSessionName as parameter
self.sessionClosedHook = sessionClosedHook #no parameter. This is also "choose a session" mode
self.clientStatusHook = clientStatusHook #all client status is done via this single hook. GUIs need to check if they already know the client or not.
self.dataClientNamesHook = dataClientNamesHook
self.dataClientDescriptionHook = dataClientDescriptionHook
self.singleInstanceActivateWindowHook = singleInstanceActivateWindowHook #added to self.processSingleInstance() to listen for a message from another wannabe-instance
if useCallbacks:
self.callbacks = {
"/nsm/gui/session/name" : self._reactCallback_activeSessionChanged,
@ -387,7 +386,7 @@ class NsmServerControl(object):
#No further action required. GUI announce below this testing.
pass
else:
self._startNsmdOurselves(sessionRoot) #Session root can be a commandline parameter we forward to the server if we start it ourselves.
self._startNsmdOurselves(sessionRoot, startupSession) #Session root can be a commandline parameter we forward to the server if we start it ourselves. startupSession is an autoloader. Both are usually None.
assert type(self.ourOwnServer) is subprocess.Popen, (self.ourOwnServer, type(self.ourOwnServer))
#Wait for the server, or test if it is reacting.
@ -398,9 +397,22 @@ class NsmServerControl(object):
self.sessionRoot = self._initial_announce() #Triggers "hi" and session root
self.internalState["sessionRoot"] = self.sessionRoot
self._forceProcessOnceToEmptyQueue() #process any leftover messages.
atexit.register(self.quit) #mostly does stuff when we started nsmd ourself
#Activate hooks for api callbacks, now that we are finished here.
#Otherwise the hooks will get called from our functions (e.g. new client) while we are still during init
self.sessionOpenReadyHook = sessionOpenReadyHook #self.sessionAsDict(nsmSessionName) as parameter
self.sessionOpenLoadingHook = sessionOpenLoadingHook #self.sessionAsDict(nsmSessionName) as parameter
self.sessionClosedHook = sessionClosedHook #no parameter. This is also "choose a session" mode
self.clientStatusHook = clientStatusHook #all client status is done via this single hook. GUIs need to check if they already know the client or not.
self.dataClientNamesHook = dataClientNamesHook
self.dataClientDescriptionHook = dataClientDescriptionHook
self.singleInstanceActivateWindowHook = singleInstanceActivateWindowHook #added to self.processSingleInstance() to listen for a message from another wannabe-instance
self._receiverActive = True
logger.info("nsmservercontrol init is complete. Ready for event loop")
#Now an external event loop can add self.process
#Internal Methods
@ -457,6 +469,41 @@ class NsmServerControl(object):
self._receiverActive = True
logger.info("Resuming receiving async mode.")
def _forceProcessOnceToEmptyQueue(self):
"""Sometimes we want to make sure everything is processed until we continue. For example
in our init.
Initial usecase was connecting to a running nsmd with session. The api first callback to
export sessions to the GUI was freezing because listSession was chocking on leftover messages
from gui_announce to a running session, which sends the session name and a list of clients.
The latter is not happening when starting the server ourselves, so we weren't expecting this.
To be honest, this is really a patch to work around a design flaw and we hope this is a
one-off corner case."""
logger.info("Force processing queue")
#First gather all osc messages still in the pipe
while True:
try:
data, addr = self.sock.recvfrom(1024)
msg = _IncomingMessage(data)
if msg:
self._queue.append(msg)
except BlockingIOError: #happens while no data is received. Has nothing to do with blocking or not.
break
except socket.timeout:
break
#Now process them all. This is different than normal self.process().
for msg in self._queue:
if msg.oscpath in self.callbacks:
self.callbacks[msg.oscpath](msg.params)
else:
logger.warning(f"Unhandled message with path {msg.oscpath} and parameters {msg.params}")
self._queue.clear()
logger.info("Ended force processing queue")
def process(self):
"""Use this in an external event loop"""
if self._receiverActive:
@ -513,13 +560,20 @@ class NsmServerControl(object):
finally:
tempServerSock.close()
def _startNsmdOurselves(self, sessionRoot:str):
def _startNsmdOurselves(self, sessionRoot:str, startupSession:str):
assert self.nsmOSCUrl
hostname, port = self.nsmOSCUrl
arguments = ["nsmd","--osc-port", str(port)]
if sessionRoot:
self.ourOwnServer = subprocess.Popen(["nsmd","--osc-port", str(port), "--session-root", sessionRoot])
else:
self.ourOwnServer = subprocess.Popen(["nsmd","--osc-port", str(port)])
arguments += ["--session-root", sessionRoot]
if startupSession:
logger.info(f"Got start-session as command line parameter. Fowarding to nsmd command line: {startupSession}")
arguments += ["--load-session", startupSession]
self.ourOwnServer = subprocess.Popen(arguments)
def _blockingRequest(self, path:str, arguments:list, answerPath:str, answerArguments:list, repeat=False)->list:
@ -578,11 +632,15 @@ class NsmServerControl(object):
"""nsm/gui/gui_announce triggers a multi-stage reply. First we get "hi",
then we get the session root. We wait for session root and then clean 'hi' from the queue.
When we connect to a running nsmd we also receive /nsm/gui/session/name with the current
session (or empty string for no current).
If in a session we will receive a list of clients which ends the gui_announce stage.
Returns session root as pathlib-path."""
resultArguments = self._blockingRequest(path="/nsm/gui/gui_announce", arguments=[], answerPath="/nsm/gui/session/root", answerArguments=[])
if len(self._queue) == 1 and self._queue[0].oscpath == "/nsm/gui/gui_announce" and self._queue[0].params == ["hi"]:
logger.info("Got 'hi'. We are now the registered nsmd GUI as per our initial /nsm/gui/gui_announce")
self._queue.clear()
self._queue.clear() #this is safe because we tested above that there is exactly the hi message in the queue.
else:
logging.error(f"For ValueError below: {[(m.oscpath, m.params) for m in self._queue]}")
raise ValueError("We were expecting a clean _queue with only 'hi' as leftover, but instead there were unhandled messages. see print above. Better abort than a wrong program state")
@ -621,12 +679,19 @@ class NsmServerControl(object):
like copy, rename and delete.
Ask nsmd for projects in session root and update our internal state.
This will return None without doing anything when we are already in a session.
This will wait for an answer and block all other operations.
First is /nsm/gui/server/message ['Listing sessions']
Then session names come one reply at a time such as /reply ['/nsm/server/list', 'test3']
Finally /nsm/server/list [0, 'Done.'] , not a reply
"""
#In the past we only regenerated if we are not in a session. However, that was overzealous.
#Some GUI functions did not work. Better regenerate that list as often as we want.
logger.info("Requesting project list from session server in blocking mode")
self._setPause(True)
@ -760,6 +825,7 @@ class NsmServerControl(object):
We do not trust NSM to perform the right checks. It will add an empty path or wrong
path.
"""
if not pathlib.Path(executableName).name == executableName:
logger.warning(f"{executableName} must be just an executable file in your $PATH. We expected: {pathlib.Path(executableName).name} . We will not ask nsmd to add it as client")
return False
@ -790,7 +856,11 @@ class NsmServerControl(object):
def clientRemove(self, clientId:str):
"""Client needs to be stopped already. We will do that and wait for an answer.
Remove from the session. Will not delete the save-files, but make them inaccesible"""
Remove from the session. Will not delete the save-files, but make them inaccesible.
There is never a point in nsmservercontrol where self.internalState["clients"] is emptied.
nsmd actually sends a clientRemove for every client at session stop.
"""
#We have a blocking operation in here so we need to be extra cautios that the client exists.
if not clientId in self.internalState["clients"]:
return False
@ -914,6 +984,11 @@ class NsmServerControl(object):
parameters[0] is an osc path:str without naming constraints
the rest is a list of arguments.
Attention: a broadcast is not saved by the server. You either are in the session to receive
it or you will miss it. If we run Argodejo as attached GUI (incl. --load-session) a broadcast
after the session was loaded, where programs announce themselves to all other clients,
will not be received here. Such is the case with our data-client.
"""
logger.info(f"Received broadcast. Saving in internal state: {parameters}")
self.internalState["broadcasts"].append(parameters)
@ -962,7 +1037,7 @@ class NsmServerControl(object):
nsmSessionName, sessionPath = parameters
if not nsmSessionName and not sessionPath: #No session loaded. We are in session-choosing mode.
logger.info("Session closed or never started. Choose-A-Session mode.")
self.internalState["currentSession"] = None
self.internalState["currentSession"] = None #sessionCloseHooked triggers rebuilding of the session list, which will not work when there is a current session.
self.sessionClosedHook()
else:
sessionPath = sessionPath.lstrip("/")
@ -975,21 +1050,29 @@ class NsmServerControl(object):
self.clientAdd(autoClientExecutableInPath)
self._addToNextSession = [] #reset
elif l == 0: #Another way of "no session".
self.internalState["currentSession"] = None
self.internalState["currentSession"] = None #sessionCloseHooked triggers rebuilding of the session list, which will not work when there is a current session.
self.sessionClosedHook()
else:
raise NotImplementedError(parameters)
def _initializeEmptyClient(self, clientId:str):
"""NSM reuses signals. It is quite possible that this will be called multiple times,
e.g. after opening a session"""
e.g. after opening a session.
This is not a reaction callback, we call this ourselves only in _reactCallback_ClientNew
"""
#if not self.internalState["currentSession"]:
# logger.warning(f"We received a clientNew for ID {clientId} but no session open was received."
# "This would happen in an old nsmd version. If you see the GUI with an open session and a client list you can ignore this warning")
if clientId in self.internalState["clients"]:
return
logger.info(f"Creating new internal entry for client {clientId}")
client = {
"clientId":clientId, #for convenience, included internally as well
"executable":None, #For dumb clients this is the same as reportedName.
"dumbClient":True, #Bool. Real nsm or just any old program? status "Ready" switches this.
"executable":None, #Every client announces to the GUI with the exectuable name. True nsm clients later overwrite with a pretty name which we save as "reportedName"
"reportedName":None, #str . The reported name is first the executable name, for status started. But for NSM clients it gets replaced with a reported name.
"label":None, #str
"lastStatus":None, #str
@ -1035,25 +1118,25 @@ class NsmServerControl(object):
def _reactCallback_ClientNew(self, parameters:list):
"""/nsm/gui/client/new ['nBAVO', 'jackpatch']
This is both add client or open.
It appears in the session.
And the message actually comes twice. Once when you add a client, then parameters
The message comes twice. Once when you add a client, then parameters
will contain the executable name. If the client reports itself as NSM compatible through
announce we will also get the Open message through this function.
Then the name changes from executableName to a reportedName, which will remain for the rest
of the session.
A loaded session will directly start with the reported/announced name.
of the session. Executable name is still important to look up icons in the GUI.
This message is usually followed by /nsm/gui/client/status
"""
l = len(parameters)
if l == 2:
clientId, executableName = parameters
clientId, name = parameters
if not clientId in self.internalState["clients"]:
self._initializeEmptyClient(clientId)
self._setClientData(clientId, "executable", executableName)
logger.info(f"Client started {executableName}:{clientId}")
self._setClientData(clientId, "executable", name)
logger.info(f"Client started {name}:{clientId}")
else:
self._setClientData(clientId, "reportedName", executableName)
logger.info(f"Client upgraded to NSM-compatible: {executableName}:{clientId}")
self._setClientData(clientId, "reportedName", name)
logger.info(f"Client upgraded to NSM-compatible: {name}:{clientId}")
self.clientStatusHook(self.internalState["clients"][clientId])
else:
raise NotImplementedError(parameters)
@ -1421,7 +1504,6 @@ class NsmServerControl(object):
if not sessionFile.exists():
#This is a reason to let the program exit.
print (nsmSessionName)
logger.error("Got wrong session directory from nsmd. Race condition after delete? In any case a breaking error (please report). Quitting. Project was: " + repr(sessionFile))
sysexit() #return None switch to return None to let it crash and see the python traceback
@ -1440,7 +1522,10 @@ class NsmServerControl(object):
def exportSessionsAsDicts(self)->list:
"""Return a list of dicts of projects with additional information:
"""
logger.info("Exporting sessions to dict. Will call blocking list sessions next")
results = []
#assert not self.internalState["currentSession"], self.internalState["currentSession"] #Do not request session list while in active session
self._updateSessionListBlocking()
for nsmSessionName in self.internalState["sessions"]:
result = self.sessionAsDict(nsmSessionName)

6
engine/start.py

@ -48,7 +48,7 @@ parser.add_argument("-V", "--verbose", action='store_true', help="(Development)
parser.add_argument("-u", "--url", action='store', dest="url", help="Force URL for the session. If there is already a running session we will connect to it. Otherwise we will start one there. Default is local host with random port. Example: osc.udp://myhost.localdomain:14294/")
parser.add_argument("--nsm-url", action='store', dest="url", help="Same as --url.")
parser.add_argument("-s", "--session", action='store', dest="session", help="Session to open on startup. Overrides --continue")
parser.add_argument("-l", "--load-session", action='store', dest="session", help="Session to open on startup, must exist. Overrides --continue")
parser.add_argument("-c", "--continue", action='store_true', dest="continueLastSession", help="Autostart last active session.")
parser.add_argument("-i", "--hide", action='store_true', dest="starthidden", help="Start GUI hidden in tray, only if tray available on system.")
parser.add_argument("--session-root", action='store', dest="sessionRoot", help="Root directory of all sessions. Defaults to '$HOME/NSM Sessions'")
@ -127,6 +127,10 @@ else:
}
if PATHS["startupSession"]:
logger.warning("--continue ignored because --load-session was used.")
PATHS["continueLastSession"] = None #just in case. See --help string
logger.info("PATHS: {}".format(PATHS))
#Construct QAppliction before constantsAndCOnfigs, which has the fontDB

38
qtgui/mainwindow.py

@ -159,6 +159,25 @@ class MainWindow(QtWidgets.QMainWindow):
api.callbacks.sessionOpenReady.append(self.reactCallback_sessionOpen)
api.callbacks.singleInstanceActivateWindow.append(self.activateAndRaise)
#Handle the application data cache.
#This must happen before engineStart. If a session is already running a set of initial
#client-callbacks will arrive immediately, even before the eventLoop starts.
#If not present instruct the engine to build one.
#This is also needed by the prompt in sessionController
logger.info("Trying to restore cached program database")
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
if settings.contains("programDatabase"):
listOfDicts = settings.value("programDatabase", type=list)
api.setSystemsPrograms(listOfDicts)
logger.info("Restored program database from qt cache to engine")
self._updateGUIWithCachedPrograms()
else: #First or fresh start
#A single shot timer with 0 durations is executed only after the app starts, thus the main window is ready.
logger.info("First run. Instructing engine to build program database")
QtCore.QTimer.singleShot(0, self.updateProgramDatabase) #includes self._updateGUIWithCachedPrograms()
#Starting the engine sends initial GUI data. Every window and widget must be ready to receive callbacks here
api.eventLoop.start()
api.startEngine()
@ -171,7 +190,7 @@ class MainWindow(QtWidgets.QMainWindow):
logger.info("Starting visible")
self.toggleVisible(force=True)
if PATHS["continueLastSession"]: #will be None if --sesion NAME was given as command line parameter and --continue on top.
if PATHS["continueLastSession"]: #will be None if --load-session=NAME was given as command line parameter and --continue on top.
continueSession = self.recentlyOpenedSessions.last()
if continueSession:
logger.info(f"Got continue session as command line parameter. Opening: {continueSession}")
@ -179,21 +198,8 @@ class MainWindow(QtWidgets.QMainWindow):
else:
logger.info(f"Got continue session as command line parameter but there is no session available.")
#Handle the application data cache. If not present instruct the engine to build one.
#This is also needed by the prompt in sessionController
logger.info("Trying to restore cached program database")
settings = QtCore.QSettings("LaborejoSoftwareSuite", METADATA["shortName"])
if settings.contains("programDatabase"):
listOfDicts = settings.value("programDatabase", type=list)
api.setSystemsPrograms(listOfDicts)
logger.info("Restored program database from qt cache to engine")
self._updateGUIWithCachedPrograms()
else: #First or fresh start
#A single shot timer with 0 durations is executed only after the app starts, thus the main window is ready.
logger.info("First run. Instructing engine to build program database")
QtCore.QTimer.singleShot(0, self.updateProgramDatabase) #includes self._updateGUIWithCachedPrograms()
logger.info("Deciding if we run as tray-icon or window")
if not self.isVisible():
text = QtCore.QCoreApplication.translate("mainWindow", "Argodejo ready")
self.systemTray.showMessage("Argodejo", text, QtWidgets.QSystemTrayIcon.Information, 2000) #title, message, icon, timeout. #has messageClicked() signal.
@ -350,7 +356,7 @@ class MainWindow(QtWidgets.QMainWindow):
The TrayIcon provides another method of quitting that does not call this function,
but it will call _actualQuit.
"""
if api.currentSession():
if api.ourOwnServer() and api.currentSession():
result = self._askBeforeQuit(api.currentSession())
else:
result = True

11
qtgui/opensessioncontroller.py

@ -74,7 +74,7 @@ class ClientItem(QtWidgets.QTreeWidgetItem):
self.setText(index, text)
def updateData(self, clientDict:dict):
"""Arrives via parenTreeWidget api callback"""
"""Arrives via parenTreeWidget api callback statusChanged, which is nsm status changed"""
self.clientDict = clientDict
for index, key in enumerate(self.parentController.clientsTreeWidgetColumns):
if clientDict[key] is None:
@ -83,9 +83,9 @@ class ClientItem(QtWidgets.QTreeWidgetItem):
value = clientDict[key]
if key == "visible":
if value == True:
t = QtCore.QCoreApplication.translate("OpenSession", "")
t = ""
else:
t = QtCore.QCoreApplication.translate("OpenSession", "")
t = ""
elif key == "dirty":
if value == True:
t = QtCore.QCoreApplication.translate("OpenSession", "not saved")
@ -112,11 +112,6 @@ class ClientItem(QtWidgets.QTreeWidgetItem):
if clientDict["reportedName"] is None:
self.setText(nameColumn, clientDict["executable"])
#TODO: this should be an nsmd status. Check if excecutable exists. nsmd just reports "stopped", and worse: after a long timeout.
if clientDict["lastStatus"] == "stopped" and clientDict["reportedName"] is None:
self.setText(self.parentController.clientsTreeWidgetColumns.index("lastStatus"), QtCore.QCoreApplication.translate("OpenSession", "(command not found)"))
class ClientTable(object):
"""Controls the QTreeWidget that holds loaded clients"""

7
qtgui/quickopensessioncontroller.py

@ -251,6 +251,7 @@ class QuickOpenSessionController(object):
def _openReady(self, nsmSessionExportDict):
self._nsmSessionExportDict = nsmSessionExportDict
self.nameWidget.setText(nsmSessionExportDict["nsmSessionName"])
def _sendNameChange(self):
"""The closed callback is send on start to indicate "no open session". exportDict cache is
@ -320,8 +321,10 @@ class QuickOpenSessionController(object):
We also present only one icon per executable. If you want more go into the other mode.
"""
#index = self.listWidget.indexFromItem(QuickClientItem.allItems[clientId]).row() #Row is the real index in a listView, no matter iconViewMode.
if clientDict["dumbClient"]:
#This includes the initial loading of nsm-clients.
assert clientDict["executable"]
if clientDict["dumbClient"]: #only real nsm clients in our session, whic includes the initial "not-yet" status of nsm-clients.
return
backgroundClients = METADATA["preferredClients"].values()
if clientDict["executable"] in backgroundClients:

2
qtgui/sessiontreecontroller.py

@ -188,6 +188,8 @@ class SessionTreeController(object):
We also get this for every client change so we can update our numbers"""
self.treeWidget.clear()
print ("Callback sessions changed. list: ")
self._cachedSessionDicts = sessionDicts #in case we change the flat/nested mode.
for sessionDict in sessionDicts:

Loading…
Cancel
Save