]> git.0d.be Git - jack_mixer.git/blob - nsmclient.py
Set version to 14 in preparation for next release
[jack_mixer.git] / nsmclient.py
1 #! /usr/bin/env python3
2 # -*- coding: utf-8 -*-
3 """
4 PyNSMClient -  A New Session Manager Client-Library in one file.
5
6 The Non-Session-Manager by Jonathan Moore Liles <male@tuxfamily.org>: http://non.tuxfamily.org/nsm/
7 New Session Manager, by LinuxAudio.org: https://github.com/linuxaudio/new-session-manager
8 With help from code fragments from https://github.com/attwad/python-osc ( DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE v2 )
9
10 MIT License
11
12 Copyright 2014-2020 Nils Hilbricht https://www.laborejo.org
13
14 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
15 associated documentation files (the "Software"), to deal in the Software without restriction,
16 including without limitation the rights to use, copy, modify, merge, publish, distribute,
17 sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
18 furnished to do so, subject to the following conditions:
19
20 The above copyright notice and this permission notice shall be included in all copies or
21 substantial portions of the Software.
22
23 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
24 NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
25 NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
26 DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
27 OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
28 """
29
30 import logging;
31 logger = None #filled by init with prettyName
32
33 import struct
34 import socket
35 from os import getenv, getpid, kill
36 import os
37 import os.path
38 import shutil
39 from uuid import uuid4
40 from sys import argv
41 from signal import signal, SIGTERM, SIGINT, SIGKILL #react to exit signals to close the client gracefully. Or kill if the client fails to do so.
42 from urllib.parse import urlparse
43
44 class _IncomingMessage(object):
45     """Representation of a parsed datagram representing an OSC message.
46
47     An OSC message consists of an OSC Address Pattern followed by an OSC
48     Type Tag String followed by zero or more OSC Arguments.
49     """
50
51     def __init__(self, dgram):
52         #NSM Broadcasts are bundles, but very simple ones. We only need to care about the single message it contains.
53         #Therefore we can strip the bundle prefix and handle it as normal message.
54         if b"#bundle" in dgram:
55             bundlePrefix, singleMessage = dgram.split(b"/", maxsplit=1)
56             dgram = b"/" + singleMessage  # / eaten by split
57             self.isBroadcast = True
58         else:
59             self.isBroadcast = False
60         self.LENGTH = 4 #32 bit
61         self._dgram = dgram
62         self._parameters = []
63         self.parse_datagram()
64
65
66     def get_int(self, dgram, start_index):
67         """Get a 32-bit big-endian two's complement integer from the datagram.
68
69         Args:
70         dgram: A datagram packet.
71         start_index: An index where the integer starts in the datagram.
72
73         Returns:
74         A tuple containing the integer and the new end index.
75
76         Raises:
77         ValueError if the datagram could not be parsed.
78         """
79         try:
80             if len(dgram[start_index:]) < self.LENGTH:
81                 raise ValueError('Datagram is too short')
82             return (
83                 struct.unpack('>i', dgram[start_index:start_index + self.LENGTH])[0], start_index + self.LENGTH)
84         except (struct.error, TypeError) as e:
85             raise ValueError('Could not parse datagram %s' % e)
86
87     def get_string(self, dgram, start_index):
88         """Get a python string from the datagram, starting at pos start_index.
89
90         We receive always the full string, but handle only the part from the start_index internally.
91         In the end return the offset so it can be added to the index for the next parameter.
92         Each subsequent call handles less of the same string, starting further to the right.
93
94         According to the specifications, a string is:
95         "A sequence of non-null ASCII characters followed by a null,
96         followed by 0-3 additional null characters to make the total number
97         of bits a multiple of 32".
98
99         Args:
100         dgram: A datagram packet.
101         start_index: An index where the string starts in the datagram.
102
103         Returns:
104         A tuple containing the string and the new end index.
105
106         Raises:
107         ValueError if the datagram could not be parsed.
108         """
109         #First test for empty string, which is nothing, followed by a terminating \x00 padded by three additional \x00.
110         if dgram[start_index:].startswith(b"\x00\x00\x00\x00"):
111             return "", start_index + 4
112
113         #Otherwise we have a non-empty string that must follow the rules of the docstring.
114
115         offset = 0
116         try:
117             while dgram[start_index + offset] != 0:
118                 offset += 1
119             if offset == 0:
120                 raise ValueError('OSC string cannot begin with a null byte: %s' % dgram[start_index:])
121             # Align to a byte word.
122             if (offset) % self.LENGTH == 0:
123                 offset += self.LENGTH
124             else:
125                 offset += (-offset % self.LENGTH)
126             # Python slices do not raise an IndexError past the last index,
127                 # do it ourselves.
128             if offset > len(dgram[start_index:]):
129                 raise ValueError('Datagram is too short')
130             data_str = dgram[start_index:start_index + offset]
131             return data_str.replace(b'\x00', b'').decode('utf-8'), start_index + offset
132         except IndexError as ie:
133             raise ValueError('Could not parse datagram %s' % ie)
134         except TypeError as te:
135             raise ValueError('Could not parse datagram %s' % te)
136
137     def get_float(self, dgram, start_index):
138         """Get a 32-bit big-endian IEEE 754 floating point number from the datagram.
139
140           Args:
141             dgram: A datagram packet.
142             start_index: An index where the float starts in the datagram.
143
144           Returns:
145             A tuple containing the float and the new end index.
146
147           Raises:
148             ValueError if the datagram could not be parsed.
149         """
150         try:
151             return (struct.unpack('>f', dgram[start_index:start_index + self.LENGTH])[0], start_index + self.LENGTH)
152         except (struct.error, TypeError) as e:
153             raise ValueError('Could not parse datagram %s' % e)
154
155     def parse_datagram(self):
156         try:
157             self._address_regexp, index = self.get_string(self._dgram, 0)
158             if not self._dgram[index:]:
159                 # No params is legit, just return now.
160                 return
161
162             # Get the parameters types.
163             type_tag, index = self.get_string(self._dgram, index)
164             if type_tag.startswith(','):
165                 type_tag = type_tag[1:]
166
167             # Parse each parameter given its type.
168             for param in type_tag:
169                 if param == "i":  # Integer.
170                     val, index = self.get_int(self._dgram, index)
171                 elif param == "f":  # Float.
172                     val, index = self.get_float(self._dgram, index)
173                 elif param == "s":  # String.
174                     val, index = self.get_string(self._dgram, index)
175                 else:
176                     logger.warning("Unhandled parameter type: {0}".format(param))
177                     continue
178                 self._parameters.append(val)
179         except ValueError as pe:
180             #raise ValueError('Found incorrect datagram, ignoring it', pe)
181             # Raising an error is not ignoring it!
182             logger.warning("Found incorrect datagram, ignoring it. {}".format(pe))
183
184     @property
185     def oscpath(self):
186         """Returns the OSC address regular expression."""
187         return self._address_regexp
188
189     @staticmethod
190     def dgram_is_message(dgram):
191         """Returns whether this datagram starts as an OSC message."""
192         return dgram.startswith(b'/')
193
194     @property
195     def size(self):
196         """Returns the length of the datagram for this message."""
197         return len(self._dgram)
198
199     @property
200     def dgram(self):
201         """Returns the datagram from which this message was built."""
202         return self._dgram
203
204     @property
205     def params(self):
206         """Convenience method for list(self) to get the list of parameters."""
207         return list(self)
208
209     def __iter__(self):
210         """Returns an iterator over the parameters of this message."""
211         return iter(self._parameters)
212
213 class _OutgoingMessage(object):
214     def __init__(self, oscpath):
215         self.LENGTH = 4 #32 bit
216         self.oscpath = oscpath
217         self._args = []
218
219     def write_string(self, val):
220         dgram = val.encode('utf-8')
221         diff = self.LENGTH - (len(dgram) % self.LENGTH)
222         dgram += (b'\x00' * diff)
223         return dgram
224
225     def write_int(self, val):
226         return struct.pack('>i', val)
227
228     def write_float(self, val):
229         return struct.pack('>f', val)
230
231     def add_arg(self, argument):
232         t = {str:"s", int:"i", float:"f"}[type(argument)]
233         self._args.append((t, argument))
234
235     def build(self):
236         dgram = b''
237
238         #OSC Path
239         dgram += self.write_string(self.oscpath)
240
241         if not self._args:
242             dgram += self.write_string(',')
243             return dgram
244
245         # Write the parameters.
246         arg_types = "".join([arg[0] for arg in self._args])
247         dgram += self.write_string(',' + arg_types)
248         for arg_type, value in self._args:
249             f = {"s":self.write_string, "i":self.write_int, "f":self.write_float}[arg_type]
250             dgram += f(value)
251         return dgram
252
253 class NSMNotRunningError(Exception):
254     """Error raised when environment variable $NSM_URL was not found."""
255
256 class NSMClient(object):
257     """The representation of the host programs as NSM sees it.
258     Technically consists of an udp server and a udp client.
259
260     Does not run an event loop itself and depends on the host loop.
261     E.g. a Qt timer or just a simple while True: sleep(0.1) in Python."""
262     def __init__(self, prettyName, supportsSaveStatus, saveCallback, openOrNewCallback, exitProgramCallback, hideGUICallback=None, showGUICallback=None, broadcastCallback=None, sessionIsLoadedCallback=None, loggingLevel = "info"):
263
264         self.nsmOSCUrl = self.getNsmOSCUrl() #this fails and raises NSMNotRunningError if NSM is not available. Host programs can ignore it or exit their program.
265
266         self.realClient = True
267         self.cachedSaveStatus = None #save status checks for this.
268
269         global logger
270         logger = logging.getLogger(prettyName)
271         logger.info("import")
272         if loggingLevel == "info" or loggingLevel == 20:
273             logging.basicConfig(level=logging.INFO) #development
274             logger.info("Starting PyNSM2 Client with logging level INFO. Switch to 'error' for a release!") #the NSM name is not ready yet so we just use the pretty name
275         elif loggingLevel == "error" or loggingLevel == 40:
276             logging.basicConfig(level=logging.ERROR) #production
277         else:
278             logging.warning("Unknown logging level: {}. Choose 'info' or 'error'".format(loggingLevel))
279             logging.basicConfig(level=logging.INFO) #development
280
281         #given parameters,
282         self.prettyName = prettyName #keep this consistent! Settle for one name.
283         self.supportsSaveStatus = supportsSaveStatus
284         self.saveCallback = saveCallback
285         self.exitProgramCallback = exitProgramCallback
286         self.openOrNewCallback = openOrNewCallback #The host needs to: Create a jack client with ourClientNameUnderNSM - Open the saved file and all its resources
287         self.broadcastCallback = broadcastCallback
288         self.hideGUICallback = hideGUICallback
289         self.showGUICallback = showGUICallback
290         self.sessionIsLoadedCallback = sessionIsLoadedCallback
291
292         #Reactions get the raw _IncomingMessage OSC object
293         #A client can add to reactions.
294         self.reactions = {
295                           "/nsm/client/save" : self._saveCallback,
296                           "/nsm/client/show_optional_gui" : lambda msg: self.showGUICallback(),
297                           "/nsm/client/hide_optional_gui" : lambda msg: self.hideGUICallback(),
298                           "/nsm/client/session_is_loaded" : self._sessionIsLoadedCallback,
299                           #Hello source-code reader. You can add your own reactions here by nsmClient.reactions[oscpath]=func, where func gets the raw _IncomingMessage OSC object as argument.
300                           #broadcast is handled directly by the function because it has more parameters
301                           }
302         #self.discardReactions = set(["/nsm/client/session_is_loaded"])
303         self.discardReactions = set()
304
305         #Networking and Init
306         self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) #internet, udp
307         self.sock.bind(('', 0)) #pick a free port on localhost.
308         ip, port = self.sock.getsockname()
309         self.ourOscUrl = f"osc.udp://{ip}:{port}/"
310
311         self.executableName = self.getExecutableName()
312
313         #UNIX Signals. Used for quit.
314         signal(SIGTERM, self.sigtermHandler) #NSM sends only SIGTERM. #TODO: really? pynsm version 1 handled sigkill as well.
315         signal(SIGINT, self.sigtermHandler)
316
317         #The following instance parameters are all set in announceOurselves
318         self.serverFeatures = None
319         self.sessionName = None
320         self.ourPath = None
321         self.ourClientNameUnderNSM = None
322         self.ourClientId = None # the "file extension" of ourClientNameUnderNSM
323         self.isVisible = None #set in announceGuiVisibility
324         self.saveStatus = True # true is clean. false means we need saving.
325
326         self.announceOurselves()
327
328         assert self.serverFeatures, self.serverFeatures
329         assert self.sessionName, self.sessionName
330         assert self.ourPath, self.ourPath
331         assert self.ourClientNameUnderNSM, self.ourClientNameUnderNSM
332
333         self.sock.setblocking(False) #We have waited for tha handshake. Now switch blocking off because we expect sock.recvfrom to be empty in 99.99...% of the time so we shouldn't wait for the answer.
334         #After this point the host must include self.reactToMessage in its event loop
335
336         #We assume we are save at startup.
337         self.announceSaveStatus(isClean = True)
338
339         logger.info("NSMClient client init complete. Going into listening mode.")
340
341
342     def reactToMessage(self):
343         """This is the main loop message. It is added to the clients event loop."""
344         try:
345             data, addr = self.sock.recvfrom(4096) #4096 is quite big. We don't expect nsm messages this big. Better safe than sorry. However, messages will crash the program if they are bigger than 4096.
346         except BlockingIOError: #happens while no data is received. Has nothing to do with blocking or not.
347             return None
348
349         msg = _IncomingMessage(data)
350         if msg.oscpath in self.reactions:
351             self.reactions[msg.oscpath](msg)
352         elif msg.oscpath in self.discardReactions:
353             pass
354         elif msg.oscpath == "/reply" and msg.params == ["/nsm/server/open", "Loaded."]: #NSM sends that all programs of the session were loaded.
355             logger.info ("Got /reply Loaded from NSM Server")
356         elif msg.oscpath == "/reply" and msg.params == ["/nsm/server/save", "Saved."]: #NSM sends that all program-states are saved. Does only happen from the general save instruction, not when saving our client individually
357             logger.info ("Got /reply Saved from NSM Server")
358         elif msg.isBroadcast:
359             if self.broadcastCallback:
360                 logger.info (f"Got broadcast with messagePath {msg.oscpath} and listOfArguments {msg.params}")
361                 self.broadcastCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM, msg.oscpath, msg.params)
362             else:
363                 logger.info (f"No callback for broadcast! Got messagePath {msg.oscpath} and listOfArguments {msg.params}")
364         elif msg.oscpath == "/error":
365             logger.warning("Got /error from NSM Server. Path: {} , Parameter: {}".format(msg.oscpath, msg.params))
366         else:
367             logger.warning("Reaction not implemented:. Path: {} , Parameter: {}".format(msg.oscpath, msg.params))
368
369
370     def send(self, path:str, listOfParameters:list, host=None, port=None):
371         """Send any osc message. Defaults to nsmd URL.
372         Will not wait for an answer but return None."""
373         if host and port:
374             url = (host, port)
375         else:
376             url = self.nsmOSCUrl
377         msg = _OutgoingMessage(path)
378         for arg in listOfParameters:
379             msg.add_arg(arg) #type is auto-determined by outgoing message
380         self.sock.sendto(msg.build(), url)
381
382     def getNsmOSCUrl(self):
383         """Return and save the nsm osc url or raise an error"""
384         nsmOSCUrl = getenv("NSM_URL")
385         if not nsmOSCUrl:
386             raise NSMNotRunningError("New-Session-Manager environment variable $NSM_URL not found.")
387         else:
388             #osc.udp://hostname:portnumber/
389             o = urlparse(nsmOSCUrl)
390             return o.hostname, o.port
391
392     def getExecutableName(self):
393         """Finding the actual executable name can be a bit hard
394         in Python. NSM wants the real starting point, even if
395         it was a bash script.
396         """
397         #TODO: I really don't know how to find out the name of the bash script
398         fullPath = argv[0]
399         assert os.path.dirname(fullPath) in os.environ["PATH"], (fullPath, os.path.dirname(fullPath), os.environ["PATH"]) #NSM requires the executable to be in the path. No excuses. This will never happen since the reference NSM server-GUI already checks for this.
400
401         executableName = os.path.basename(fullPath)
402         assert not "/" in executableName, executableName #see above.
403         return executableName
404
405     def announceOurselves(self):
406         """Say hello to NSM and tell it we are ready to receive
407         instructions
408
409         /nsm/server/announce s:application_name s:capabilities s:executable_name i:api_version_major i:api_version_minor i:pid"""
410
411         def buildClientFeaturesString():
412             #:dirty:switch:progress:
413             result = []
414             if self.supportsSaveStatus:
415                 result.append("dirty")
416             if self.hideGUICallback and self.showGUICallback:
417                 result.append("optional-gui")
418             if result:
419                 return ":".join([""] + result + [""])
420             else:
421                 return ""
422
423         logger.info("Sending our NSM-announce message")
424
425         announce = _OutgoingMessage("/nsm/server/announce")
426         announce.add_arg(self.prettyName)  #s:application_name
427         announce.add_arg(buildClientFeaturesString()) #s:capabilities
428         announce.add_arg(self.executableName)  #s:executable_name
429         announce.add_arg(1)  #i:api_version_major
430         announce.add_arg(2)  #i:api_version_minor
431         announce.add_arg(int(getpid())) #i:pid
432         hostname, port = self.nsmOSCUrl
433         assert hostname, self.nsmOSCUrl
434         assert port, self.nsmOSCUrl
435         self.sock.sendto(announce.build(), self.nsmOSCUrl)
436
437         #Wait for /reply (aka 'Howdy, what took you so long?)
438         data, addr = self.sock.recvfrom(1024)
439         msg = _IncomingMessage(data)
440
441         if msg.oscpath == "/error":
442             originalMessage, errorCode, reason = msg.params
443             logger.error("Code {}: {}".format(errorCode, reason))
444             quit()
445
446         elif msg.oscpath == "/reply":
447             nsmAnnouncePath, welcomeMessage, managerName, self.serverFeatures = msg.params
448             assert nsmAnnouncePath == "/nsm/server/announce", nsmAnnouncePath
449             logger.info("Got /reply " + welcomeMessage)
450
451             #Wait for /nsm/client/open
452             data, addr = self.sock.recvfrom(1024)
453             msg = _IncomingMessage(data)
454             assert msg.oscpath == "/nsm/client/open", msg.oscpath
455             self.ourPath, self.sessionName, self.ourClientNameUnderNSM = msg.params
456             self.ourClientId = os.path.splitext(self.ourClientNameUnderNSM)[1][1:]
457             logger.info("Got '/nsm/client/open' from NSM. Telling our client to load or create a file with name {}".format(self.ourPath))
458             self.openOrNewCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM) #Host function to either load an existing session or create a new one.
459             logger.info("Our client should be done loading or creating the file {}".format(self.ourPath))
460             replyToOpen = _OutgoingMessage("/reply")
461             replyToOpen.add_arg("/nsm/client/open")
462             replyToOpen.add_arg("{} is opened or created".format(self.prettyName))
463             self.sock.sendto(replyToOpen.build(), self.nsmOSCUrl)
464         else:
465             raise ValueError("Unexpected message path after announce: {}".format((msg.oscpath, msg.params)))
466
467     def announceGuiVisibility(self, isVisible):
468         message = "/nsm/client/gui_is_shown" if isVisible else "/nsm/client/gui_is_hidden"
469         self.isVisible = isVisible
470         guiVisibility = _OutgoingMessage(message)
471         logger.info("Telling NSM that our clients switched GUI visibility to: {}".format(message))
472         self.sock.sendto(guiVisibility.build(), self.nsmOSCUrl)
473
474     def announceSaveStatus(self, isClean):
475         """Only send to the NSM Server if there was really a change"""
476         if not self.supportsSaveStatus:
477             return
478
479         if not isClean == self.cachedSaveStatus:
480             message = "/nsm/client/is_clean" if isClean else "/nsm/client/is_dirty"
481             self.cachedSaveStatus = isClean
482             saveStatus = _OutgoingMessage(message)
483             logger.info("Telling NSM that our clients save state is now: {}".format(message))
484             self.sock.sendto(saveStatus.build(), self.nsmOSCUrl)
485
486     def _saveCallback(self, msg):
487         logger.info("Telling our client to save as {}".format(self.ourPath))
488         self.saveCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM)
489         replyToSave = _OutgoingMessage("/reply")
490         replyToSave.add_arg("/nsm/client/save")
491         replyToSave.add_arg("{} saved".format(self.prettyName))
492         self.sock.sendto(replyToSave.build(), self.nsmOSCUrl)
493         #it is assumed that after saving the state is clear
494         self.announceSaveStatus(isClean = True)
495
496
497     def _sessionIsLoadedCallback(self, msg):
498         if self.sessionIsLoadedCallback:
499             logger.info("Received 'Session is Loaded'. Our client supports it. Forwarding message...")
500             self.sessionIsLoadedCallback()
501         else:
502             logger.info("Received 'Session is Loaded'. Our client does not support it, which is the default. Discarding message...")
503
504     def sigtermHandler(self, signal, frame):
505         """Wait for the user to quit the program
506
507         The user function does not need to exit itself.
508         Just shutdown audio engines etc.
509
510         It is possible, that the client does not implement quit
511         properly. In that case NSM protocol demands that we quit anyway.
512         No excuses.
513
514         Achtung GDB! If you run your program with
515             gdb --args python foo.py
516         the Python signal handler will not work. This has nothing to do with this library.
517         """
518         logger.info("Telling our client to quit.")
519         self.exitProgramCallback(self.ourPath, self.sessionName, self.ourClientNameUnderNSM)
520         #There is a chance that exitProgramCallback will hang and the program won't quit. However, this is broken design and bad programming. We COULD place a timeout here and just kill after 10s or so, but that would make quitting our responsibility and fixing a broken thing.
521         #If we reach this point we have reached the point of no return. Say goodbye.
522         logger.warning("Client did not quit on its own. Sending SIGKILL.")
523         kill(getpid(), SIGKILL)
524         logger.error("SIGKILL did nothing. Do it manually.")
525
526     def debugResetDataAndExit(self):
527         """This is solely meant for debugging and testing. The user way of action should be to
528         remove the client from the session and add a new instance, which will get a different
529         NSM-ID.
530         Afterwards we perform a clean exit."""
531         logger.warning("debugResetDataAndExit will now delete {} and then request an exit.".format(self.ourPath))
532         if os.path.exists(self.ourPath):
533             if os.path.isfile(self.ourPath):
534                 try:
535                     os.remove(self.ourPath)
536                 except Exception as e:
537                     logger.info(e)
538             elif os.path.isdir(self.ourPath):
539                 try:
540                     shutil.rmtree(self.ourPath)
541                 except Exception as e:
542                     logger.info(e)
543         else:
544             logger.info("{} does not exist.".format(self.ourPath))
545         self.serverSendExitToSelf()
546
547     def serverSendExitToSelf(self):
548         """If you want a very strict client you can block any non-NSM quit-attempts, like ignoring a
549         qt closeEvent, and instead send the NSM Server a request to close this client.
550         This method is a shortcut to do just that.
551         """
552         logger.info("Sending SIGTERM to ourselves to trigger the exit callback.")
553         #if "server-control" in self.serverFeatures:
554         #    message = _OutgoingMessage("/nsm/server/stop")
555         #    message.add_arg("{}".format(self.ourClientId))
556         #    self.sock.sendto(message.build(), self.nsmOSCUrl)
557         #else:
558         kill(getpid(), SIGTERM) #this calls the exit callback
559
560     def serverSendSaveToSelf(self):
561         """Some clients want to offer a manual Save function, mostly for psychological reasons.
562         We offer a clean solution in calling this function which will trigger a round trip over the
563         NSM server so our client thinks it received a Save instruction. This leads to a clean
564         state with a good saveStatus and no required extra functionality in the client."""
565
566         logger.info("instructing the NSM-Server to send Save to ourselves.")
567         if "server-control" in self.serverFeatures:
568             #message = _OutgoingMessage("/nsm/server/save") # "Save All" Command.
569             message = _OutgoingMessage("/nsm/gui/client/save")
570             message.add_arg("{}".format(self.ourClientId))
571             self.sock.sendto(message.build(), self.nsmOSCUrl)
572         else:
573             logger.warning("...but the NSM-Server does not support server control. Server only supports: {}".format(self.serverFeatures))
574
575     def changeLabel(self, label:str):
576         """This function is implemented because it is provided by NSM. However, it does not much.
577         The message gets received but is not saved.
578         The official NSM GUI uses it but then does not save it.
579         We would have to send it every startup ourselves.
580
581         This is fine for us as clients, but you need to provide a GUI field to enter that label."""
582         logger.info("Telling the NSM-Server that our label is now " + label)
583         message = _OutgoingMessage("/nsm/client/label")
584         message.add_arg(label)  #s:label
585         self.sock.sendto(message.build(), self.nsmOSCUrl)
586
587     def broadcast(self, path:str, arguments:list):
588         """/nsm/server/broadcast s:path [arguments...]
589         We, as sender, will not receive the broadcast back.
590
591         Broadcasts starting with /nsm are not allowed and will get discarded by the server
592         """
593         if path.startswith("/nsm"):
594             logger.warning("Attempted broadbast starting with /nsm. Not allwoed")
595         else:
596             logger.info("Sending broadcast " + path + repr(arguments))
597             message = _OutgoingMessage("/nsm/server/broadcast")
598             message.add_arg(path)
599             for arg in arguments:
600                 message.add_arg(arg)  #type autodetect
601             self.sock.sendto(message.build(), self.nsmOSCUrl)
602
603     def importResource(self, filePath):
604         """aka. import into session
605
606         ATTENTION! You will still receive an absolute path from this function. You need to make
607         sure yourself that this path will not be saved in your save file, but rather use a place-
608         holder that gets replaced by the actual session path each time. A good point is after
609         serialisation. search&replace for the session prefix ("ourPath") and replace it with a tag
610         e.g. <sessionDirectory>. The opposite during load.
611         Only such a behaviour will make your session portable.
612
613         Do not use the following pattern: An alternative that comes to mind is to only work with
614         relative paths and force your programs workdir to the session directory. Better work with
615         absolute paths internally .
616
617         Symlinks given path into session dir and returns the linked path relative to the ourPath.
618         It can handles single files as well as whole directories.
619
620         if filePath is already a symlink we do not follow it. os.path.realpath or os.readlink will
621         not be used.
622
623         Multilayer links may indicate a users ordering system that depends on
624         abstractions. e.g. with mounted drives under different names which get symlinked to a
625         reliable path.
626
627         Basically do not question the type of our input filePath.
628
629         tar with the follow symlink option has os.path.realpath behaviour and therefore is able
630         to follow multiple levels of links anyway.
631
632         A hardlink does not count as a link and will be detected and treated as real file.
633
634         Cleaning up a session directory is either responsibility of the user
635         or of our client program. We do not provide any means to unlink or delete files from the
636         session directory.
637         """
638
639         #Even if the project was not saved yet now it is time to make our directory in the NSM dir.
640         if not os.path.exists(self.ourPath):
641             os.makedirs(self.ourPath)
642
643         filePath = os.path.abspath(filePath) #includes normalisation
644         if not os.path.exists(self.ourPath):raise FileNotFoundError(self.ourPath)
645         if not os.path.isdir(self.ourPath): raise NotADirectoryError(self.ourPath)
646         if not os.access(self.ourPath, os.W_OK): raise PermissionError("not writable", self.ourPath)
647
648         if not os.path.exists(filePath):raise FileNotFoundError(filePath)
649         if os.path.isdir(filePath): raise IsADirectoryError(filePath)
650         if not os.access(filePath, os.R_OK): raise PermissionError("not readable", filePath)
651
652         filePathInOurSession = os.path.commonprefix([filePath, self.ourPath]) == self.ourPath
653         linkedPath = os.path.join(self.ourPath, os.path.basename(filePath))
654         linkedPathAlreadyExists = os.path.exists(linkedPath)
655
656         if not os.access(os.path.dirname(linkedPath), os.W_OK): raise PermissionError("not writable", os.path.dirname(linkedPath))
657
658
659         if filePathInOurSession:
660             #loadResource from our session dir. Portable session, manually copied beforehand or just loading a link again.
661             linkedPath = filePath #we could return here, but we continue to get the tests below.
662             logger.info(f"tried to import external resource {filePath} but this is already in our session directory. We use this file directly instead. ")
663
664         elif linkedPathAlreadyExists and os.readlink(linkedPath) == filePath:
665             #the imported file already exists as link in our session dir. We do not link it again but simply report the existing link.
666             #We only check for the first target of the existing link and do not follow it through to a real file.
667             #This way all user abstractions and file structures will be honored.
668             linkedPath = linkedPath
669             logger.info(f"tried to import external resource {filePath} but this was already linked to our session directory before. We use the old link: {linkedPath} ")
670
671         elif linkedPathAlreadyExists:
672             #A new file shall be imported but it would create a linked name which already exists in our session dir.
673             #Because we already checked for a new link to the same file above this means actually linking a different file so we need to differentiate with a unique name
674             firstpart, extension = os.path.splitext(linkedPath)
675             uniqueLinkedPath = firstpart + "." + uuid4().hex + extension
676             assert not os.path.exists(uniqueLinkedPath)
677             os.symlink(filePath, uniqueLinkedPath)
678             logger.info(self.ourClientNameUnderNSM + f":pysm2: tried to import external resource {filePath} but potential target link {linkedPath} already exists. Linked to {uniqueLinkedPath} instead.")
679             linkedPath = uniqueLinkedPath
680
681         else: #this is the "normal" case. External resources will be linked.
682             assert not os.path.exists(linkedPath)
683             os.symlink(filePath, linkedPath)
684             logger.info(f"imported external resource {filePath} as link {linkedPath}")
685
686         assert os.path.exists(linkedPath), linkedPath
687         return linkedPath
688
689 class NullClient(object):
690     """Use this as a drop-in replacement if your program has a mode without NSM but you don't want
691     to change the code itself.
692     This was originally written for programs that have a core-engine and normal mode of operations
693     is a GUI with NSM but they also support commandline-scripts and batch processing.
694     For these you don't want NSM."""
695
696     def __init__(self, *args, **kwargs):
697         self.realClient = False
698         self.ourClientNameUnderNSM = "NSM Null Client"
699
700     def announceSaveStatus(self, *args):
701         pass
702
703     def announceGuiVisibility(self, *args):
704         pass
705
706     def reactToMessage(self):
707         pass
708
709     def importResource(self):
710         return ""
711
712     def serverSendExitToSelf(self):
713         quit()