Running a very basic Story for the hugveys

This commit is contained in:
Ruben van de Ven 2019-01-22 08:59:45 +01:00
parent f5f08fc103
commit 1ea85dd490
13 changed files with 12828 additions and 164 deletions

View file

@ -17,13 +17,33 @@ from hugvey.story import Story
from hugvey.voice.google import GoogleVoiceClient from hugvey.voice.google import GoogleVoiceClient
from hugvey.voice.player import Player from hugvey.voice.player import Player
from hugvey.voice.streamer import AudioStreamer from hugvey.voice.streamer import AudioStreamer
import queue
logger = logging.getLogger("command") logger = logging.getLogger("command")
# def exceptionEmitter(a):
# print(a)
# def decorate(func):
# print('decorate')
# async def call(*args, **kwargs):
# print('call')
# # pre(func, *args, **kwargs)
# try:
# result = await func(*args, **kwargs)
# except Exception as e:
# logger.critical(e, "in", func)
# raise e
# # post(func, *args, **kwargs)
# return result
# return call
# return decorate
class CentralCommand(object): class CentralCommand(object):
"""docstring for CentralCommand.""" """docstring for CentralCommand."""
def __init__(self, debug_mode = False):
def __init__(self, debug_mode=False):
self.debug = debug_mode self.debug = debug_mode
self.eventQueue = asyncio.Queue() self.eventQueue = asyncio.Queue()
self.commandQueue = asyncio.Queue() self.commandQueue = asyncio.Queue()
@ -31,7 +51,7 @@ class CentralCommand(object):
self.hugveys = {} self.hugveys = {}
self.ctx = Context.instance() self.ctx = Context.instance()
self.hugveyLock = asyncio.Lock() self.hugveyLock = asyncio.Lock()
self.start_time = time.time()
def loadConfig(self, filename): def loadConfig(self, filename):
if hasattr(self, 'config'): if hasattr(self, 'config'):
@ -41,7 +61,7 @@ class CentralCommand(object):
logger.debug('Load config from {}'.format(filename)) logger.debug('Load config from {}'.format(filename))
self.config = yaml.safe_load(fp) self.config = yaml.safe_load(fp)
self.hugvey_ids = [i+1 for i in range(self.config['hugveys'])] self.hugvey_ids = [i + 1 for i in range(self.config['hugveys'])]
# load languages: # load languages:
self.languages = {} self.languages = {}
@ -52,6 +72,33 @@ class CentralCommand(object):
self.panopticon = Panopticon(self, self.config) self.panopticon = Panopticon(self, self.config)
def getHugveyStatus(self, hv_id):
status = {'id': hv_id}
if not hv_id in self.hugveys:
status['status'] = 'off'
return status
hv = self.hugveys[hv_id]
status['status'] = 'running' if hv.isRunning.is_set() else 'paused'
status['language'] = hv.language_code
status['msg'] = hv.story.currentMessage.id
status['counts'] = hv.story.getStoryCounts()
status['finished'] = hv.story.isFinished()
return status
def getStatusSummary(self):
status = {
'uptime': time.time() - self.start_time,
'languages': self.config['languages'],
'hugveys': [],
}
for hv_id in self.hugvey_ids:
status['hugveys'].append(self.getHugveyStatus(hv_id))
return status
def commandHugvey(self, hv_id, msg): def commandHugvey(self, hv_id, msg):
""" """
@ -59,8 +106,9 @@ class CentralCommand(object):
""" """
if threading.current_thread().getName() != 'MainThread': if threading.current_thread().getName() != 'MainThread':
# Threading nightmares! Adding to queue from other thread/loop (not sure which is the isse) # Threading nightmares! Adding to queue from other thread/loop (not sure which is the isse)
# won't trigger asyncios queue.get() so we have to do this thread safe, in the right loop # won't trigger asyncios queue.get() so we have to do this thread
self.loop.call_soon_threadsafe( self._queueCommand, hv_id, msg ) # safe, in the right loop
self.loop.call_soon_threadsafe(self._queueCommand, hv_id, msg)
else: else:
self._queueCommand(hv_id, msg) self._queueCommand(hv_id, msg)
@ -89,8 +137,10 @@ class CentralCommand(object):
# sleep to allow pending connections to connect # sleep to allow pending connections to connect
await asyncio.sleep(1) await asyncio.sleep(1)
logger.info("Ready to publish commands on: {}".format(self.config['events']['cmd_address'])) logger.info("Ready to publish commands on: {}".format(
logger.debug('Already {} items in queue'.format(self.commandQueue.qsize())) self.config['events']['cmd_address']))
logger.debug('Already {} items in queue'.format(
self.commandQueue.qsize()))
while self.isRunning.is_set(): while self.isRunning.is_set():
hv_id, cmd = await self.commandQueue.get() hv_id, cmd = await self.commandQueue.get()
@ -109,64 +159,72 @@ class CentralCommand(object):
'host': socket.gethostname(), 'host': socket.gethostname(),
'ip': self.getIp(), 'ip': self.getIp(),
''' '''
async with self.hugveyLock: # lock to prevent duplicates on creation async with self.hugveyLock: # lock to prevent duplicates on creation
if not hugvey_id in self.hugveys: if not hugvey_id in self.hugveys:
logger.info(f'Instantiate hugvey #{hugvey_id}') logger.info(f'Instantiate hugvey #{hugvey_id}')
print('a')
h = HugveyState(hugvey_id, self) h = HugveyState(hugvey_id, self)
h.config(msg['host'],msg['ip']) print('a')
h.config(msg['host'], msg['ip'])
print('b')
self.hugveys[hugvey_id] = h self.hugveys[hugvey_id] = h
thread = threading.Thread(target=h.start, name=f"hugvey#{hugvey_id}") thread = threading.Thread(
target=h.start, name=f"hugvey#{hugvey_id}")
thread.start() thread.start()
print('c')
else: else:
logger.info(f'Reconfigure hugvey #{hugvey_id}') logger.info(f'Reconfigure hugvey #{hugvey_id}')
# (re)configure exisitng hugveys # (re)configure exisitng hugveys
h.config(msg['host'],msg['ip']) h.config(msg['host'], msg['ip'])
async def eventListener(self): async def eventListener(self):
s = self.ctx.socket(zmq.SUB) s = self.ctx.socket(zmq.SUB)
s.bind(self.config['events']['listen_address']) s.bind(self.config['events']['listen_address'])
logger.info("Listen for events on: {}".format(self.config['events']['listen_address'])) logger.info("Listen for events on: {}".format(
self.config['events']['listen_address']))
for id in self.hugvey_ids: for id in self.hugvey_ids:
s.subscribe(getTopic(id)) s.subscribe(getTopic(id))
while self.isRunning.is_set(): while self.isRunning.is_set():
hugvey_id, msg = await zmqReceive(s) try:
hugvey_id, msg = await zmqReceive(s)
if hugvey_id not in self.hugvey_ids: if hugvey_id not in self.hugvey_ids:
logger.critical("Message from alien Hugvey: {}".format(hugvey_id)) logger.critical(
continue "Message from alien Hugvey: {}".format(hugvey_id))
elif hugvey_id not in self.hugveys: continue
if msg['event'] == 'connection': elif hugvey_id not in self.hugveys:
# Create a hugvey if msg['event'] == 'connection':
await self.instantiateHugvey(hugvey_id, msg) # Create a hugvey
await self.instantiateHugvey(hugvey_id, msg)
else:
logger.warning(
"Message from uninstantiated Hugvey {}".format(hugvey_id))
logger.debug("Message contains: {}".format(msg))
continue
else: else:
logger.warning("Message from uninstantiated Hugvey {}".format(hugvey_id)) await self.hugveys[hugvey_id].eventQueue.put(msg)
logger.debug("Message contains: {}".format(msg)) except Exception as e:
continue logger.critical(f"Exception while running event loop:")
else: logger.exception(e)
await self.hugveys[hugvey_id].eventQueue.put(msg)
pass
# def getPanopticon(self):
# self.panopticon =
def start(self): def start(self):
self.isRunning.set() self.isRunning.set()
self.loop = asyncio.get_event_loop() self.loop = asyncio.get_event_loop()
# self.panopticon_loop = asyncio.new_event_loop() # self.panopticon_loop = asyncio.new_event_loop()
self.tasks = {} # collect tasks so we can cancel in case of error self.tasks = {} # collect tasks so we can cancel in case of error
self.tasks['eventListener'] = self.loop.create_task(self.eventListener()) self.tasks['eventListener'] = self.loop.create_task(
self.tasks['commandSender'] = self.loop.create_task(self.commandSender()) self.eventListener())
self.tasks['commandSender'] = self.loop.create_task(
self.commandSender())
print(threading.current_thread())
# we want the web interface in a separate thread # we want the web interface in a separate thread
self.panopticon_thread = threading.Thread(target=self.panopticon.start, name="Panopticon") self.panopticon_thread = threading.Thread(
target=self.panopticon.start, name="Panopticon")
self.panopticon_thread.start() self.panopticon_thread.start()
print(threading.current_thread())
self.loop.run_forever() self.loop.run_forever()
@ -174,17 +232,19 @@ class CentralCommand(object):
self.isRunning.clear() self.isRunning.clear()
class HugveyState(object): class HugveyState(object):
"""Represents the state of a Hugvey client on the server. """Represents the state of a Hugvey client on the server.
Manages server connections & voice parsing etc. Manages server connections & voice parsing etc.
""" """
def __init__(self, id: int, command: CentralCommand): def __init__(self, id: int, command: CentralCommand):
self.id = id self.id = id
self.command = command self.command = command
self.logger = logging.getLogger(f"hugvey{self.id}") self.logger = logging.getLogger(f"hugvey{self.id}")
self.loop = asyncio.new_event_loop() self.loop = asyncio.new_event_loop()
self.isConfigured = False self.isConfigured = False
self.isRunning = threading.Event()
self.eventQueue = None self.eventQueue = None
self.language_code = 'en-GB' self.language_code = 'en-GB'
self.story = Story(self) self.story = Story(self)
@ -193,7 +253,8 @@ class HugveyState(object):
def config(self, hostname, ip): def config(self, hostname, ip):
self.ip = ip self.ip = ip
self.hostname = hostname self.hostname = hostname
self.logger.info(f"Hugvey {self.id} at {self.ip}, host: {self.hostname}") self.logger.info(
f"Hugvey {self.id} at {self.ip}, host: {self.hostname}")
if self.isConfigured == True: if self.isConfigured == True:
# a reconfiguration/reconnection # a reconfiguration/reconnection
@ -212,15 +273,13 @@ class HugveyState(object):
""" """
Start the tasks Start the tasks
""" """
# stop on isRunning.is_set() or wait()
# self.loop.create_task(self.processAudio())
tasks = asyncio.gather( tasks = asyncio.gather(
self.catchException(self.processAudio()), self.catchException(self.processAudio()),
self.catchException(self.handleEvents()), self.catchException(self.handleEvents()),
self.catchException(self.playStory()), self.catchException(self.playStory()),
loop=self.loop) loop=self.loop)
self.loop.run_until_complete(tasks) self.loop.run_until_complete(tasks)
# asyncio.run_coroutine_threadsafe(self._start(), self.loop) self.isRunning.set()
async def catchException(self, awaitable): async def catchException(self, awaitable):
try: try:
@ -243,14 +302,21 @@ class HugveyState(object):
self.story.events.append(msg) self.story.events.append(msg)
async def handleEvents(self): async def handleEvents(self):
self.eventQueue = asyncio.Queue() # start event queue here, to avoid loop issues self.eventQueue = asyncio.Queue() # start event queue here, to avoid loop issues
while self.command.isRunning.is_set(): while self.command.isRunning.is_set():
event = await self.eventQueue.get() event = await self.eventQueue.get()
self.logger.info("Received: {}".format(event)) self.logger.info("Received: {}".format(event))
if event['event'] =='language': if event['event'] == 'language':
self.setLanguage(event['code']) self.setLanguage(event['code'])
if event['event'] == 'pause':
self.pause()
if event['event'] == 'restart':
self.restart()
if event['event'] == 'resume':
self.resume()
self.eventQueue = None self.eventQueue = None
def setLanguage(self, language_code): def setLanguage(self, language_code):
@ -263,6 +329,21 @@ class HugveyState(object):
self.story.reset() self.story.reset()
self.story.setStoryData(self.command.languages[language_code]) self.story.setStoryData(self.command.languages[language_code])
def pause(self):
self.google.pause()
self.story.pause()
self.isRunning.clear()
def resume(self):
self.google.resume()
self.story.resume()
self.isRunning.set()
def restart(self):
self.story.reset()
self.resume()
self.isRunning.set()
async def playStory(self): async def playStory(self):
await self.story.start() await self.story.start()
@ -278,7 +359,8 @@ class HugveyState(object):
if self.command.debug: if self.command.debug:
self.logger.warn("Debug on: Connecting Audio player") self.logger.warn("Debug on: Connecting Audio player")
self.player = Player(self.command.config['voice']['src_rate'], self.command.config['voice']['out_rate']) self.player = Player(
self.command.config['voice']['src_rate'], self.command.config['voice']['out_rate'])
streamer.addConsumer(self.player) streamer.addConsumer(self.player)
self.logger.info("Start Speech") self.logger.info("Start Speech")
@ -287,7 +369,7 @@ class HugveyState(object):
src_rate=self.command.config['voice']['src_rate'], src_rate=self.command.config['voice']['src_rate'],
credential_file=self.command.config['voice']['google_credentials'], credential_file=self.command.config['voice']['google_credentials'],
language_code=self.language_code language_code=self.language_code
) )
streamer.addConsumer(self.google) streamer.addConsumer(self.google)
await streamer.run() await streamer.run()

View file

@ -11,28 +11,73 @@ import tornado.ioloop
import os import os
from pytz.reference import Central from pytz.reference import Central
import asyncio import asyncio
import json
logger = logging.getLogger("panopticon") logger = logging.getLogger("panopticon")
web_dir = os.path.join(os.path.split(__file__)[0], '..','www') web_dir = os.path.join(os.path.split(__file__)[0], '..', 'www')
print(web_dir) print(web_dir)
class WebSocketHandler(tornado.websocket.WebSocketHandler):
connections = set()
# the client connected def getWebSocketHandler(central_command):
def open(self): class WebSocketHandler(tornado.websocket.WebSocketHandler):
self.connections.add(self) connections = set()
print("New client connected")
# the client sent the message # the client connected
def on_message(self, message): def open(self):
[con.write_message(message) for con in self.connections] self.connections.add(self)
logger.info("New client connected")
# client disconnected # the client sent the message
def on_close(self): def on_message(self, message):
self.connections.remove(self) try:
print("Client disconnected") msg = json.loads(message)
if msg['action'] == 'init':
self.msgInit()
if msg['action'] == 'get_status':
self.msgStatus()
if msg['action'] == 'resume':
self.msgResume(msg['hugvey'])
if msg['action'] == 'pause':
self.msgPause(msg['hugvey'])
if msg['action'] == 'restart':
self.msgRestart(msg['hugvey'])
except Exception as e:
self.send({'alert': 'Invalid request: {}'.format(e)})
def send(self, message):
j = json.dumps(message)
[con.write_message(j) for con in self.connections]
# client disconnected
def on_close(self):
self.connections.remove(self)
logger.info("Client disconnected")
def getStatusMsg(self):
msg = central_command.getStatusSummary()
msg['action'] = 'status'
return msg
def msgStatus(self):
self.send(self.getStatusMsg())
def msgInit(self):
msg = self.getStatusMsg()
self.send(msg)
def msgResume(self, hv_id):
central_command.hugveys[hv_id].eventQueue.put({'event': 'resume'})
def msgPause(self, hv_id):
central_command.hugveys[hv_id].eventQueue.put({'event': 'pause'})
def msgRestart(self, hv_id):
central_command.hugveys[hv_id].eventQueue.put({'event': 'restart'})
return WebSocketHandler
class Panopticon(object): class Panopticon(object):
@ -40,12 +85,13 @@ class Panopticon(object):
self.command = central_command self.command = central_command
self.config = config self.config = config
self.application = tornado.web.Application([ self.application = tornado.web.Application([
(r"/ws", WebSocketHandler), (r"/ws", getWebSocketHandler(self.command)),
(r"/uploads/(.*)", tornado.web.StaticFileHandler, {"path": config['web']['files_dir']}), (r"/uploads/(.*)", tornado.web.StaticFileHandler,
(r"/(.*)", tornado.web.StaticFileHandler, {"path": web_dir, "default_filename": 'index.html'}), {"path": config['web']['files_dir']}),
(r"/(.*)", tornado.web.StaticFileHandler,
{"path": web_dir, "default_filename": 'index.html'}),
], debug=True) ], debug=True)
self.application.listen(config['web']['port']) self.application.listen(config['web']['port'])
# self.loop.configure(evt_loop) # self.loop.configure(evt_loop)

View file

@ -7,6 +7,7 @@ import asyncio
logger = logging.getLogger("narrative") logger = logging.getLogger("narrative")
class Message(object): class Message(object):
def __init__(self, id, text): def __init__(self, id, text):
self.id = id self.id = id
@ -16,9 +17,9 @@ class Message(object):
@classmethod @classmethod
def initFromJson(message, data, story): def initFromJson(message, data, story):
msg = message(data['@id'], data['text']) msg = message(data['@id'], data['text'])
msg.isStart = data['start'] if 'start' in data else False msg.isStart = data['start'] if 'start' in data else False
return msg; return msg
def setReply(self, text): def setReply(self, text):
self.reply = text self.reply = text
@ -28,7 +29,8 @@ class Message(object):
def getReply(self): def getReply(self):
if self.reply is None: if self.reply is None:
raise Exception("Getting reply while there is none! {0}".format(self.id)) raise Exception(
"Getting reply while there is none! {0}".format(self.id))
return self.reply return self.reply
@ -38,6 +40,7 @@ class Condition(object):
A condition, basic conditions are built in, custom condition can be given by A condition, basic conditions are built in, custom condition can be given by
providing a custom method. providing a custom method.
""" """
def __init__(self, id): def __init__(self, id):
self.id = id self.id = id
self.method = None self.method = None
@ -45,7 +48,7 @@ class Condition(object):
@classmethod @classmethod
def initFromJson(conditionClass, data, story): def initFromJson(conditionClass, data, story):
condition = conditionClass(data['@id']) condition = conditionClass(data['@id'])
# TODO: should Condition be subclassed? # TODO: should Condition be subclassed?
if data['type'] == "replyContains": if data['type'] == "replyContains":
condition.method = condition._hasMetReplyContains condition.method = condition._hasMetReplyContains
@ -55,7 +58,7 @@ class Condition(object):
if 'vars' in data: if 'vars' in data:
condition.vars = data['vars'] condition.vars = data['vars']
return condition; return condition
def isMet(self, story): def isMet(self, story):
""" """
@ -98,6 +101,7 @@ class Direction(object):
""" """
A condition based edge in the story graph A condition based edge in the story graph
""" """
def __init__(self, id, msgFrom: Message, msgTo: Message): def __init__(self, id, msgFrom: Message, msgTo: Message):
self.id = id self.id = id
self.msgFrom = msgFrom self.msgFrom = msgFrom
@ -111,18 +115,19 @@ class Direction(object):
def initFromJson(direction, data, story): def initFromJson(direction, data, story):
msgFrom = story.get(data['source']) msgFrom = story.get(data['source'])
msgTo = story.get(data['target']) msgTo = story.get(data['target'])
direction = direction(data['@id'], msgFrom, msgTo) direction = direction(data['@id'], msgFrom, msgTo)
if 'conditions' in data: if 'conditions' in data:
for conditionId in data['conditions']: for conditionId in data['conditions']:
c = story.get(conditionId) c = story.get(conditionId)
direction.addCondition(c) direction.addCondition(c)
return direction; return direction
class Interruption(object): class Interruption(object):
""" """
An Interruption. Used to catch events outside of story flow. An Interruption. Used to catch events outside of story flow.
""" """
def __init__(self, id): def __init__(self, id):
self.id = id self.id = id
self.conditions = [] self.conditions = []
@ -132,12 +137,13 @@ class Interruption(object):
@classmethod @classmethod
def initFromJson(interruptionClass, data, story): def initFromJson(interruptionClass, data, story):
interrupt = interruptionClass(data['@id']) interrupt = interruptionClass(data['@id'])
if 'conditions' in data: if 'conditions' in data:
for conditionId in data['conditions']: for conditionId in data['conditions']:
c = story.get(conditionId) c = story.get(conditionId)
interrupt.addCondition(c) interrupt.addCondition(c)
return interrupt; return interrupt
storyClasses = { storyClasses = {
'Msg': Message, 'Msg': Message,
@ -146,18 +152,87 @@ storyClasses = {
'Interruption': Interruption, 'Interruption': Interruption,
} }
class Stopwatch(object):
"""
Keep track of elapsed time. Use multiple markers, but a single pause/resume button
"""
def __init__(self):
self.isRunning = asyncio.Event()
self.reset()
def getElapsed(self, since_mark='start'):
t = time.time()
if self.paused_at != 0:
pause_duration = t - self.paused_at
else:
pause_duration = 0
return t - self.marks[since_mark] - pause_duration
def pause(self):
self.paused_at = time.time()
self.isRunning.clear()
def resume(self):
if self.paused_at == 0:
return
pause_duration = time.time() - self.paused_at
for m in self.marks:
self.marks[m] += pause_duration
self.paused_at = 0
self.isRunning.set()
def reset(self):
self.marks = {}
self.setMark('start')
self.paused_at = 0
self.isRunning.set()
def setMark(self, name):
self.marks[name] = time.time()
def clearMark(self, name):
if name in self.marks:
self.marks.pop(name)
class Story(object): class Story(object):
"""Story represents and manages a story/narrative flow""" """Story represents and manages a story/narrative flow"""
#TODO should we separate 'narrative' (the graph) from the story (the current user flow) # TODO should we separate 'narrative' (the graph) from the story (the
# current user flow)
def __init__(self, hugvey_state): def __init__(self, hugvey_state):
super(Story, self).__init__() super(Story, self).__init__()
self.hugvey = hugvey_state self.hugvey = hugvey_state
self.events = [] # queue of received events self.events = [] # queue of received events
self.commands = [] # queue of commands to send self.commands = [] # queue of commands to send
self.log = [] # all nodes/elements that are triggered self.log = [] # all nodes/elements that are triggered
self.currentMessage = None self.currentMessage = None
self.timer = Stopwatch()
def pause(self):
logger.debug('pause hugvey')
self.timer.pause()
def resume(self):
logger.debug('resume hugvey')
self.timer.resume()
def getStoryCounts(self):
# counts = {}
# for item in self.log:
# n =item.__class__.__name__
# if n not in counts:
# counts[n] = 0
# counts[n] += 1
# return counts
return {
'messages': len([e for e in self.log if isinstance(e, Message)]),
'interruptions': len([e for e in self.log if isinstance(e, Interruption)])
}
def setStoryData(self, story_data): def setStoryData(self, story_data):
""" """
@ -171,7 +246,7 @@ class Story(object):
self.elements = {} self.elements = {}
self.interruptions = [] self.interruptions = []
self.directionsPerMsg = {} self.directionsPerMsg = {}
self.startMessage = None # The entrypoint to the graph self.startMessage = None # The entrypoint to the graph
self.reset() self.reset()
for el in self.data: for el in self.data:
@ -185,17 +260,26 @@ class Story(object):
if currentId: if currentId:
self.currentMessage = self.get(currentId) self.currentMessage = self.get(currentId)
if self.currentMessage: if self.currentMessage:
logger.info(f"Reinstantiated current message: {self.currentMessage.id}") logger.info(
f"Reinstantiated current message: {self.currentMessage.id}")
else: else:
logger.warn("Could not reinstatiate current message. Starting over") logger.warn(
"Could not reinstatiate current message. Starting over")
def reset(self): def reset(self):
self.startTime = time.time() self.timer.reset()
self.currentMessage = None # currently active message, determines active listeners etc. # self.startTime = time.time()
# currently active message, determines active listeners etc.
self.currentMessage = None
self.lastMsgTime = None self.lastMsgTime = None
self.lastSpeechStartTime = None self.lastSpeechStartTime = None
self.lastSpeechEndTime = None self.lastSpeechEndTime = None
self.variables = {} # captured variables from replies self.variables = {} # captured variables from replies
self.finish_time = False
self.events = [] # queue of received events
self.commands = [] # queue of commands to send
self.log = [] # all nodes/elements that are triggered
def add(self, obj): def add(self, obj):
if obj.id in self.elements: if obj.id in self.elements:
@ -223,25 +307,24 @@ class Story(object):
return self.elements[id] return self.elements[id]
return None return None
def stop(self): def stop(self):
logger.info("Stop Story") logger.info("Stop Story")
if self.isRunning: if self.isRunning:
self.isRunning = False self.isRunning = False
def _processPendingEvents(self): def _processPendingEvents(self):
# Gather events: # Gather events:
nr = len(self.events) nr = len(self.events)
for i in range(nr): for i in range(nr):
e = self.events.pop(0) e = self.events.pop(0)
logger.info("handle '{}'".format( e )) logger.info("handle '{}'".format(e))
if e['event'] == "exit": if e['event'] == "exit":
self.stop() self.stop()
if e['event'] == 'connect': if e['event'] == 'connect':
# a client connected. Shold only happen in the beginning or in case of error # a client connected. Shold only happen in the beginning or in case of error
# that is, until we have a 'reset' or 'start' event. # that is, until we have a 'reset' or 'start' event.
self.setCurrentMessage(self.currentMessage) # reinitiate current message # reinitiate current message
self.setCurrentMessage(self.currentMessage)
if e['event'] == "playbackFinish": if e['event'] == "playbackFinish":
if e['msgId'] == self.currentMessage.id: if e['msgId'] == self.currentMessage.id:
@ -254,6 +337,7 @@ class Story(object):
if e['event'] == 'speech': if e['event'] == 'speech':
# log if somebody starts speaking # log if somebody starts speaking
# TODO: use pausing timer
if self.lastSpeechStartTime is None or self.lastSpeechStartTime < self.lastMsgTime: if self.lastSpeechStartTime is None or self.lastSpeechStartTime < self.lastMsgTime:
self.lastSpeechStartTime = e['time'] self.lastSpeechStartTime = e['time']
@ -266,7 +350,8 @@ class Story(object):
for direction in directions: for direction in directions:
for condition in direction.conditions: for condition in direction.conditions:
if condition.isMet(self): if condition.isMet(self):
logger.info("Condition is met: {0}, going to {1}".format(condition.id, direction.msgTo.id)) logger.info("Condition is met: {0}, going to {1}".format(
condition.id, direction.msgTo.id))
self.log.append(condition) self.log.append(condition)
self.log.append(direction) self.log.append(direction)
self.setCurrentMessage(direction.msgTo) self.setCurrentMessage(direction.msgTo)
@ -276,20 +361,21 @@ class Story(object):
""" """
every 1/10 sec. determine what needs to be done based on the current story state every 1/10 sec. determine what needs to be done based on the current story state
""" """
loopDuration = 0.1 # Configure fps loopDuration = 0.1 # Configure fps
lastTime = time.time() lastTime = time.time()
logger.info("Start renderer") logger.info("Start renderer")
while self.isRunning: while self.isRunning:
if self.isRunning is False: if self.isRunning is False:
break break
# pause on timer paused
await self.timer.isRunning.wait() # wait for un-pause
for i in range(len(self.events)): for i in range(len(self.events)):
self._processPendingEvents() self._processPendingEvents()
if self.currentMessage.id not in self.directionsPerMsg: if self.currentMessage.id not in self.directionsPerMsg:
# TODO: finish! self.finish()
pass
directions = self.getCurrentDirections() directions = self.getCurrentDirections()
self._processDirections(directions) self._processDirections(directions)
@ -307,9 +393,11 @@ class Story(object):
def setCurrentMessage(self, message): def setCurrentMessage(self, message):
self.currentMessage = message self.currentMessage = message
self.lastMsgTime = time.time() self.lastMsgTime = time.time()
self.lastMsgFinishTime = None # to be filled in by the event self.lastMsgFinishTime = None # to be filled in by the event
logger.info("Current message: ({0}) \"{1}\"".format(message.id, message.text)) logger.info("Current message: ({0}) \"{1}\"".format(
message.id, message.text))
self.log.append(message)
# TODO: prep events & timer etc. # TODO: prep events & timer etc.
self.hugvey.sendCommand({ self.hugvey.sendCommand({
'action': 'play', 'action': 'play',
@ -321,8 +409,8 @@ class Story(object):
for direction in self.getCurrentDirections(): for direction in self.getCurrentDirections():
conditions = [c.id for c in direction.conditions] conditions = [c.id for c in direction.conditions]
logger.debug("- {0} -> {1} (when: {2}) ".format(direction.msgFrom.id, direction.msgTo.id, conditions)) logger.debug(
"- {0} -> {1} (when: {2}) ".format(direction.msgFrom.id, direction.msgTo.id, conditions))
def getCurrentDirections(self): def getCurrentDirections(self):
if self.currentMessage.id not in self.directionsPerMsg: if self.currentMessage.id not in self.directionsPerMsg:
@ -332,10 +420,21 @@ class Story(object):
async def start(self): async def start(self):
logger.info("Starting story") logger.info("Starting story")
self.startTime = time.time() self.timer.reset()
# self.startTime = time.time()
self.isRunning = True self.isRunning = True
self.setCurrentMessage(self.startMessage) self.setCurrentMessage(self.startMessage)
await self._renderer() await self._renderer()
def isFinished(self):
if hasattr(self, 'finish_time'):
return self.finish_time
return False
def finish(self):
logger.info(f"Finished story for {self.hugvey.id}")
self.hugvey.pause()
self.finish_time = time.time()
self.timer.pause()

View file

@ -32,17 +32,26 @@ class GoogleVoiceClient(object):
# Create a thread-safe buffer of audio data # Create a thread-safe buffer of audio data
self.buffer = queue.Queue() self.buffer = queue.Queue()
self.isRunning = False self.isRunning = threading.Event()
self.toBeShutdown = False
self.target_rate = 16000 self.target_rate = 16000
self.cv_laststate = None self.cv_laststate = None
self.restart = False self.restart = False
self.task = threading.Thread(target=self.run, name=f"hugvey#{self.hugvey.id}v") self.task = threading.Thread(target=self.run, name=f"hugvey#{self.hugvey.id}v")
self.task.setDaemon(True) self.task.setDaemon(True)
self.task.start() self.task.start()
def pause(self):
self.isRunning.clear()
self.restart = True
def resume(self):
self.isRunning.set()
def generator(self): def generator(self):
while self.isRunning: while not self.toBeShutdown:
yield self.buffer.get() yield self.buffer.get()
def setLanguage(self, language_code): def setLanguage(self, language_code):
@ -54,11 +63,13 @@ class GoogleVoiceClient(object):
self.restart = True self.restart = True
def run(self): def run(self):
self.isRunning = True self.isRunning.set()
while self.isRunning: while not self.toBeShutdown:
try: try:
self.isRunning.wait()
self.speech_client = speech.SpeechClient() self.speech_client = speech.SpeechClient()
config = types.RecognitionConfig( config = types.RecognitionConfig(
encoding=enums.RecognitionConfig.AudioEncoding.LINEAR16, encoding=enums.RecognitionConfig.AudioEncoding.LINEAR16,
@ -122,7 +133,7 @@ class GoogleVoiceClient(object):
self.restart = False self.restart = False
raise RequireRestart("Restart required") raise RequireRestart("Restart required")
if not self.isRunning: if self.toBeShutdown:
logger.warn("Stopping voice loop") logger.warn("Stopping voice loop")
break break
except RequireRestart as e: except RequireRestart as e:
@ -142,7 +153,7 @@ class GoogleVoiceClient(object):
self.buffer.put_nowait(data) self.buffer.put_nowait(data)
def shutdown(self): def shutdown(self):
self.isRunning = False self.toBeShutdown = True

3
local/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
*
!.gitignore

View file

@ -7,7 +7,7 @@ voice:
port: 4444 port: 4444
chunk: 2972 chunk: 2972
google_credentials: "/home/ruben/Documents/Projecten/2018/Hugvey/test_googlespeech/My First Project-0c7833e0d5fa.json" google_credentials: "/home/ruben/Documents/Projecten/2018/Hugvey/test_googlespeech/My First Project-0c7833e0d5fa.json"
hugveys: 3 hugveys: 25
languages: languages:
- code: en-GB - code: en-GB
file: story_en.json file: story_en.json

100
www/hugvey_console.js Normal file
View file

@ -0,0 +1,100 @@
var panopticon;
class Panopticon {
constructor() {
console.log( "Init panopticon" );
this.hugveys = new Vue( {
el: "#status",
data: {
uptime: 0,
languages: [],
hugveys: []
},
methods: {
time_passed: function (hugvey, property) {
console.log("property!", Date(hugvey[property] * 1000));
return moment(Date(hugvey[property] * 1000)).fromNow();
}
}
} );
this.socket = new ReconnectingWebSocket( "ws://localhost:8888/ws", null, { debug: true, reconnectInterval: 3000 } );
this.socket.addEventListener( 'open', ( e ) => {
this.send( { action: 'init' } );
} );
this.socket.addEventListener( 'close', function( e ) {
console.log( 'Closed connection' );
} );
this.socket.addEventListener( 'message', ( e ) => {
let msg = JSON.parse( e.data );
if ( typeof msg['alert'] !== 'undefined' ) {
alert(msg['alert']);
}
if ( typeof msg['action'] === 'undefined' ) {
console.error( "not a valid message: " + e.data );
return;
}
switch ( msg['action'] ) {
case 'status':
this.hugveys.uptime = this.stringToHHMMSS(msg['uptime']);
this.hugveys.languages = msg['languages'];
this.hugveys.hugveys = msg['hugveys'];
break;
}
} );
}
send( msg ) {
this.socket.send( JSON.stringify( msg ) );
}
getStatus() {
// console.log('get status', this, panopticon);
panopticon.send( { action: 'get_status' } );
}
init() {
setInterval( this.getStatus, 3000 );
}
stringToHHMMSS (string) {
var sec_num = parseInt(string, 10); // don't forget the second param
var hours = Math.floor(sec_num / 3600);
var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
var seconds = sec_num - (hours * 3600) - (minutes * 60);
if (hours < 10) {hours = "0"+hours;}
if (minutes < 10) {minutes = "0"+minutes;}
if (seconds < 10) {seconds = "0"+seconds;}
return hours+':'+minutes+':'+seconds;
}
loadNarrative(code, file) {
}
resume(hv_id) {
this.send({ action: 'resume', hugvey: hv_id })
}
pause(hv_id) {
this.send({ action: 'play', hugvey: hv_id })
}
restart(hv_id) {
this.send({ action: 'restart', hugvey: hv_id })
}
}
window.addEventListener( 'load', function() {
panopticon = new Panopticon();
panopticon.init();
})

View file

@ -1,17 +1,43 @@
<html> <html>
<head> <head>
<title>Pillow Talk Control Interface</title> <title>Pillow Talk Control Interface</title>
<!-- development version, includes helpful console warnings --> <!-- development version, includes helpful console warnings -->
<script src="/vue.js"></script> <script src="/vue.js"></script>
<script src="/reconnecting-websocket.js"></script>
<script src="/moment.min.js"></script>
<link rel="stylesheet" href="styles.css"></link>
</head> </head>
<body> <body>
<div id='status'> <div id='status'>
</div> <div id='overview'>
<div id='story'> <dl>
</div> <dt>Uptime</dt>
<div id='hugvey'> <dd>{{uptime}}</dd>
</div> <dt>Languages</dt>
<div id='app'>{{message}}</div> <dd v-for="lang in languages" :title="lang.file"
<script src="/hugvey_console.js"></script> @click="panopticon.loadNarrative(lang.code, lang.file)">{{lang.code}}</dd>
</dl>
</div>
<div class='hugvey' v-for="hv in hugveys"
:class="[{'hugvey--off': hv.status == 'off'},{'hugvey--on': hv.status != 'off'},{'hugvey--paused': hv.status == 'paused'},{'hugvey--running': hv.status == 'running'}]">
<h1>
{{ hv.id }}
<!-- / {{ hv.status }} -->
</h1>
<div v-if="hv.status != 'off'">
{{ hv.language }} / {{ hv.msg }}
<div v-if="hv.finished != false">
Finished: {{time_passed(hv, 'finished')}}
</div>
<div v-for="c, key in hv.counts"><dt>{{key}}</dt><dd>{{c}}</dd></div>
<div v-if="hv.status != 'running'" @click="panopticon.pause(hv.id)">Pause</div>
<div v-if="hv.status != 'paused'" @click="panopticon.resume(hv.id)">Resume</div>
</div>
</div>
</div>
<div id='story'></div>
<div id='hugvey'></div>
<script type='application/javascript' src="/hugvey_console.js"></script>
</body> </body>
</html> </html>

1
www/moment.min.js vendored Normal file

File diff suppressed because one or more lines are too long

832
www/narrative_builder.html Normal file
View file

@ -0,0 +1,832 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Pillow Talk - Narrative Builder</title>
<script src="https://d3js.org/d3.v5.min.js"></script>
<script type="text/javascript">
// https://raw.githubusercontent.com/KoryNunn/crel/master/crel.min.js
!function(n,e){"object"==typeof exports?module.exports=e():"function"==typeof define&&define.amd?define(e):n.crel=e()}(this,function(){function n(a){var d,s=arguments,p=s[1],y=2,m=s.length,x=n[o];if(a=n[u](a)?a:c.createElement(a),m>1){if((!e(p,r)||n[f](p)||Array.isArray(p))&&(--y,p=null),m-y==1&&e(s[y],"string"))a.textContent=s[y];else for(;y<m;++y)null!==(d=s[y])&&l(a,d);for(var v in p)if(x[v]){var A=x[v];e(A,t)?A(a,p[v]):a[i](A,p[v])}else e(p[v],t)?a[v]=p[v]:a[i](v,p[v])}return a}var e=function(n,e){return typeof n===e},t="function",r="object",i="setAttribute",o="attrMap",f="isNode",u="isElement",c=document,a=function(n){return n instanceof Node},d=function(n){return n instanceof Element},l=function(e,t){if(Array.isArray(t))return void t.map(function(n){l(e,n)});n[f](t)||(t=c.createTextNode(t)),e.appendChild(t)};return n[o]={},n[u]=d,n[f]=a,e(Proxy,"undefined")||(n.proxy=new Proxy(n,{get:function(e,t){return!(t in n)&&(n[t]=n.bind(null,t)),n[t]}})),n});
crel.attrMap['on'] = function(element, value) {
for (var eventName in value) {
element.addEventListener(eventName, value[eventName]);
}
};
</script>
<style media="screen">
body{
margin:0;
overflow: hidden;
font-family: "Noto Sans", sans-serif;
}
svg{
width:100vw;
height: 100vh;
cursor: grab;
}
svg:active{
cursor: grabbing;
}
circle{
cursor: pointer;
fill: rgb(119, 97, 142);
}
.startMsg circle{
fill: lightseagreen;
}
.endMsg circle{
fill: lightslategray;
}
.orphanedMsg{
fill: lightcoral;
}
text{
text-anchor: middle;
font-size: 11pt;
font-family: sans-serif;
fill: white;
}
line{
marker-end: url('#arrowHead');
stroke-width: 2px;
stroke: black;
}
line.link--noconditions{
stroke-dasharray: 5 4;
stroke: red;
}
label::after {
content: '';
clear: both;
display: block;
}
label{
width:100%;
font-weight:bold;
display: block;
margin: 0 -10px;
padding: 5px 10px;
}
label input,label select{
float: right;
}
label:nth-child(odd){
background-color: rgba(255,255,255,0.3);
}
#msg{
position: absolute;
top:0;
right:0;
width: 30%;
max-height:100%;
overflow-y: auto;
}
#msg .msg__info, #msg .directions > div{
padding: 10px;
margin-bottom: 10px;
background:lightgray;
}
#opener{
display: flex;
width: 100%;
height: 100vh;
justify-content: center;
align-items: center;
flex-direction: column;
}
#nodes g:hover circle,
.selectedMsg circle {
stroke: lightgreen;
stroke-width: 8;
}
.controlDown #nodes g:hover circle,
.secondaryMsg circle {
stroke: lightgreen;
stroke-width: 5;
stroke-dasharray: 10 3;
}
.condition h4{
text-align: center;
}
.condition + .condition::before {
content: "OR";
display: block;
border-bottom: solid 2px;
height: 10px;
margin-bottom: 15px;
text-align: center;
text-shadow: 2px 2px 2px lightgray,-2px 2px 2px lightgray,2px -2px 2px lightgray,-2px -2px 2px lightgray;
}
.condition--add{
/* text-align: center; */
}
.btn{
padding: 5px;
background:lightgray;
border-radius: 5px;
display: inline-block;
cursor: pointer;
}
.btn:hover{
background: lightblue;
}
</style>
</head>
<body>
<div id="opener">
<h1>Hugvey</h1>
<h3>Select a narrative json file</h3>
<input id='fileOpener' type="file" />
<div>
<input id='fileLoad' type="submit" value="Load file" />
<input id='stateLoad' type="button" value="Load last state" />
</div>
</div>
<div id="msg"></div>
<div id="controls">
<div id='btn-save' class='btn'>Save JSON</div>
<div id='btn-addMsg' class='btn'>New Message</div>
</div>
<svg id='graph' viewbox="0 0 1280 1024" preserveAspectRatio="xMidYMid">
<defs>
<marker markerHeight="8" markerWidth="8" refY="0" refX="12" viewBox="0 -6 16 12" preserveAspectRatio="none" orient="auto" id="arrowHead"><path d="M0,-6L16,0L0,6" fill="black"></path></marker>
</defs>
<g id='container'>
</g>
</svg>
</body>
<script type="text/javascript">
class Graph{
constructor() {
this.width = 1280;
this.height = 1024;
this.nodeSize = 80;
this.maxChars = 16;
this.svg = d3.select('#graph');
this.container = d3.select('#container');
this.selectedMsg = null;
this.messages = []; // initialise empty array. For the simulation, make sure we keep the same array object
this.directions = []; // initialise empty array. For the simulation, make sure we keep the same array object
this.conditions = []; // initialise empty array. For the simulation, make sure we keep the same array object
this.interruptions = []; // initialise empty array. For the simulation, make sure we keep the same array object
let graph = this;
this.controlDown = false;
document.addEventListener('keydown', function(e){
console.log(e);
if(e.which == "17") {
graph.controlDown = true;
document.body.classList.add('controlDown');
}
});
document.addEventListener('keyup', function(e){
console.log(e);
if(e.which == "17") {
graph.controlDown = false;
document.body.classList.remove('controlDown');
}
});
let c = this.container;
let zoomed = function(){
c.attr("transform", d3.event.transform);
}
this.svg.call(d3.zoom()
.scaleExtent([1 / 2, 8])
.on("zoom", zoomed));
this.nodesG = this.container.append("g")
.attr("id", "nodes")
this.linkG = this.container.append("g")
.attr("id", "links");
document.getElementById('btn-save').addEventListener('click', function(e){ graph.saveJson(); });
document.getElementById('btn-addMsg').addEventListener('click', function(e){ graph.createMsg(); });
}
clickMsg(msg) {
// event when a message is clicked.
console.log(msg);
if(this.controlDown) {
this.secondarySelectMsg(msg);
} else {
this.selectMsg(msg);
}
}
secondarySelectMsg(msg) {
if(this.selectedMsg !== null) {
this.addDirection(this.selectedMsg, msg);
} else {
console.error('No message selected as Source');
}
}
selectMsg(msg) {
let selectedEls = document.getElementsByClassName('selectedMsg');
while(selectedEls.length > 0){
selectedEls[0].classList.remove('selectedMsg');
}
document.getElementById(msg['@id']).classList.add('selectedMsg');
this.selectedMsg = msg;
this.showMsg(msg);
}
updateMsg() {
// used eg. after a condition creation.
this.showMsg(this.selectedMsg);
}
showMsg(msg) {
let msgEl = document.getElementById('msg');
msgEl.innerHTML = "";
let startAttributes = {
'name': msg['@id'] + '-start',
'disabled': true,
'type': 'checkbox',
'on': {
'change': this.getEditEventListener()
}
}
if(msg['start'] == true) {
startAttributes['checked'] = 'checked';
}
let msgInfoEl = crel('div', {'class': 'msg__info'},
crel('h1', {'class':'msg__id'}, msg['@id']),
crel('label',
crel('span', 'Text'),
crel('input', {
'name': msg['@id'] + '-text',
'value': msg['text'],
'on': {
'change': this.getEditEventListener()
}
} )
),
crel('label',
crel('span', 'Start'),
crel('input', startAttributes)
)
);
msgEl.appendChild(msgInfoEl);
// let directionHEl = document.createElement('h2');
// directionHEl.innerHTML = "Directions";
let fromDirections =[] , toDirections = [];
for(let direction of this.getDirectionsTo(msg)) {
toDirections.push(this.getDirectionEl(direction, msg));
}
for(let direction of this.getDirectionsFrom(msg)) {
fromDirections.push(this.getDirectionEl(direction, msg));
}
let directionsEl = crel('div', {'class': 'directions'},
crel('h2', 'Directions'),
...toDirections, ...fromDirections
);
msgEl.appendChild(directionsEl);
}
getDirectionEl(direction, msg) {
let directionEl = document.createElement('div');
if(direction['source'] == msg) {
directionEl.innerHTML = `<h3>To ${direction['target']['@id']}</h3>`;
} else {
directionEl.innerHTML = `<h3>From ${direction['source']['@id']}</h3>`;
}
let del = document.createElement('div');
del.innerHTML = "delete";
del.classList.add("deleteBtn");
let g = this;
del.addEventListener('click', (e) => g.rmDirection(direction));
directionEl.appendChild(del);
// TODO; conditions
for(let conditionId of direction['conditions']) {
let condition = this.getNodeById(conditionId);
directionEl.appendChild(this.getEditConditionFormEl(condition, direction));
}
directionEl.appendChild(this.getAddConditionFormEl(direction));
return directionEl;
}
getEditConditionFormEl(condition, direction) {
let conditionEl = crel('div', {'class': 'condition condition--edit'},
crel('h4', {'title': condition['@id']}, condition['type'])
)
let labelLabel = document.createElement('label');
labelLabel.innerHTML = "Description";
let labelInput = crel('input',{
'name': `${condition['@id']}-label`,
'value': typeof condition['label'] == 'undefined' ? "" : condition['label'],
'on': {
'change': this.getEditEventListener()
}
});
labelLabel.appendChild(labelInput);
conditionEl.appendChild(labelLabel);
for(let v in condition['vars']) {
let varLabel = document.createElement('label');
varLabel.innerHTML = v;
let varInput = document.createElement('input');
if(v == 'seconds') {
varInput.type = 'number';
}
varInput.name = `${condition['@id']}-vars.${v}`;
varInput.value = condition['vars'][v];
varInput.addEventListener('change', this.getEditEventListener());
varLabel.appendChild(varInput);
conditionEl.appendChild(varLabel);
}
return conditionEl;
}
getConditionTypes() {
if(typeof this.conditionTypes === 'undefined') {
// type: vars: attribtes for crel()
this.conditionTypes = {
'timeout': {
'seconds': {'type': 'number', 'value': 10, 'min':0, 'step': 0.1}
},
'replyContains': {
'regex': {'value': '.+'}
}
}
}
return this.conditionTypes;
}
fillConditionFormForType(conditionForm, type) {
conditionForm.innerHTML = "";
let vars = this.getConditionTypes()[type];
for(let v in vars){
let attr = vars[v];
attr['name'] = v;
conditionForm.appendChild(
crel('label',
crel('span', v),
crel('input', attr)
)
);
}
}
getAddConditionFormEl(direction) {
let optionEls = [];
let types = this.getConditionTypes();
for(let type in types) {
optionEls.push(crel('option', type));
}
let conditionForm = crel('div', {'class': 'condition--vars'});
let g = this;
let addConditionEl = crel('div', {'class': 'condition condition--add'},
crel('form', {
'on': {
'submit': function(e) {
e.preventDefault();
let form = new FormData(e.target);
console.log('submit', form);
let type = form.get('type');
form.delete('type');
let label = form.get('label');
form.delete('label');
let vars = {};
for(var pair of form.entries()) {
vars[pair[0]] = pair[1];
}
g.addConditionForDirection(type, label, vars, direction);
}
}
},
crel("h4", "Create New Condition"),
crel("label",
crel('span', "Type"),
crel('select', {
'name': 'type',
'on': {
'change': function(e){
g.fillConditionFormForType(conditionForm, e.target.value);
}
}}, optionEls),
),
crel("label",
crel('span', "Description"),
crel('input', {'name': 'label'})
),
conditionForm,
crel('input', {
'type':'submit',
'value':'create'
})
)
);
this.fillConditionFormForType(conditionForm, optionEls[0].value);
return addConditionEl;
}
rmConditionFromDirection(condition, direction) {
let id = condition['@id'];
// TODO
if(typeof direction != 'undefined') {
}
this._rmNode(id);
}
getConditionEl(condition) {
let conditionEl = document.createElement('div');
return conditionEl;
}
getDirectionsFrom(msg) {
return this.directions.filter(d => d['source'] == msg);
}
getDirectionsTo(msg) {
return this.directions.filter(d => d['target'] == msg);
}
addMsg() {
let msg = {
"@id": "n" + Date.now().toString(36),
"@type": "Msg",
"text": "New",
"start": false
}
this.data.push(msg);
this.updateFromData();
this.build();
return msg;
}
rmMsg(msg) {
let invalidatedDirections = this.directions.filter(d => d['source'] == msg || d['target'] == msg);
console.log('invalidated', invalidatedDirections);
for(let dir of invalidatedDirections) {
let i = this.data.indexOf(dir);
this.data.splice(i, 1);
}
this._rmNode(msg);
}
_rmNode(node) {
// remove msg/direction/condition/etc
let i = this.data.indexOf(node);
this.data.splice(i, 1);
this.updateFromData();
this.build();
return this.data;
}
addConditionForDirection(type, label, vars, direction) {
let con = this.addCondition(type, label, vars, true);
direction['conditions'].push(con['@id']);
this.updateFromData();
this.build();
this.updateMsg();
}
addCondition(type, label, vars, skip) {
let con = {
"@id": "c" + Date.now().toString(36),
"@type": "Condition",
"type": type,
"label": label,
"vars": vars
}
this.data.push(con);
if(skip !== true) {
this.updateFromData();
this.build();
}
return con;
}
addDirection(source, target) {
let dir = {
"@id": "d" + Date.now().toString(36),
"@type": "Direction",
"source": source,
"target": target,
"conditions": []
}
this.data.push(dir);
this.updateFromData();
this.build();
return dir;
}
rmDirection(dir) {
this._rmNode(dir);
}
createMsg() {
this.addMsg();
this.build();
}
getNodeById(id) {
return this.data.filter(node => node['@id'] == id)[0];
}
/**
* Use wrapper method, because for event handlers 'this' will refer to
* the input object
*/
getEditEventListener(){
let graph = this;
let el = function(e){
let parts = e.srcElement.name.split('-');
let id = parts[0], field = parts[1];
console.log(this, graph);
let node = graph.getNodeById(id);
let path = field.split('.'); // use vars.test to set ['vars']['test'] = value
var res=node;
for (var i=0;i<path.length;i++){
if(i == (path.length -1)) {
console.log('last', path[i]);
res[path[i]] = e.srcElement.value;
} else {
res=res[path[i]];
}
}
// node[field] = e.srcElement.value;
graph.build();
}
return el;
}
getJsonString() {
// recreate array to have the right order of items.
this.data = [...this.messages, ...this.conditions,
...this.directions, ...this.interruptions]
let d = [];
let toRemove = ['sourceX', 'sourceY', 'targetX', 'targetY', 'x','y', 'vx','vy']
for(let node of this.data) {
let n = {};
console.log(node['source']);
for (let e in node) {
if (node.hasOwnProperty(e) && toRemove.indexOf(e) == -1 ) {
if(this.data.indexOf(node[e]) != -1) {
n[e] = node[e]['@id'];
} else {
n[e] = node[e];
}
}
}
d.push(n);
}
return JSON.stringify(d);
}
saveJson() {
var blob = new Blob([this.getJsonString()], {type: 'application/json'});
if(window.navigator.msSaveOrOpenBlob) {
window.navigator.msSaveBlob(blob, "pillow_talk.json");
}
else{
var elem = window.document.createElement('a');
elem.href = window.URL.createObjectURL(blob);
elem.download = "pillow_talk.json";
document.body.appendChild(elem);
elem.click();
document.body.removeChild(elem);
}
}
loadData(data) {
this.data = data;
this.updateFromData();
this.build(true);
}
updateFromData() {
this.messages = this.data.filter((node) => node['@type'] == 'Msg');
this.directions = this.data.filter((node) => node['@type'] == 'Direction');
this.conditions = this.data.filter((node) => node['@type'] == 'Condition');
this.interruptions = this.data.filter((node) => node['@type'] == 'Interruption');
// save state;
this.saveState();
}
saveState() {
window.localStorage.setItem("lastState", this.getJsonString());
}
hasSavedState() {
return window.localStorage.getItem("lastState") !== null;
}
loadFromState() {
this.loadData(JSON.parse(window.localStorage.getItem("lastState")));
}
build(isInit) {
this.simulation = d3.forceSimulation(this.messages)
.force("link", d3.forceLink(this.directions).id(d => d['@id']))
.force("charge", d3.forceManyBody().strength(-1000))
.force("center", d3.forceCenter(this.width / 2, this.height / 2))
.force("collide", d3.forceCollide(this.nodeSize*2))
;
// Update existing nodes
let node = this.nodesG
.selectAll("g")
.data(this.messages, n => n['@id'])
;
// Update existing nodes
let newNode = node.enter();
let newNodeG = newNode.append("g")
.attr('id', d => d['@id'])
.call(d3.drag(this.simulation))
.on('click', function(d){
this.clickMsg(d);
}.bind(this))
;
console.log('a');
let circle = newNodeG.append("circle")
.attr('r', this.nodeSize)
// .text(d => d.id)
;
let text = newNodeG.append("text")
;
// remove
node.exit().remove();
node = node.merge(newNodeG);
// for all existing nodes:
node.attr('class', msg => {
let classes = [];
if( this.selectedMsg == msg) classes.push('selectedMsg');
if( msg['start'] == true ) classes.push('startMsg');
if(this.getDirectionsFrom(msg).length < 1) {
classes.push('endMsg');
if(this.getDirectionsTo(msg).length < 1) classes.push('orphanedMsg');
}
return classes.join(' ');
})
let link = this.linkG
.selectAll("line")
.data(this.directions)
;
let newLink = link.enter()
.append("line")
;
//remove
link.exit().remove();
link = link.merge(newLink);
link.attr('class', l => { return `link ` + (l['conditions'].length == 0 ? "link--noconditions" : "link--withconditions"); });
// console.log('c');
let formatText = (t) => {
if(t.length > this.maxChars) {
return t.substr(0, this.maxChars - 3) + '...';
} else {
return t;
}
};
node.selectAll("text").text(d => formatText(`(${d['@id']}) ${d['text']}`));
// console.log('q');
// // TODO: update text
// let text = newNodeG.append("text")
// // .attr('stroke', "black")
// .text(d => formatText(`(${d['@id']}) ${d['text']}`))
// // .attr('title', d => d.label)
// ;
let n = this.nodesG;
this.simulation.on("tick", () => {
link
.each(function(d){
let sourceX, targetX, midX, dx, dy, angle;
// This mess makes the arrows exactly perfect.
// thanks to http://bl.ocks.org/curran/9b73eb564c1c8a3d8f3ab207de364bf4
if( d.source.x < d.target.x ){
sourceX = d.source.x;
targetX = d.target.x;
} else if( d.target.x < d.source.x ){
targetX = d.target.x;
sourceX = d.source.x;
} else if (d.target.isCircle) {
targetX = sourceX = d.target.x;
} else if (d.source.isCircle) {
targetX = sourceX = d.source.x;
} else {
midX = (d.source.x + d.target.x) / 2;
if(midX > d.target.x){
midX = d.target.x;
} else if(midX > d.source.x){
midX = d.source.x;
} else if(midX < d.target.x){
midX = d.target.x;
} else if(midX < d.source.x){
midX = d.source.x;
}
targetX = sourceX = midX;
}
dx = targetX - sourceX;
dy = d.target.y - d.source.y;
angle = Math.atan2(dx, dy);
// Compute the line endpoint such that the arrow
// is touching the edge of the node rectangle perfectly.
d.sourceX = sourceX + Math.sin(angle) * this.nodeSize;
d.targetX = targetX - Math.sin(angle) * this.nodeSize;
d.sourceY = d.source.y + Math.cos(angle) * this.nodeSize;
d.targetY = d.target.y - Math.cos(angle) * this.nodeSize;
}.bind(this))
.attr("x1", function(d) { return d.sourceX; })
.attr("y1", function(d) { return d.sourceY; })
.attr("x2", function(d) { return d.targetX; })
.attr("y2", function(d) { return d.targetY; });
node.attr("transform", d => `translate(${d.x},${d.y})`);
// .attr("cy", d => d.y);
});
// this.simulation.alpha(1);
// this.simulation.restart();
if(typeof isInit != 'undefined' && isInit) {
for (let i = 0, n = Math.ceil(Math.log(this.simulation.alphaMin()) / Math.log(1 - this.simulation.alphaDecay())); i < n; ++i) {
this.simulation.tick();
}
}
return this.svg.node();
}
}
var graph = new Graph();
var openerDiv = document.getElementById("opener");
var fileOpenerBtn = document.getElementById('fileOpener');
var openerSubmit = document.getElementById('fileLoad');
var loadFileFromInput = function (inputEl) {
if(inputEl.files && inputEl.files[0]){
var reader = new FileReader();
reader.onload = function (e) {
var output=e.target.result;
let j = JSON.parse(output);
graph.loadData(j);
openerDiv.parentElement.removeChild(openerDiv);
};//end onload()
reader.readAsText(inputEl.files[0]);
}
}
fileOpenerBtn.addEventListener('change', function(){ loadFileFromInput(this); })
if(fileOpenerBtn.files.length) {
openerSubmit.addEventListener('click', function() { loadFileFromInput(fileOpener); });
} else {
openerSubmit.parentElement.removeChild(openerSubmit);
}
let loadStateBtn = document.getElementById('stateLoad');
if(graph.hasSavedState()) {
loadStateBtn.addEventListener('click', function() {
graph.loadFromState();
openerDiv.parentElement.removeChild(openerDiv);
});
} else {
loadStateBtn.parentElement.removeChild(loadStateBtn);
}
</script>
</html>

View file

@ -0,0 +1,365 @@
// MIT License:
//
// Copyright (c) 2010-2012, Joe Walnes
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
/**
* This behaves like a WebSocket in every way, except if it fails to connect,
* or it gets disconnected, it will repeatedly poll until it successfully connects
* again.
*
* It is API compatible, so when you have:
* ws = new WebSocket('ws://....');
* you can replace with:
* ws = new ReconnectingWebSocket('ws://....');
*
* The event stream will typically look like:
* onconnecting
* onopen
* onmessage
* onmessage
* onclose // lost connection
* onconnecting
* onopen // sometime later...
* onmessage
* onmessage
* etc...
*
* It is API compatible with the standard WebSocket API, apart from the following members:
*
* - `bufferedAmount`
* - `extensions`
* - `binaryType`
*
* Latest version: https://github.com/joewalnes/reconnecting-websocket/
* - Joe Walnes
*
* Syntax
* ======
* var socket = new ReconnectingWebSocket(url, protocols, options);
*
* Parameters
* ==========
* url - The url you are connecting to.
* protocols - Optional string or array of protocols.
* options - See below
*
* Options
* =======
* Options can either be passed upon instantiation or set after instantiation:
*
* var socket = new ReconnectingWebSocket(url, null, { debug: true, reconnectInterval: 4000 });
*
* or
*
* var socket = new ReconnectingWebSocket(url);
* socket.debug = true;
* socket.reconnectInterval = 4000;
*
* debug
* - Whether this instance should log debug messages. Accepts true or false. Default: false.
*
* automaticOpen
* - Whether or not the websocket should attempt to connect immediately upon instantiation. The socket can be manually opened or closed at any time using ws.open() and ws.close().
*
* reconnectInterval
* - The number of milliseconds to delay before attempting to reconnect. Accepts integer. Default: 1000.
*
* maxReconnectInterval
* - The maximum number of milliseconds to delay a reconnection attempt. Accepts integer. Default: 30000.
*
* reconnectDecay
* - The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. Accepts integer or float. Default: 1.5.
*
* timeoutInterval
* - The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. Accepts integer. Default: 2000.
*
*/
(function (global, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory);
} else if (typeof module !== 'undefined' && module.exports){
module.exports = factory();
} else {
global.ReconnectingWebSocket = factory();
}
})(this, function () {
if (!('WebSocket' in window)) {
return;
}
function ReconnectingWebSocket(url, protocols, options) {
// Default settings
var settings = {
/** Whether this instance should log debug messages. */
debug: false,
/** Whether or not the websocket should attempt to connect immediately upon instantiation. */
automaticOpen: true,
/** The number of milliseconds to delay before attempting to reconnect. */
reconnectInterval: 1000,
/** The maximum number of milliseconds to delay a reconnection attempt. */
maxReconnectInterval: 30000,
/** The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. */
reconnectDecay: 1.5,
/** The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. */
timeoutInterval: 2000,
/** The maximum number of reconnection attempts to make. Unlimited if null. */
maxReconnectAttempts: null,
/** The binary type, possible values 'blob' or 'arraybuffer', default 'blob'. */
binaryType: 'blob'
}
if (!options) { options = {}; }
// Overwrite and define settings with options if they exist.
for (var key in settings) {
if (typeof options[key] !== 'undefined') {
this[key] = options[key];
} else {
this[key] = settings[key];
}
}
// These should be treated as read-only properties
/** The URL as resolved by the constructor. This is always an absolute URL. Read only. */
this.url = url;
/** The number of attempted reconnects since starting, or the last successful connection. Read only. */
this.reconnectAttempts = 0;
/**
* The current state of the connection.
* Can be one of: WebSocket.CONNECTING, WebSocket.OPEN, WebSocket.CLOSING, WebSocket.CLOSED
* Read only.
*/
this.readyState = WebSocket.CONNECTING;
/**
* A string indicating the name of the sub-protocol the server selected; this will be one of
* the strings specified in the protocols parameter when creating the WebSocket object.
* Read only.
*/
this.protocol = null;
// Private state variables
var self = this;
var ws;
var forcedClose = false;
var timedOut = false;
var eventTarget = document.createElement('div');
// Wire up "on*" properties as event handlers
eventTarget.addEventListener('open', function(event) { self.onopen(event); });
eventTarget.addEventListener('close', function(event) { self.onclose(event); });
eventTarget.addEventListener('connecting', function(event) { self.onconnecting(event); });
eventTarget.addEventListener('message', function(event) { self.onmessage(event); });
eventTarget.addEventListener('error', function(event) { self.onerror(event); });
// Expose the API required by EventTarget
this.addEventListener = eventTarget.addEventListener.bind(eventTarget);
this.removeEventListener = eventTarget.removeEventListener.bind(eventTarget);
this.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget);
/**
* This function generates an event that is compatible with standard
* compliant browsers and IE9 - IE11
*
* This will prevent the error:
* Object doesn't support this action
*
* http://stackoverflow.com/questions/19345392/why-arent-my-parameters-getting-passed-through-to-a-dispatched-event/19345563#19345563
* @param s String The name that the event should use
* @param args Object an optional object that the event will use
*/
function generateEvent(s, args) {
var evt = document.createEvent("CustomEvent");
evt.initCustomEvent(s, false, false, args);
return evt;
};
this.open = function (reconnectAttempt) {
ws = new WebSocket(self.url, protocols || []);
ws.binaryType = this.binaryType;
if (reconnectAttempt) {
if (this.maxReconnectAttempts && this.reconnectAttempts > this.maxReconnectAttempts) {
return;
}
} else {
eventTarget.dispatchEvent(generateEvent('connecting'));
this.reconnectAttempts = 0;
}
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'attempt-connect', self.url);
}
var localWs = ws;
var timeout = setTimeout(function() {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'connection-timeout', self.url);
}
timedOut = true;
localWs.close();
timedOut = false;
}, self.timeoutInterval);
ws.onopen = function(event) {
clearTimeout(timeout);
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'onopen', self.url);
}
self.protocol = ws.protocol;
self.readyState = WebSocket.OPEN;
self.reconnectAttempts = 0;
var e = generateEvent('open');
e.isReconnect = reconnectAttempt;
reconnectAttempt = false;
eventTarget.dispatchEvent(e);
};
ws.onclose = function(event) {
clearTimeout(timeout);
ws = null;
if (forcedClose) {
self.readyState = WebSocket.CLOSED;
eventTarget.dispatchEvent(generateEvent('close'));
} else {
self.readyState = WebSocket.CONNECTING;
var e = generateEvent('connecting');
e.code = event.code;
e.reason = event.reason;
e.wasClean = event.wasClean;
eventTarget.dispatchEvent(e);
if (!reconnectAttempt && !timedOut) {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'onclose', self.url);
}
eventTarget.dispatchEvent(generateEvent('close'));
}
var timeout = self.reconnectInterval * Math.pow(self.reconnectDecay, self.reconnectAttempts);
setTimeout(function() {
self.reconnectAttempts++;
self.open(true);
}, timeout > self.maxReconnectInterval ? self.maxReconnectInterval : timeout);
}
};
ws.onmessage = function(event) {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'onmessage', self.url, event.data);
}
var e = generateEvent('message');
e.data = event.data;
eventTarget.dispatchEvent(e);
};
ws.onerror = function(event) {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'onerror', self.url, event);
}
eventTarget.dispatchEvent(generateEvent('error'));
};
}
// Whether or not to create a websocket upon instantiation
if (this.automaticOpen == true) {
this.open(false);
}
/**
* Transmits data to the server over the WebSocket connection.
*
* @param data a text string, ArrayBuffer or Blob to send to the server.
*/
this.send = function(data) {
if (ws) {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'send', self.url, data);
}
return ws.send(data);
} else {
throw 'INVALID_STATE_ERR : Pausing to reconnect websocket';
}
};
/**
* Closes the WebSocket connection or connection attempt, if any.
* If the connection is already CLOSED, this method does nothing.
*/
this.close = function(code, reason) {
// Default CLOSE_NORMAL code
if (typeof code == 'undefined') {
code = 1000;
}
forcedClose = true;
if (ws) {
ws.close(code, reason);
}
};
/**
* Additional public API method to refresh the connection if still open (close, re-open).
* For example, if the app suspects bad data / missed heart beats, it can try to refresh.
*/
this.refresh = function() {
if (ws) {
ws.close();
}
};
}
/**
* An event listener to be called when the WebSocket connection's readyState changes to OPEN;
* this indicates that the connection is ready to send and receive data.
*/
ReconnectingWebSocket.prototype.onopen = function(event) {};
/** An event listener to be called when the WebSocket connection's readyState changes to CLOSED. */
ReconnectingWebSocket.prototype.onclose = function(event) {};
/** An event listener to be called when a connection begins being attempted. */
ReconnectingWebSocket.prototype.onconnecting = function(event) {};
/** An event listener to be called when a message is received from the server. */
ReconnectingWebSocket.prototype.onmessage = function(event) {};
/** An event listener to be called when an error occurs. */
ReconnectingWebSocket.prototype.onerror = function(event) {};
/**
* Whether all instances of ReconnectingWebSocket should log debug messages.
* Setting this to true is the equivalent of setting all instances of ReconnectingWebSocket.debug to true.
*/
ReconnectingWebSocket.debugAll = false;
ReconnectingWebSocket.CONNECTING = WebSocket.CONNECTING;
ReconnectingWebSocket.OPEN = WebSocket.OPEN;
ReconnectingWebSocket.CLOSING = WebSocket.CLOSING;
ReconnectingWebSocket.CLOSED = WebSocket.CLOSED;
return ReconnectingWebSocket;
});

44
www/styles.css Normal file
View file

@ -0,0 +1,44 @@
body{
font-family: sans-serif;
}
#status{
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 430px;
height: 100%;
overflow-y: scroll;
}
#status > div{
width: 33.3333333%;
height: 150px;
border: solid 1px;
box-sizing: border-box;
position: relative;
}
.hugvey{
background-image: linear-gradient(to top, #587457, #35a589);
color:white;
display: flex;
flex-direction: column;
justify-content: center;
}
.hugvey.hugvey--off{
background-image: linear-gradient(to top, #575d74, #3572a5);
}
.hugvey h1{
text-align: center;
margin: 0;
}
.hugvey.hugvey--off h1::after{
content: '[off]'
}
.hugvey.hugvey--on h1 {
position: absolute;
left: 5px;
top: 5px;
}

11055
www/vue.js Normal file

File diff suppressed because it is too large Load diff