#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2022 , 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 < http : / / www . gnu . org / licenses / > .
"""
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 = [ ]
self . dataClientTimelineMaximumDurationChanged = [ ] #in minutes. this is purely a GUI construct. the jackClient knows no limit!.
#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 _dataClientTimelineMaximumDurationChanged ( self , minutes : int ) :
"""
This callback is still used , even if nsm - data is not in the session .
It will then purely be a roundtrip from gui widget - > api - > gui - callback without
saving anything .
For compatibility reasons it will still send a " None " when nsm - data leaves the session .
The GUI can then just continue with the current value . It just
means that the values will not be saved in the session .
"""
for func in self . dataClientTimelineMaximumDurationChanged :
func ( minutes )
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 ,
dataClientTimelineMaximumDurationChangedHook = callbacks . _dataClientTimelineMaximumDurationChanged ,
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 . listenToAnotherInstanceAttempt )
#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 getNsmClients ( ) - > list :
""" Returns a, probably cached, list of dicts that represent all nsm capable clients
on this system . Including the list of clients the user added themselves """
return programDatabase . getNsmClients ( )
def getNsmExecutables ( ) - > set :
""" Cached access fort fast membership tests. Is this program in the PATH?
This is just to check if an executable is available on this system .
The content of the set are executable names , the same as [ " agordejoExec " ] from getNsmClients elements
"""
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 """
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 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 )
def setTimelineMaximumDuration ( minutes : int ) :
nsmServerControl . setTimelineMaximumDuration ( int ( minutes ) )
#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