""" "Conscript reporting" This server controls all hugveys and the processing of their narratives. It exposes itself for control to the panopticon server. """ import os import time import yaml import zmq from zmq.asyncio import Context import asyncio from hugvey.communication import getTopic, zmqSend, zmqReceive, LOG_BS from hugvey.panopticon import Panopticon from hugvey.story import Story, Stopwatch from hugvey.speech.google import GoogleVoiceClient from hugvey.speech.player import Player from hugvey.speech.streamer import AudioStreamer import json import logging import queue import threading from hugvey.voice import VoiceStorage import multiprocessing from hugvey.speech.recorder import Recorder from pythonosc import udp_client, osc_server, dispatcher import copy from pythonosc.osc_server import AsyncIOOSCUDPServer from hugvey.variablestore import VariableStore import datetime import telegram_handler mainLogger = logging.getLogger("hugvey") logger = mainLogger.getChild("command") eventLogger = logging.getLogger("events") eventLogger.setLevel(logging.DEBUG) # 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): """docstring for CentralCommand.""" def __init__(self, args = {}, debug_mode=False): self.debug = debug_mode self.eventQueue = asyncio.Queue() self.commandQueue = asyncio.Queue() self.lightQueue = asyncio.Queue() self.isRunning = threading.Event() self.logQueue = multiprocessing.Queue() self.hugveys = {} self.ctx = Context.instance() self.hugveyLock = asyncio.Lock() self.start_time = time.time() self.languageFiles = {} self.languageConfig = {} self.args = args # cli args self.timer = Stopwatch() self.hugveyWarnings = {} self.lightMapFile = os.path.join('state','lightMap.json') self.lightMap = {} eventLogger.addHandler(logging.handlers.QueueHandler(self.logQueue)) def loadConfig(self, filename): if hasattr(self, 'config'): raise Exception("Overriding config not supported yet") with open(filename, 'r') as fp: logger.debug('Load config from {}'.format(filename)) self.config = yaml.safe_load(fp) for arg in vars(self.args): if arg in self.config: logger.debug("Override argument {}".format(arg)) self.config[arg] = getattr(self.args,arg) self.hugvey_ids = [i + 1 for i in range(self.config['hugveys'])] self.loadLightMap() self.loadLanguages() voice_dir = os.path.join(self.config['web']['files_dir'], 'voices') self.voiceStorage = VoiceStorage(voice_dir, self.languageConfig) varDb = os.path.join( self.config['voice']['record_dir'], 'hugvey_variable_store.db' ) self.variableStore = VariableStore(varDb) self.panopticon = Panopticon(self, self.config, self.voiceStorage) # extra handlers so we get some info when AFK: if 'telegram' in self.config and self.config['telegram']['token']: fmt = '%(message)s\n%(name)s:%(funcName)s (%(filename)s:%(lineno)d)' formatter = telegram_handler.HtmlFormatter(fmt=fmt, use_emoji = False) for chat_id in self.config['telegram']['chat_ids']: handler = telegram_handler.TelegramHandler( token=self.config['telegram']['token'], level=logging.CRITICAL, chat_id=chat_id ) handler.setFormatter(formatter) logging.getLogger('hugvey').addHandler(handler) logger.critical("Start server with Telegram integration") # telegram_handler.formatter.use_emoji = True def loadLanguages(self): logger.debug('load language files') self.languages = {} self.languageCache = {} for lang in self.config['languages']: lang_filename = os.path.join(self.config['web']['files_dir'], lang['file']) self.languageFiles[lang['code']] = lang['file'] self.languageConfig[lang['code']] = lang with open(lang_filename, 'r') as fp: self.languages[lang['code']] = json.load(fp) self.future_language = list(self.languages.keys())[0] def loadLightMap(self): if os.path.exists(self.lightMapFile): with open(self.lightMapFile) as fp: lightMap = json.load(fp) #json only has string keys, we want integers (not using pickle for human readability) self.lightMap = {int(k): v for k,v in lightMap.items()} logger.info(f"Loaded light mapping from {self.lightMapFile}") for hv_id in self.hugvey_ids: if hv_id not in self.lightMap: print(hv_id, self.lightMap) raise Exception("Invalid light map, not all hugveys are included. Remove lightMap.json") else: # by default each hv, has the same nr of light self.lightMap = {id: id for id in self.hugvey_ids} def saveLightMap(self): with open(self.lightMapFile, 'w') as fp: json.dump(self.lightMap, fp, indent=4, sort_keys=True) logger.info(f"Wrote light mapping to {self.lightMapFile}") def getHugveyStatus(self, hv_id, isSelected = False): status = {'id': hv_id} if not hv_id in self.hugveys: status['status'] = 'off' return status hv = self.hugveys[hv_id] # if not hv.story: # status['status'] = 'off' # return status #: :type hv: HugveyState status['status'] = hv.getStatus() status['light'] = hv.lightTransitionStatus status['language'] = hv.language_code status['light_id'] = hv.lightId status['available'] = hv.isAvailable() status['msg'] = hv.story.currentMessage.id if hv.story and hv.story.currentMessage else None # status['finished'] = hv.story.isFinished() status['history'] = {} if isSelected is False or not hv.story else hv.story.getLogSummary() # status['history'] = hv.story.getLogSummary() # disabled as it is a bit slow. We now have eventLog # status['counts'] = {t: len(a) for t, a in status['history'].items() if t != 'directions' } status['counts'] = {} if not hv.story else hv.story.getLogCounts() status['duration'] = None if not hv.story else (hv.story.timer.getElapsed('first_speech') if hv.story.timer.hasMark('first_speech') else None) status['has_state'] = Story.hugveyHasSavedState(hv.lightId) status['variables'] = {} if not isSelected or not hv.story else hv.story.variableValues # evLogger = eventLogger.getChild(f"{hv_id}") status['time_since_hugvey_spoke_state'] = "" status['time_since_visitor_spoke_state'] = "" hugveyCriticalDiff = self.config['story']['hugvey_critical_silence'] if 'hugvey_critical_silence' in self.config['story'] else 90 audienceCriticalDiff = self.config['story']['audience_critical_silence'] if 'audience_critical_silence' in self.config['story'] else 15*60 if not hv.story: status['time_since_hugvey_spoke'] = '-' status['time_since_visitor_spoke'] = '-' else: if not hasattr(hv.story, 'lastMsgStartTime') or not hv.story.lastMsgStartTime: status['time_since_hugvey_spoke'] = '?' elif not hasattr(hv.story, 'lastMsgFinishTime') or not hv.story.lastMsgFinishTime: status['time_since_hugvey_spoke'] = 'speaking' else: diff = datetime.timedelta(seconds=int(hv.story.timer.getElapsed() - hv.story.lastMsgFinishTime)) status['time_since_hugvey_spoke'] = str(diff) diffs = diff.total_seconds() if diffs > hugveyCriticalDiff: status['time_since_hugvey_spoke_state'] = 'critical' elif diffs > hugveyCriticalDiff/1.5: status['time_since_hugvey_spoke_state'] = 'warning' if not hv.story.timer.hasMark('last_speech'): status['time_since_visitor_spoke'] = 'never' else: diff = datetime.timedelta(seconds=int(hv.story.timer.getElapsed('last_speech'))) status['time_since_visitor_spoke'] = str(diff) diffs = diff.total_seconds() if diffs > audienceCriticalDiff: # evLogger.warning("Very long time since audience spoke") status['time_since_visitor_spoke_state'] = 'critical' elif diffs > audienceCriticalDiff/2: status['time_since_visitor_spoke_state'] = 'warning' # else: # #clear warning # pass return status def setLightForHugvey(self, hv_id, lightId): if hv_id not in self.lightMap: logger.critical(f"Try to configure light for non-existing Hugvey {hv_id}") return logger.info(f"Set light for hugvey: {hv_id} to {lightId}") self.lightMap[hv_id] = lightId self.hugveys[hv_id].setLightId(lightId) self.saveLightMap() def getStatusSummary(self, selected_ids = []): status = { 'uptime': "-" if not self.start_time else (time.time() - self.start_time), 'loop_timer': self.timer.getElapsed(), 'languages': self.config['languages'], 'hugvey_ids': self.hugvey_ids, 'hugveys': [], 'logbookId': None, 'logbook': [], 'starts_since_lang_change': self.getHugveyStartsSinceLanguageChange(), 'future_language': self.future_language, } #use this to test if any threads stay open # eg. after killing/dying of a hugvey # print(threading.enumerate()) for hv_id in self.hugvey_ids: status['hugveys'].append(self.getHugveyStatus(hv_id, hv_id in selected_ids)) status['hugveys'].sort(key=lambda hv: hv['light_id'] if 'light_id' in hv else hv['id']) # if selected_id and selected_id in self.hugveys: # if self.hugveys[selected_id].recorder: # status['logbook'] = self.hugveys[selected_id].recorder.currentLog # status['logbookId'] = selected_id return status def getHugveyStartsSinceLanguageChange(self): ''' Some info on the nr. of hugveys since last change of language (using the change-all button in panopticon) ''' changeTime = self.timer.marks['languageChange'] if self.timer.hasMark('languageChange') else self.timer.marks['start'] nrOfStarts = 0 for hv in self.hugveys.values(): if hv.story: if hv.story.timer.hasMark('first_speech'): if hv.story.timer.marks['first_speech'] > changeTime: nrOfStarts += 1 return nrOfStarts def setFutureLanguage(self, lang_code): """ Set language for all future hugvey runs (so after a 'finish') """ self.timer.setMark('languageChange') self.future_language = lang_code for hv_id in self.hugveys: if self.hugveys[hv_id].eventQueue: self.hugveys[hv_id].eventQueue.put_nowait({'event': 'change_language_if_available', 'lang_code': lang_code}) def setLoopTime(self, secondsAgo: int): self.timer.setMark('start', time.time() - secondsAgo) def commandHugvey(self, hv_id, msg): """ prepare command to be picked up by the sender """ logging.debug(f"COmmand {hv_id}: {msg}") if threading.current_thread().getName() != 'MainThread': # 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 self.loop.call_soon_threadsafe(self._queueCommand, hv_id, msg) else: self._queueCommand(hv_id, msg) def _queueCommand(self, hv_id, msg): self.commandQueue.put_nowait((hv_id, msg)) def commandLight(self, route, data): """ Buffer light commands """ logging.debug(f"Light: {route} {data}") if threading.current_thread().getName() != 'MainThread': # 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 self.loop.call_soon_threadsafe(self._queueLightCommand, route, data) else: self._queueLightCommand(route, data) def _queueLightCommand(self, route, data): self.lightQueue.put_nowait((route, data)) def commandAllHugveys(self, msg): for hv_id in self.hugvey_ids: self.commandHugvey(hv_id, msg) def commandAllActiveHugveys(self, msg): for hv_id in self.hugveys: self.commandHugvey(hv_id, msg) async def commandSender(self): s = self.ctx.socket(zmq.PUB) s.bind(self.config['events']['cmd_address']) self.commandAllHugveys({'action': 'show_yourself'}) # sleep to allow pending connections to connect await asyncio.sleep(1) logger.info("Ready to publish commands on: {}".format( self.config['events']['cmd_address'])) logger.debug('Already {} items in queue'.format( self.commandQueue.qsize())) while self.isRunning.is_set(): hv_id, cmd = await self.commandQueue.get() logger.debug('Got command to send: {} {}'.format(hv_id, cmd)) zmqSend(s, hv_id, cmd) logger.warn('Stopping command sender') s.close() async def lightSender(self): lightConn = udp_client.SimpleUDPClient( self.config['light']['ip'], self.config['light']['port']) logger.info(f"Ready to send light commands to: {self.config['light']['ip']}:{self.config['light']['port']}") while self.isRunning.is_set(): route, data = await self.lightQueue.get() logger.debug('Got light to send: {} {}'.format(route, data)) lightConn.send_message(route, data) await asyncio.sleep(.06) logger.warn('Stopping light sender') lightConn._sock.close() def restartTimerHandler(self, address, *args): """ See self.oscListener """ logger.warn(f"Restart loop timer") self.timer.reset() if len(args) > 0 and float(args[0]) > 0: print(args, args[0]) logger.warn(f"Set timer to custom time: {float(args[0])} seconds ago") self.timer.setMark('start', time.time() - float(args[0])) async def oscListener(self): """ OSC server, listens for loop restarts """ dispatch = dispatcher.Dispatcher() dispatch.map("/loop", self.restartTimerHandler) server = osc_server.AsyncIOOSCUDPServer( ("0.0.0.0", 9000), dispatch, asyncio.get_event_loop() ) logger.info('Start OSC server to receive loop re-starts') # await server.serve() transport, protocol = await server.create_serve_endpoint() # logger.critical(f"{transport}, {protocol}") # transport.close() async def redLightController(self): """ Every second, check if no hugveys are available. If so, the red light should be overruled to be on. If any is available, send a 0 to release the override. """ currentCode = None while self.isRunning.is_set(): # statusses = [hv.getStatus() for hv in self.hugveys.values()] # lightOn = HugveyState.STATE_AVAILABLE not in statusses statusses = [hv.isAvailable() for hv in self.hugveys.values()] lightOn = True not in statusses lightCode = 1 if lightOn else 0 if lightCode != currentCode: self.commandLight('/red', [lightCode]) currentCode = lightCode await asyncio.sleep(1) logger.warn('Stopping red light controller') def instantiateHugvey(self, hugvey_id): ''' Start a HugveyState, according to a show_yourself reply 'event': 'connection', 'id': self.hugvey_id, 'host': socket.gethostname(), 'ip': self.getIp(), ''' # async with self.hugveyLock: # lock to prevent duplicates on creation if not hugvey_id in self.hugveys: thread = threading.Thread( target=self.hugveyStateRunner, args=(hugvey_id,), name=f"hugvey#{hugvey_id}") thread.start() def hugveyStateRunner(self, hugvey_id): while self.isRunning.is_set(): logger.info(f'Instantiate hugvey #{hugvey_id}') h = HugveyState(hugvey_id, self) h.setLightId(self.lightMap[hugvey_id]) # h.config(msg['host'], msg['ip']) self.hugveys[hugvey_id] = h r = h.run() self.hugveys.pop(hugvey_id) if not r: # stop if False, ie. when stream has gone return #TODO: hugveyid in log and prevent duplicate messages logger.critical(f'Hugvey {hugvey_id} stopped (crashed?). Reinstantiate after 5 sec') time.sleep(5) async def timerEmitter(self): """ This is fixed: a one hour loop with a collective moment 10-15 minutes, 30-35 minutes and 50-55 minutes """ loop_duration = 60 * 60 # one hour loop intervals = [ { 'start_time': 10*60, 'duration': 5 * 60, }, { 'start_time': 30*60, 'duration': 5 * 60, }, { 'start_time': 50*60, 'duration': 5 * 60, } ] self.start_time = time.time() # TODO: emit start event while self.isRunning.is_set(): pass async def eventListener(self): s = self.ctx.socket(zmq.SUB) s.bind(self.config['events']['listen_address']) logger.debug("Listen for events on: {}".format( self.config['events']['listen_address'])) for id in self.hugvey_ids: s.subscribe(getTopic(id)) while self.isRunning.is_set(): try: hugvey_id, msg = await zmqReceive(s) if hugvey_id not in self.hugvey_ids: logger.critical( "Message from alien Hugvey: {}".format(hugvey_id)) continue elif hugvey_id not in self.hugveys: # if msg['event'] == 'connection': # # Create a hugvey # self.instantiateHugvey(hugvey_id, msg) # else: logger.warning( "Message from uninstantiated Hugvey {}".format(hugvey_id)) logger.debug("Message contains: {}".format(msg)) continue else: self.hugveys[hugvey_id].queueEvent(msg) # await self.hugveys[hugvey_id].eventQueue.put(msg) except Exception as e: logger.critical(f"Exception while running event loop:") logger.exception(e) async def voiceListener(self, hugvey_id): s = self.ctx.socket(zmq.REP) #: :type s: zmq.sugar.Socket voiceAddr = f"ipc://voice{hugvey_id}" s.bind(voiceAddr) logger.debug("Listen for voice requests on: {}".format(voiceAddr)) while self.isRunning.is_set(): try: r = await s.recv_json() isVariable = bool(r['variable']) text = r['text'] hv = self.hugveys[hugvey_id] #: :type hv: HugveyState fn = await self.voiceStorage.requestFile(hv.language_code, text, isVariable) if fn is None: eventLogger.getChild(f"{hugvey_id}").critical("error: No voice file fetched, check logs.") fn = 'local/crash.wav' # TODO: trigger a repeat/crash event. await s.send_string(fn) except Exception as e: logger.critical(f"Exception while running voice loop:") logger.exception(e) def start(self): self.isRunning.set() self.loop = asyncio.get_event_loop() # self.panopticon_loop = asyncio.new_event_loop() self.tasks = {} # collect tasks so we can cancel in case of error self.tasks['eventListener'] = self.loop.create_task( self.catchException(self.eventListener())) self.tasks['commandSender'] = self.loop.create_task( self.catchException(self.commandSender())) self.tasks['lightSender'] = self.loop.create_task( self.catchException(self.lightSender())) self.tasks['oscListener'] = self.loop.create_task( self.catchException(self.oscListener())) self.tasks['redLightController'] = self.loop.create_task( self.catchException(self.redLightController())) self.tasks['variableStore'] = self.loop.create_task( self.catchException(self.variableStore.queueProcessor())) for hid in self.hugvey_ids: self.tasks['voiceListener'] = self.loop.create_task( self.catchException(self.voiceListener(hid))) self.instantiateHugvey(hid) # we want the web interface in a separate thread self.panopticon_thread = threading.Thread( target=self.panopticon.start, name="Panopticon") self.panopticon_thread.start() self.loop.run_forever() def stop(self): self.isRunning.clear() async def catchException(self, awaitable): try: # print(awaitable) await awaitable except Exception as e: logger.exception(e) logger.critical(f"Hugvey restart might be required but not implemented yet") class HugveyState(object): """Represents the state of a Hugvey client on the server. Manages server connections & voice parsing etc. """ # all statusses can only go up or down, except for gone, which is an error state: # off <-> blocked <-> available <-> running <-> paused STATE_OFF = "off" STATE_BLOCKED = "blocked" STATE_AVAILABLE = "available" STATE_RUNNING = "running" STATE_PAUSE = "paused" STATE_GONE = "gone" def __init__(self, id: int, command: CentralCommand): self.id = id self.lightId = id self.command = command self.logger = mainLogger.getChild(f"{self.id}").getChild("command") self.loop = asyncio.new_event_loop() self.isConfigured = None self.isRunning = asyncio.Event(loop=self.loop) self.isRunning.clear() self.eventQueue = None self.language_code = 'en-GB' self.future_language_code = None self.story = None self.streamer = None self.google = None self.player = None self.recorder = None self.notShuttingDown = True # TODO: allow shutdown of object self.startMsgId = None self.lightStatus = 0 self.eventLogger = eventLogger.getChild(f"{self.id}") self.blockRestart = False self.setStatus(self.STATE_GONE) self.requireRestartAfterStop = None self.lightTransitionStatus = {'intensity': self.command.config['light']['off_intensity'], 'duration': self.command.config['light']['fade_duration_id'], 'isSophie': False} def __del__(self): self.logger.warn("Destroying hugvey object") def isAvailable(self): if self.command.config['story']['loop']: if (self.status == self.STATE_RUNNING or self.status == self.STATE_PAUSE) and self.story: if self.story.currentMessage: if self.story.currentMessage.id == self.story.startMessage.id: return True return self.status == self.STATE_AVAILABLE def getStatus(self): return self.status def setStatus(self, status, log=True): self.status = status # if the story is looping, light should not go off when the story starts if status != self.STATE_RUNNING or self.command.config['story']['loop'] is False: lightOn = status in [self.STATE_AVAILABLE, self.STATE_PAUSE] intensity = self.command.config['light']['on_intensity'] if lightOn else self.command.config['light']['off_intensity'] duration = self.command.config['light']['fade_duration_id'] self.transitionLight(intensity, duration) if log: self.eventLogger.info(f"status: {self.status}") def config(self, hostname, ip): self.ip = ip self.hostname = hostname if self.isConfigured is not None: # a reconfiguration/reconnection pass else: self.logger.info( f"Hugvey {self.id} at {self.ip}, host: {self.hostname}") if self.status == self.STATE_GONE: # turn on :-) self.setStatus(self.STATE_BLOCKED) self.isConfigured = time.time() def sendCommand(self, msg): """ Send message or command to hugvey @param msg: The message to be sent. Probably a dict() """ self.command.commandHugvey(self.id, msg) def run(self): self.logger.info(f"Await hugvey #{self.id}") tasks = asyncio.gather( self.catchException(self.processAudio()), self.catchException(self.handleEvents()), self.catchException(self.playStory()), loop=self.loop) self.loop.run_until_complete(tasks) self.logger.warning(f"FINISHED RUNNING {self.id}") return self.requireRestartAfterStop async def catchException(self, awaitable): try: # print(awaitable) await awaitable except Exception as e: self.logger.exception(e) self.logger.critical(f"Hugvey crash") self.eventLogger.critical(f"error: {e}") # restart # TODO: test proper functioning self.shutdown() def queueEvent(self, msg): if 'time' not in msg: # add time, so we can track time passed msg['time'] = time.time() if not self.eventQueue: self.logger.critical("No event queue to put {}".format(msg)) else: # Allow for both the Hugvey Command, or the Story handle the event. self.loop.call_soon_threadsafe(self._queueEvent, msg) def _queueEvent(self, msg): """ Put event in both the event loop for the story as well as the Hugvey State handler """ # self.logger.debug(f"Queue event in hugvey loop: {msg}") # a little less logging please :-) self.eventQueue.put_nowait(msg) # connection events don't need to go to the story if msg['event'] == 'connection': return if self.story: self.story.events.append(msg) else: self.logger.critical("Cannot queue event, as no story is present.") async def handleEvents(self): self.eventQueue = asyncio.Queue() # start event queue here, to avoid loop issues while self.notShuttingDown: try: event = await asyncio.wait_for(self.eventQueue.get(), 2) except asyncio.futures.TimeoutError as e: # detect missing heartbeat: if self.isConfigured and time.time() - self.isConfigured > 15: self.logger.error("Hugvey did not send heartbeat.") # self.gone() self.shutdown() continue self.logger.debug("Received: {}".format(event)) if event['event'] == 'connection': # 'event': 'connection', # 'id': self.hugvey_id, # 'host': socket.gethostname(), # 'ip': self.getIp(), self.config(event['host'], event['ip']) if event['event'] == 'language': self.setLanguage(event['code']) if event['event'] == 'pause': self.pause() if event['event'] == 'block': self.block() if event['event'] == 'unblock': self.available() if event['event'] == 'restart': self.restart() if event['event'] == 'finish': self.blockRestart = True self.story._finish() # finish story AND hugvey state if event['event'] == 'resume': self.resume() if event['event'] == 'change_language': self.setLanguage(event['lang_code']) if event['event'] == 'change_language_if_available': self.future_language_code = event['lang_code'] # if self.isAvailable() or self.status == self.STATE_BLOCKED: # self.setLanguage(event['lang_code']) if event['event'] == 'change_light': self.setLightId(event['light_id']) if event['event'] == 'change_light_status': self.setLightStatus(event['status']) if event['event'] == 'play_msg': self.logger.info(f"Play given message {event['msg_id']}") if not self.story: self.logger.critical("No story to play message in") else: if self.story is None: return if event['reloadStory']: self.startMsgId = event['msg_id'] self.logger.debug(f"Restart from {self.startMsgId}") self.restart() else: msg = self.story.get(event['msg_id']) await self.story.setCurrentMessage(msg) self.eventQueue = None def setLanguage(self, language_code): self.configureLanguage(language_code) if self.isRunning.is_set(): self.restart() def configureLanguage(self, language_code): if language_code not in self.command.languages: raise Exception("Invalid language {}".format(language_code)) self.logger.info(f"set language: {language_code}") self.language_code = language_code self.future_language_code = language_code if self.google: self.google.setLanguage(language_code) def pause(self, log = True): if log: self.logger.info('Pause') if self.google: self.google.pause() if self.story: self.story.pause() self.isRunning.clear() self.setStatus(self.STATE_PAUSE, log=log) def resume(self, log = True): """Start playing without reset, also used to play from a saved state""" if log: self.logger.info('Resume') if self.google: self.google.resume() if self.story: self.story.resume() self.isRunning.set() self.setStatus(self.STATE_RUNNING) def restart(self): """Start playing with reset""" self.logger.info('Restart') if Story.hugveyHasSavedState(self.lightId): Story.clearSavedState(self.lightId) if self.story: self.story.stop() self.resume(log=False) def block(self): """Block a hugvey""" self.logger.info('block') if self.google: self.google.pause() if self.story: self.story.finish() self.isRunning.clear() self.setStatus(self.STATE_BLOCKED) def available(self, log=True): """Put in available mode""" self.logger.info('Finish/Await') # TODO: Toggle running if config says so, but turn light on self.pause(log=False) self.setStatus(self.STATE_AVAILABLE, log=log) def setLightStatus(self, on): self.lightStatus = 1 if on else 0 self.logger.log(LOG_BS, f"Send /hugvey {self.lightStatus}") self.command.commandLight('/hugvey', [self.lightId, self.lightStatus]) def transitionLight(self, intensity, duration, isSophie = False): """ Intensity: 0-255 duration: an integer between 0-92 indicating the lanbox fade times The light fade in & out for Sophie (a moment in the story) are an override, so that they come in even though all is out. """ self.lightTransitionStatus = {'intensity': intensity, 'duration': duration, 'isSophie': isSophie} self.logger.debug(f"Send /hugvey_fade {intensity} {duration} {1 if isSophie else 0}") self.command.commandLight('/hugvey_fade', [self.lightId, intensity, int(duration), 1 if isSophie else 0]) def setLightId(self, id): """ Connect hugvey to another light """ self.lightId = id def gone(self): '''Status to 'gone' as in, shutdown/crashed/whatever ''' self.pause(log=False) if self.story: self.story.stop() self.logger.warn('Gone') self.eventLogger.warn("Gone") self.isConfigured = None self.setStatus(self.STATE_GONE) def shutdown(self, definitive = False): self.logger.info(f"Start shutdown sequence {definitive}") self.eventLogger.critical(f"error: shutting down") if self.streamer: self.streamer.stop() if self.story: self.story.shutdown() self.story = None # shutdown for stream consumers already ran. Only clear references if self.google: self.google = None if self.player: self.player = None if self.recorder: self.recorder = None if self.requireRestartAfterStop is None: # prevent double setting of the same variable # first call sometimes triggers second self.requireRestartAfterStop = not definitive self.notShuttingDown = False async def playStory(self): while self.notShuttingDown: try: await asyncio.wait_for(self.isRunning.wait(), 1) except asyncio.futures.TimeoutError as e: # timeout + catch so we can shutdown if needed without infinite await continue else: # new story instance on each run port = self.command.config['web']['port'] resuming = False if Story.hugveyHasSavedState(self.lightId): self.logger.info(f"Recovering from state :-)") self.story = Story.loadStoryFromState(self) resuming = True if self.story.language_code != self.language_code: self.logger.info("Changing language") self.configureLanguage(self.story.language_code) else: if self.future_language_code and self.future_language_code != self.language_code: self.logger.info(f"Restart with other language: {self.language_code} -> {self.future_language_code}") self.configureLanguage(self.future_language_code) self.story = Story(self, port) self.story.setStoryData(copy.deepcopy(self.command.languages[self.language_code]), self.language_code) if not self.streamer: await asyncio.sleep(1) self.streamer.triggerStart() startMsgId = self.startMsgId self.startMsgId = None # use only once, reset before 'run' if not startMsgId and self.story.currentMessage: startMsgId = self.story.currentMessage.id self.logger.info(f"Starting from {startMsgId}") self.setLightStatus(False) await self.story.run(startMsgId, resuming) if self.command.config['story']['loop']: if not self.blockRestart: if self.notShuttingDown: self.logger.info("Loop story") self.restart() else: self.logger.info("Don't loop on manual finish") # reset a potential setting of blockRestart self.blockRestart = False # self.story = None def getStreamer(self): if not self.streamer: self.streamer = AudioStreamer( self.command.config['voice']['chunk'], self.ip, int(self.command.config['voice']['port']) + self.id, self.id) if self.command.config['voyeur']: self.logger.warn("Debug on: Connecting Audio player") self.player = Player( self.command.config['voice']['src_rate'], self.command.config['voice']['out_rate']) self.streamer.addConsumer(self.player) if self.command.config['voice']['record_dir']: self.logger.info("Record Audio of conversation") self.recorder = Recorder( self.id, self.command.config['voice']['src_rate'], self.command.config['voice']['record_dir'], self.command.config['voice']['record_voice'] if 'record_voice' in self.command.config['voice'] else False) self.streamer.addConsumer(self.recorder) self.logger.debug("Start Speech") self.google = GoogleVoiceClient( hugvey=self, src_rate=self.command.config['voice']['src_rate'], credential_file=self.command.config['voice']['google_credentials'], language_code=self.language_code ) self.streamer.addConsumer(self.google) return self.streamer async def processAudio(self): ''' Start the audio streamer service ''' self.logger.debug("Start audio loop") while self.notShuttingDown: try: await asyncio.wait_for(self.isRunning.wait(), 1) except asyncio.futures.TimeoutError as e: # timeout + catch so we can shutdown if needed without infinite await continue else: self.logger.debug("Start audio stream") await self.getStreamer().run() self.logger.critical(f"stream has left the building from {self.ip}") self.eventLogger.critical(f"error: stream has left the building from {self.ip}") # if we end up here, the streamer finished, probably meaning hte hugvey shutdown # self.gone()