#!bin/python from run_local import log from twisted.internet import reactor from twisted.internet.defer import inlineCallbacks, returnValue import cyclone.web, cyclone.websocket, cyclone.httpclient import sys, optparse, logging, json, itertools from rdflib import URIRef, Literal from light9 import networking, showconfig from light9.effecteval.effect import EffectNode from light9.effect.edit import getMusicStatus, songNotePatch from light9.effecteval.effectloop import makeEffectLoop from greplin.scales.cyclonehandler import StatsHandler from light9.namespaces import L9 from rdfdb.patch import Patch from rdfdb.syncedgraph import SyncedGraph from greplin import scales from cycloneerr import PrettyErrorHandler from light9.coffee import StaticCoffee class EffectEdit(PrettyErrorHandler, cyclone.web.RequestHandler): def get(self): self.set_header('Content-Type', 'text/html') self.write(open("light9/effecteval/effect.html").read()) def delete(self): graph = self.settings.graph uri = URIRef(self.get_argument('uri')) with graph.currentState(tripleFilter=(None, L9['effect'], uri)) as g: song = ctx = list(g.subjects(L9['effect'], uri))[0] self.settings.graph.patch( Patch(delQuads=[ (song, L9['effect'], uri, ctx), ])) @inlineCallbacks def currentSong(): s = (yield getMusicStatus())['song'] if s is None: raise ValueError("no current song") returnValue(URIRef(s)) class SongEffects(PrettyErrorHandler, cyclone.web.RequestHandler): def wideOpenCors(self): self.set_header('Access-Control-Allow-Origin', '*') self.set_header('Access-Control-Allow-Methods', 'GET, PUT, POST, DELETE, OPTIONS') self.set_header('Access-Control-Max-Age', '1000') self.set_header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With') def options(self): self.wideOpenCors() self.write('') @inlineCallbacks def post(self): self.wideOpenCors() dropped = URIRef(self.get_argument('drop')) try: song = URIRef(self.get_argument('uri')) except Exception: # which? song = yield currentSong() event = self.get_argument('event', default='default') note = self.get_argument('note', default=None) if note is not None: note = URIRef(note) log.info("adding to %s", song) note, p = yield songNotePatch(self.settings.graph, dropped, song, event, ctx=song, note=note) self.settings.graph.patch(p) self.settings.graph.suggestPrefixes(song, {'song': URIRef(song + '/')}) self.write(json.dumps({'note': note})) class SongEffectsUpdates(cyclone.websocket.WebSocketHandler): def connectionMade(self, *args, **kwargs): self.graph = self.settings.graph self.graph.addHandler(self.updateClient) def updateClient(self): # todo: abort if client is gone playlist = self.graph.value(showconfig.showUri(), L9['playList']) songs = list(self.graph.items(playlist)) out = [] for s in songs: out.append({'uri': s, 'label': self.graph.label(s), 'effects': []}) seen = set() for n in self.graph.objects(s, L9['note']): for uri in self.graph.objects(n, L9['effectClass']): if uri in seen: continue seen.add(uri) out[-1]['effects'].append({ 'uri': uri, 'label': self.graph.label(uri) }) out[-1]['effects'].sort(key=lambda e: e['uri']) self.sendMessage({'songs': out}) class EffectUpdates(cyclone.websocket.WebSocketHandler): """ stays alive for the life of the effect page """ def connectionMade(self, *args, **kwargs): log.info("websocket opened") self.uri = URIRef(self.get_argument('uri')) self.sendMessage({'hello': repr(self)}) self.graph = self.settings.graph self.graph.addHandler(self.updateClient) def updateClient(self): # todo: if client has dropped, abort and don't get any more # graph updates # EffectNode knows how to put them in order. Somehow this is # not triggering an update when the order changes. en = EffectNode(self.graph, self.uri) codeLines = [c.code for c in en.codes] self.sendMessage({'codeLines': codeLines}) def connectionLost(self, reason): log.info("websocket closed") def messageReceived(self, message): log.info("got message %s" % message) # write a patch back to the graph def replaceObjects(graph, c, s, p, newObjs): patch = graph.getObjectPatch(context=c, subject=s, predicate=p, newObject=newObjs[0]) moreAdds = [] for line in newObjs[1:]: moreAdds.append((s, p, line, c)) fullPatch = Patch(delQuads=patch.delQuads, addQuads=patch.addQuads + moreAdds) graph.patch(fullPatch) class Code(PrettyErrorHandler, cyclone.web.RequestHandler): def put(self): effect = URIRef(self.get_argument('uri')) codeLines = [] for i in itertools.count(0): k = 'codeLines[%s][text]' % i v = self.get_argument(k, None) if v is not None: codeLines.append(Literal(v)) else: break if not codeLines: log.info("no codelines received on PUT /code") return with self.settings.graph.currentState(tripleFilter=(None, L9['effect'], effect)) as g: song = next(g.subjects(L9['effect'], effect)) replaceObjects(self.settings.graph, song, effect, L9['code'], codeLines) # right here we could tell if the code has a python error and return it self.send_error(202) class EffectEval(PrettyErrorHandler, cyclone.web.RequestHandler): @inlineCallbacks def get(self): # return dmx list for that effect uri = URIRef(self.get_argument('uri')) response = yield cyclone.httpclient.fetch( networking.musicPlayer.path('time')) songTime = json.loads(response.body)['t'] node = EffectNode(self.settings.graph, uri) outSub = node.eval(songTime) self.write(json.dumps(outSub.get_dmx_list())) # Completely not sure where the effect background loop should # go. Another process could own it, and get this request repeatedly: class SongEffectsEval(PrettyErrorHandler, cyclone.web.RequestHandler): def get(self): song = URIRef(self.get_argument('song')) effects = effectsForSong(self.settings.graph, song) # noqa raise NotImplementedError self.write(maxDict(effectDmxDict(e) for e in effects)) # noqa # return dmx dict for all effects in the song, already combined class App(object): def __init__(self, show, outputWhere): self.show = show self.outputWhere = outputWhere self.graph = SyncedGraph(networking.rdfdb.url, "effectEval") self.graph.initiallySynced.addCallback(self.launch).addErrback( log.error) self.stats = scales.collection( '/', scales.PmfStat('sendLevels', recalcPeriod=1), scales.PmfStat('getMusic', recalcPeriod=1), scales.PmfStat('evals', recalcPeriod=1), scales.PmfStat('sendOutput', recalcPeriod=1), scales.IntStat('errors'), ) def launch(self, *args): log.info('launch') if self.outputWhere: self.loop = makeEffectLoop(self.graph, self.stats, self.outputWhere) self.loop.startLoop() SFH = cyclone.web.StaticFileHandler self.cycloneApp = cyclone.web.Application(handlers=[ (r'/()', SFH, { 'path': 'light9/effecteval', 'default_filename': 'index.html' }), (r'/effect', EffectEdit), (r'/effect\.js', StaticCoffee, { 'src': 'light9/effecteval/effect.coffee' }), (r'/(effect-components\.html)', SFH, { 'path': 'light9/effecteval' }), (r'/effectUpdates', EffectUpdates), (r'/code', Code), (r'/songEffectsUpdates', SongEffectsUpdates), (r'/effect/eval', EffectEval), (r'/songEffects', SongEffects), (r'/songEffects/eval', SongEffectsEval), (r'/stats/(.*)', StatsHandler, { 'serverName': 'effecteval' }), ], debug=True, graph=self.graph, stats=self.stats) reactor.listenTCP(networking.effectEval.port, self.cycloneApp) log.info("listening on %s" % networking.effectEval.port) if __name__ == "__main__": parser = optparse.OptionParser() parser.add_option( '--show', help='show URI, like http://light9.bigasterisk.com/show/dance2008', default=showconfig.showUri()) parser.add_option("-v", "--verbose", action="store_true", help="logging.DEBUG") parser.add_option("--twistedlog", action="store_true", help="twisted logging") parser.add_option("--output", metavar="WHERE", help="dmx or leds") (options, args) = parser.parse_args() log.setLevel(logging.DEBUG if options.verbose else logging.INFO) if not options.show: raise ValueError("missing --show http://...") app = App(URIRef(options.show), options.output) if options.twistedlog: from twisted.python import log as twlog twlog.startLogging(sys.stderr) reactor.run()