#! /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 ), more specifically its template base application. The Template Base Application is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ import logging; logger = logging.getLogger("nsm-data"); logger.info("import") URL="https://www.laborejo.org/agordejo/nsm-data" HARD_LIMIT = 512 # no single message longer than this VERSION= 1.1 #In case the user tries to run this standalone. import argparse parser = argparse.ArgumentParser(description="nsm-data is a module for Agordejo. It only communicates over OSC in an NSM-Session and has no standalone functionality.") parser.add_argument("-v", "--version", action='version', version=str(VERSION)) args = parser.parse_args() import json import pathlib from time import sleep from sys import exit as sysexit from nsmclient import NSMClient from nsmclient import NSMNotRunningError def chunkstring(string): return [string[0+i:HARD_LIMIT+i] for i in range(0, len(string), HARD_LIMIT)] class DataClient(object): """ Keys are strings, While nsmd OSC support int, str and float we use json exclusively. We expect a json string and will parse it here. All message consist of two arguments maximum: a key and, if a create-function, a json string. Rule: all client-keys are send as strings, even in replies. All client-values are send as json-string, even if originally just a string. Description is a multi-part message, a string. DataClient will register itself as Data-Storage. All other communication is done via osc. In theory every application can read and write us (like a book!) We listen to OSC paths and reply to the sender, which must give its address explicitly. /agordejo/datastorage/readall s:request-host i:request-port #Request all data /agordejo/datastorage/read s:key s:request-host i:request-port #Request one value The write functions have no reply. They will print out to stdout/err but not send an error message back. /agordejo/datastorage/create s:key any:value #Write/Create one value /agordejo/datastorage/update s:kecy any:value #Update a value, but only if it exists /agordejo/datastorage/delete s:key #Remove a key/value completely """ def __init__(self): self.data = None #Dict. created in openOrNewCallbackFunction, saved as json self.absoluteJsonFilePath = None #pathlib.Path set by openOrNewCallbackFunction self._descriptionStringArray = {"identifier":None} #int:str self._descriptionId = None self.nsmClient = NSMClient(prettyName = "Data-Storage", #will raise an error and exit if this example is not run from NSM. saveCallback = self.saveCallbackFunction, openOrNewCallback = self.openOrNewCallbackFunction, supportsSaveStatus = True, # Change this to True if your program announces it's save status to NSM exitProgramCallback = self.exitCallbackFunction, broadcastCallback = None, hideGUICallback = None, #replace with your hiding function. You need to answer in your function with nsmClient.announceGuiVisibility(False) showGUICallback = None, #replace with your showing function. You need to answer in your function with nsmClient.announceGuiVisibility(True) sessionIsLoadedCallback = self.sessionIsLoadedCallback, #no parametersd loggingLevel = "error", #"info" for development or debugging, "error" for production. default is error. ) #Add custom callbacks. They all receive _IncomingMessage(data) self.nsmClient.reactions["/agordejo/datastorage/setclientoverridename"] = self.setClientOverrideName self.nsmClient.reactions["/agordejo/datastorage/getclientoverridename"] = self.getClientOverrideName self.nsmClient.reactions["/agordejo/datastorage/getall"] = self.getAll self.nsmClient.reactions["/agordejo/datastorage/getdescription"] = self.getDescription self.nsmClient.reactions["/agordejo/datastorage/setdescription"] = self.setDescription self.nsmClient.reactions["/agordejo/datastorage/gettimelinemaximum"] = self.getTimelineMaximum self.nsmClient.reactions["/agordejo/datastorage/settimelinemaximum"] = self.setTimelineMaximum #self.nsmClient.reactions["/agordejo/datastorage/read"] = self.reactRead #generic key/value storage #self.nsmClient.reactions["/agordejo/datastorage/readall"] = self.reactReadAll #self.nsmClient.reactions["/agordejo/datastorage/create"] = self.reactCreate #self.nsmClient.reactions["/agordejo/datastorage/update"] = self.reactUpdate #self.nsmClient.reactions["/agordejo/datastorage/delete"] = self.reactDelete #NsmClients only returns from init when it has a connection, and on top (for us) when session is ready. It is safe to announce now. self.nsmClient.broadcast("/agordejo/datastorage/announce", [self.nsmClient.ourClientId, HARD_LIMIT, self.nsmClient.ourOscUrl]) while True: self.nsmClient.reactToMessage() sleep(0.05) #20fps update cycle def getAll(self, msg): """A complete data dump, intended to use once after startup. Will split into multiple reply messages, if needed. Our mirror datastructure in nsmservercontrol.py calls that on init. """ senderHost, senderPort = msg.params path = "/agordejo/datastorage/reply/getall" encoded = json.dumps(self.data) chunks = chunkstring(encoded) l = len(chunks) for index, chunk in enumerate(chunks): listOfParameters = [index+0, l-1, chunk] self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) def getDescription(self, msg)->str: """Returns a normal string, not json""" senderHost, senderPort = msg.params path = "/agordejo/datastorage/reply/getdescription" chunks = chunkstring(self.data["description"]) l = len(chunks) for index, chunk in enumerate(chunks): listOfParameters = [index+0, l-1, chunk] self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) def setDescription(self, msg): """ Answers with descriptionId and index when data was received and saved. The GUI needs to buffer this a bit. Don't send every char as single message. This is for multi-part messages Index is 0 based, chunk is part of a simple string, not json. The descriptionId:int indicates the message the chunks belong to. If we see a new one we reset our storage. """ descriptionId, index, chunk, senderHost, senderPort = msg.params #str, int, str, str, int if not self._descriptionId == descriptionId: self._descriptionId = descriptionId self._descriptionStringArray.clear() self._descriptionStringArray[index] = chunk buildString = "".join([v for k,v in sorted(self._descriptionStringArray.items())]) self.data["description"] = buildString self.nsmClient.announceSaveStatus(False) def getClientOverrideName(self, msg): """Answers with empty string if clientId does not exist or has not data. This is a signal for the GUI/host to use the original name!""" clientId, senderHost, senderPort = msg.params path = "/agordejo/datastorage/reply/getclient" if clientId in self.data["clientOverrideNames"]: name = self.data["clientOverrideNames"][clientId] else: logger.info(f"We were instructed to read client {clientId}, but it does not exist") name = "" listOfParameters = [clientId, json.dumps(name)] self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) def setClientOverrideName(self, msg): """We accept empty string as a name to remove the name override. """ clientId, jsonValue = msg.params name = json.loads(jsonValue)[:HARD_LIMIT] if name: self.data["clientOverrideNames"][clientId] = name else: #It is possible that a client not present in our storage will send an empty string. Protect. if clientId in self.data["clientOverrideNames"]: del self.data["clientOverrideNames"][clientId] self.nsmClient.announceSaveStatus(False) def getTimelineMaximum(self, msg): """ In minutes If the GUI supports global jack transport controls this can be used to remember the users setting for the maximum timeline duration. JACKs own data is without an upper bound.""" senderHost, senderPort = msg.params path = "/agordejo/datastorage/reply/gettimelinemaximum" if "timelineMaximumDuration" in self.data: numericValue = self.data["timelineMaximumDuration"] else: logger.info(f"We were instructed to read the timeline maximum duration, but it does not exist yet") numericValue = 5# minutes. listOfParameters = [json.dumps(numericValue)] self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) def setTimelineMaximum(self, msg): """In minutes""" jsonValue = msg.params[0] #list of 1 numericValue = json.loads(jsonValue) if numericValue <= 1: numericValue = 1 self.data["timelineMaximumDuration"] = numericValue self.nsmClient.announceSaveStatus(False) #Generic Functions. Not in use and not ready. #Callback Reactions to OSC. They all receive _IncomingMessage(data) def reactReadAll(self, msg): senderHost, senderPort = msg.params path = "/agordejo/datastorage/reply/readall" encoded = json.dumps("") chunks = chunkstring(encoded, 512) l = len(chunks) for index, chunk in enumerate(chunks): listOfParameters = [index+0, l-1, chunk] self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) def reactRead(self, msg): key, senderHost, senderPort = msg.params if key in self.data: path = "/agordejo/datastorage/reply/read" listOfParameters = [key, json.dumps(self.data[key])] self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort) else: logger.warning(f"We were instructed to read key {key}, but it does not exist") def reactCreate(self, msg): key, jsonValue = msg.params value = json.loads(jsonValue) self.data[key] = value self.nsmClient.announceSaveStatus(False) def reactUpdate(self, msg): key, jsonValue = msg.params value = json.loads(jsonValue) if key in self.data: self.data[key] = value self.nsmClient.announceSaveStatus(False) else: logger.warning(f"We were instructed to update key {key} with value {value}, but it does not exist") def reactDelete(self, msg): key = msg.params[0] if key in self.data: del self.data[key] self.nsmClient.announceSaveStatus(False) else: logger.warning(f"We were instructed to delete key {key}, but it does not exist") #NSM Callbacks and File Handling def saveCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM): result = self.data result["origin"] = URL result["version"] = VERSION jsonData = json.dumps(result, indent=2) try: with open(self.absoluteJsonFilePath, "w", encoding="utf-8") as f: f.write(jsonData) except Exception as e: logging.error("Will not load or save because: " + e.__repr__()) return self.absoluteJsonFilePath #nsmclient.py will send save status clean def openOrNewCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM): self.absoluteJsonFilePath = pathlib.Path(ourPath) try: self.data = self.openFromJson(self.absoluteJsonFilePath) except FileNotFoundError: self.data = None #This makes debugging output nicer. If we init Data() here all errors will be presented as follow-up error "while handling exception FileNotFoundError". except (NotADirectoryError, PermissionError) as e: self.data = None logger.error("Will not load or save because: " + e.__repr__()) #Version 1.1 save file updates if self.data: if not "timelineMaximumDuration" in self.data: self.data["timelineMaximumDuration"] = 5 #5 minutes as sensible default else: self.data = {"clientOverrideNames":{}, "description":"", "timelineMaximumDuration":5} #5 minutes as sensible default logger.info("New/Open complete") #Data is not send here. Instead the gui calls the getAll message later. def openFromJson(self, absoluteJsonFilePath): with open(absoluteJsonFilePath, "r", encoding="utf-8") as f: try: text = f.read() result = json.loads(text) except Exception as error: result = None logger.error(error) if result and "version" in result and "origin" in result and result["origin"] == URL: if result["version"] <= VERSION: assert type(result) is dict, (result, type(result)) logger.info("Loading file from json complete") return result else: logger.error(f"""{absoluteJsonFilePath} was saved with {result["version"]} but we need {VERSION}""") #self.nsmClient.setLabel... We cannot use nsm client here because at this point we are still in the open/new callback. and self.nsmClient does not exist yet. sysexit() else: logger.error(f"""Error. {absoluteJsonFilePath} not loaded. Not a sane agordejo/nsm-data file in json format""") sysexit() def sessionIsLoadedCallback(self): """At one point I thought we could send our data when session is ready, so the GUI actually has clients to rename. However, that turned out impossible or impractical. Instead the GUI now just fails if nameOverrides that we send are not available yet and tries again later. Leave that in for documentation. """ pass #def broadcastCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM, messagePath, listOfArguments): # print (__file__, "broadcast") def exitCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM): sysexit(0) if __name__ == '__main__': """Creating an instance starts the client and does not return""" try: DataClient() except NSMNotRunningError: parser.print_help()