Music production session manager
https://www.laborejo.org
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.
302 lines
14 KiB
302 lines
14 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2020, 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 <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
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.0
|
|
|
|
#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/read"] = self.reactRead
|
|
#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 dumb, intended to use once after startup.
|
|
Will split into multiple reply messages, if needed"""
|
|
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)
|
|
|
|
#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__())
|
|
|
|
if not self.data:
|
|
self.data = {"clientOverrideNames":{}, "description":""}
|
|
logger.info("New/Open complete")
|
|
#TODO: send data
|
|
|
|
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}""")
|
|
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()
|
|
|