Assists music production by grouping standalone programs into sessions. Community version of "Non Session Manager".
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

714 lines
33KB

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