Mercurial > louis > mq > lightsd
changeset 496:08ad69e0a7a7
get things working in a good shape
author | Louis Opter <kalessin@kalessin.fr> |
---|---|
date | Fri, 14 Oct 2016 00:50:44 -0700 |
parents | e3a86bd3a01a |
children | 019c6e75f61d |
files | add_monolight.patch |
diffstat | 1 files changed, 291 insertions(+), 107 deletions(-) [+] |
line wrap: on
line diff
--- a/add_monolight.patch Thu Oct 06 02:16:35 2016 -0700 +++ b/add_monolight.patch Fri Oct 14 00:50:44 2016 -0700 @@ -1,5 +1,5 @@ # HG changeset patch -# Parent c38eb50fef4516266f1231b1278af61bb43b64b8 +# Parent 045e51ef0bec937287f1e5b88bb87d8218a488a9 Start an experimental GUI for a Monome 128 Varibright Written in Python >= 3.5. @@ -18,7 +18,7 @@ new file mode 100644 --- /dev/null +++ b/monolight/monolight/cli.py -@@ -0,0 +1,79 @@ +@@ -0,0 +1,101 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. @@ -38,11 +38,13 @@ + +import asyncio +import click ++import contextlib +import functools +import locale +import logging +import monome +import os ++import signal +import subprocess + +from . import lightsc @@ -75,6 +77,25 @@ +LIGHTSD_SOCKET = "unix://" + os.path.join(get_lightsd_rundir(), "socket") + + ++@contextlib.contextmanager ++def handle_unix_signals(loop): ++ SIGNALS = (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT) ++ if hasattr(loop, "add_signal_handler"): ++ for signum in SIGNALS: ++ loop.add_signal_handler(signum, ui.stop) ++ yield ++ # Workaround dumb bug in Python: ++ # Traceback (most recent call last): ++ # File "/usr/lib64/python3.5/asyncio/base_events.py", line 431, in __del__ ++ # File "/usr/lib64/python3.5/asyncio/unix_events.py", line 58, in close ++ # File "/usr/lib64/python3.5/asyncio/unix_events.py", line 139, in remove_signal_handler # noqa ++ # File "/usr/lib64/python3.5/signal.py", line 47, in signal ++ # TypeError: signal handler must be signal.SIG_IGN, signal.SIG_DFL, or a callable object # noqa ++ if hasattr(loop, "remove_signal_handler"): ++ for signum in SIGNALS: ++ loop.remove_signal_handler(signum) ++ ++ +@click.command() +@click.option("--serialoscd-host", default="127.0.0.1") +@click.option("--serialoscd-port", type=click.IntRange(0, 2**16 - 1)) @@ -82,27 +103,123 @@ +def main(serialoscd_host, serialoscd_port, lightsd_url): + loop = asyncio.get_event_loop() + -+ # TODO: add signal and EOF handling and shutdown tasks gracefully ++ tasks = asyncio.gather( ++ loop.create_task(lightsc.create_lightsd_connection(lightsd_url)), ++ loop.create_task(monome.create_serialosc_connection( ++ functools.partial(osc.MonomeApplication, ui.submit_keypress) ++ )) ++ ) ++ loop.run_until_complete(tasks) ++ lightsd, serialosc = tasks.result() + -+ serialosc = loop.create_task(monome.create_serialosc_connection( -+ functools.partial(osc.MonomeApplication, ui.submit_keypress) -+ )) -+ lightsd = loop.create_task(lightsc.create_lightsd_connection(lightsd_url)) ++ with handle_unix_signals(loop): ++ # TODO: make which monome instance to use something configurable ++ ui_task = loop.create_task(ui.start(loop, lightsd, serialosc)) ++ ++ loop.run_until_complete(ui_task) + -+ loop.run_until_complete(lightsd) -+ loop.run_until_complete(serialosc) ++ serialosc.disconnect() ++ loop.run_until_complete(lightsd.close()) +diff --git a/monolight/monolight/lightsc/__init__.py b/monolight/monolight/lightsc/__init__.py +new file mode 100644 +--- /dev/null ++++ b/monolight/monolight/lightsc/__init__.py +@@ -0,0 +1,21 @@ ++# Copyright (c) 2016, Louis Opter <louis@opter.org> ++# ++# This file is part of lightsd. ++# ++# lightsd is free software: you can redistribute it and/or modify ++# it under the terms of the GNU General Public License as published by ++# the Free Software Foundation, either version 3 of the License, or ++# (at your option) any later version. ++# ++# lightsd is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# You should have received a copy of the GNU General Public License ++# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + -+ # TODO: make which monome instance to use something configurable -+ ui_task = loop.create_task(ui.start( # noqa -+ loop, lightsd.result(), serialosc.result() -+ )) -+ -+ loop.run_forever() -diff --git a/monolight/monolight/lightsc.py b/monolight/monolight/lightsc.py ++from .lightsc import ( # noqa ++ LightsClient, ++ create_lightsd_connection, ++) +diff --git a/monolight/monolight/lightsc/commands.py b/monolight/monolight/lightsc/commands.py new file mode 100644 --- /dev/null -+++ b/monolight/monolight/lightsc.py -@@ -0,0 +1,212 @@ ++++ b/monolight/monolight/lightsc/commands.py +@@ -0,0 +1,64 @@ ++# Copyright (c) 2016, Louis Opter <louis@opter.org> ++# ++# This file is part of lightsd. ++# ++# lightsd is free software: you can redistribute it and/or modify ++# it under the terms of the GNU General Public License as published by ++# the Free Software Foundation, either version 3 of the License, or ++# (at your option) any later version. ++# ++# lightsd is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# You should have received a copy of the GNU General Public License ++# along with lightsd. If not, see <http://www.gnu.org/licenses/>. ++ ++ ++class Command: ++ ++ METHOD = None ++ ++ def __init__(self, *args): ++ self.params = args ++ ++ ++class SetLightFromHSBK(Command): ++ ++ METHOD = "set_light_from_hsbk" ++ ++ def __init__(self, targets, h, s, b, k, transition): ++ Command.__init__(self, targets, h, s, b, k, transition) ++ ++ ++class GetLightState(Command): ++ ++ METHOD = "get_light_state" ++ ++ def __init__(self, targets): ++ Command.__init__(self, targets) ++ ++ ++class PowerOff(Command): ++ ++ METHOD = "power_off" ++ ++ def __init__(self, targets): ++ Command.__init__(self, targets) ++ ++ ++class PowerOn(Command): ++ ++ METHOD = "power_on" ++ ++ def __init__(self, targets): ++ Command.__init__(self, targets) ++ ++ ++class PowerToggle(Command): ++ ++ METHOD = "power_toggle" ++ ++ def __init__(self, targets): ++ Command.__init__(self, targets) +diff --git a/monolight/monolight/lightsc/lightsc.py b/monolight/monolight/lightsc/lightsc.py +new file mode 100644 +--- /dev/null ++++ b/monolight/monolight/lightsc/lightsc.py +@@ -0,0 +1,248 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. @@ -122,7 +239,6 @@ + +import asyncio +import collections -+import contextlib +import functools +import json +import logging @@ -130,6 +246,8 @@ +import urllib +import uuid + ++from . import commands ++ +logger = logging.getLogger("monolight.lightsc") + +PendingRequestEntry = collections.namedtuple( @@ -149,24 +267,25 @@ + self.response_handler = response_handler + + -+class _LightsClientBatchProxy: ++class _JSONRPCBatch: + + def __init__(self, client): + self._client = client + self._batch = [] + -+ def batch_jsonrpc_call(method): -+ @functools.wraps(method) -+ async def wrapper(client, method, params, response_handler=None): -+ self._batch.append( -+ _JSONRPCCall(method, params, response_handler), -+ ) -+ return wrapper ++ async def __aenter__(self): ++ return self ++ ++ async def __aexit__(self, exc_type, exc_val, exc_tb): ++ if exc_type is None: ++ await self._client._jsonrpc_execute(self._batch) + -+ self._jsonrpc_call = batch_jsonrpc_call(client._jsonrpc_call) -+ -+ def __getattr__(self, name): -+ return self._client.__getattribute__(name) ++ def apply(self, command): ++ self._batch.append(_JSONRPCCall( ++ command.METHOD, ++ command.params, ++ response_handler=self._client._HANDLERS.get(command.__class__) ++ )) + + +class LightsClient: @@ -190,8 +309,85 @@ + self.refresh_interval = refresh_interval + self._pending_requests = {} + self._reader = self._writer = None ++ self._listen_task = self._poll_task = None + self._loop = loop or asyncio.get_event_loop() + ++ def _handle_light_state(self, response): ++ logger.info("Updating bulbs state") ++ self._bulbs = {b["label"]: b for b in response} ++ ++ _HANDLERS = { ++ commands.GetLightState: _handle_light_state ++ } ++ ++ def _handle_response(self, id, response): ++ handler_cb, timeout_handle = self._pending_requests.pop(id) ++ timeout_handle.cancel() ++ ++ if response is None: ++ logger.info("Timeout on request {}".format(id)) ++ return ++ ++ if handler_cb is not None: ++ handler_cb(self, response) ++ return ++ ++ logger.info("No handler for response {}: {}".format(id, response)) ++ ++ async def _jsonrpc_execute(self, pipeline): ++ calls = [call.request for call in pipeline] ++ payload = json.dumps(calls[0] if len(calls) == 1 else calls) ++ payload = payload.encode(self.encoding, "surrogateescape") ++ ++ self._writer.write(payload) ++ ++ for call in calls: ++ logger.info("Request {id}: {method}({params})".format(**call)) ++ ++ await self._writer.drain() ++ ++ for call in pipeline: ++ id = call.request["id"] ++ timeout_cb = functools.partial( ++ self._handle_response, id, response=None ++ ) ++ self._pending_requests[id] = PendingRequestEntry( ++ handler_cb=call.response_handler, ++ timeout_handle=self._loop.call_later(self.TIMEOUT, timeout_cb) ++ ) ++ ++ async def close(self): ++ futures = [] ++ if self._poll_task is not None: ++ self._poll_task.cancel() ++ futures.append(self._poll_task) ++ if self._listen_task is not None: ++ self._listen_task.cancel() ++ futures.append(self._listen_task) ++ await asyncio.wait(futures, loop=self._loop) ++ self._poll_task = self._listen_task = None ++ ++ if self._writer is not None: ++ if self._writer.can_write_eof(): ++ self._writer.write_eof() ++ self._writer.close() ++ if self._reader is not None: ++ self._reader.feed_eof() ++ if not self._reader.at_eof(): ++ await self._reader.read() ++ self._reader = self._writer = None ++ ++ async def _reconnect(self): ++ await self.close() ++ await self.connect() ++ ++ async def apply(self, command): ++ await self._jsonrpc_execute([_JSONRPCCall( ++ command.METHOD, ++ command.params, ++ response_handler=self._HANDLERS.get(command.__class__), ++ )]) ++ + async def connect(self): + parts = urllib.parse.urlparse(self.url) + if parts.scheme == "unix": @@ -218,32 +414,22 @@ + logger.error("Couldn't open {}".format(self.url)) + raise + -+ async def _reconnect(self): -+ # TODO: properly close everything -+ await self.connect() -+ -+ def _handle_response(self, id, response): -+ handler_cb, timeout_handle = self._pending_requests.pop(id) -+ timeout_handle.cancel() -+ -+ if response is None: -+ logger.info("Timeout on request {}".format(id)) -+ return -+ -+ logger.info("Response {}".format(id)) -+ if handler_cb is not None: -+ handler_cb(response) ++ async def poll(self): ++ while True: ++ await self.apply(commands.GetLightState(["*"])) ++ await asyncio.sleep(self.refresh_interval) + + async def listen(self): + buf = bytearray() ++ + while True: -+ logger.info("Reading...") + chunk = await self._reader.read(self.READ_SIZE) -+ buf += chunk + if not len(chunk): # EOF, reconnect ++ logger.info("EOF, reconnecting...") + await self._reconnect() + return + ++ buf += chunk + try: + json.loads(buf.decode(self.encoding, "ignore")) + except Exception: @@ -255,57 +441,24 @@ + response = json.loads(buf.decode(self.encoding, "surrogateescape")) + buf = bytearray() + -+ self._handle_response(response["id"], response["result"]) -+ -+ async def _jsonrpc_execute(self, pipeline): -+ calls = [call.request for call in pipeline] -+ payload = json.dumps(calls[0] if len(calls) == 1 else calls) -+ payload = payload.encode(self.encoding, "surrogateescape") -+ -+ self._writer.write(payload) -+ -+ for call in calls: -+ logger.info("Request {id}: {method}({params})".format(**call)) -+ -+ await self._writer.drain() -+ -+ for call in pipeline: -+ id = call.request["id"] -+ timeout_cb = functools.partial( -+ self._handle_response, id, response=None -+ ) -+ self._pending_requests[id] = PendingRequestEntry( -+ handler_cb=call.response_handler, -+ timeout_handle=self._loop.call_later(self.TIMEOUT, timeout_cb) -+ ) ++ # Convert the response to a batch response if needed so we ++ # can always loop over it: ++ batch = response if isinstance(response, list) else [response] + -+ async def _jsonrpc_call(self, method, params, response_handler=None): -+ await self._jsonrpc_execute([ -+ _JSONRPCCall(method, params, response_handler), -+ ]) -+ -+ @contextlib.contextmanager -+ def batch(self): -+ proxy = _LightsClientBatchProxy(self) -+ yield proxy -+ self.loop.ensure_future(self._jsonrpc_execute(proxy._batch)) ++ for response in batch: ++ error = response.get("error") ++ if error is not None: ++ id = response["id"] ++ del self._pending_requests[id] ++ logger.warning("Error {}: {code} - {message}".format( ++ id, **error ++ )) ++ continue + -+ async def get_light_state(self, targets): -+ await self._jsonrpc_call( -+ "get_light_state", targets, self._handle_light_state -+ ) -+ -+ async def power_off(self, targets): -+ await self._jsonrpc_call("power_off", targets) ++ self._handle_response(response["id"], response["result"]) + -+ async def power_on(self, targets): -+ await self._jsonrpc_call("power_on", targets) -+ -+ async def power_toggle(self, targets): -+ await self._jsonrpc_call("power_toggle", targets) -+ -+ def _handle_light_state(self, response): -+ self._bulbs = {b["label"]: b for b in response} ++ def batch(self): ++ return _JSONRPCBatch(self) + + +async def create_lightsd_connection(url, loop=None): @@ -369,9 +522,9 @@ new file mode 100644 --- /dev/null +++ b/monolight/monolight/ui.py -@@ -0,0 +1,83 @@ +@@ -0,0 +1,114 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> -+ ++# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify @@ -396,18 +549,26 @@ + MONOME_KEYPRESS_DOWN, + monome_apply, +) ++from .lightsc.commands import ( ++ SetLightFromHSBK, ++ PowerOff, ++ PowerOn, ++ PowerToggle, ++) + +logger = logging.getLogger("monolight.ui") + -+_event_queue = asyncio.Queue() ++_event_queue = None + +_KeyPress = collections.namedtuple("_KeyPress", ("x", "y", "state")) + ++_STOP_SENTINEL = object() ++ + +def draw(serialosc): + buf = monome.LedBuffer(8, 8) + buf.led_set(0, 0, 1) -+ for x in range(0, 4): ++ for x in range(0, 5): + buf.led_set(x, 7, 1) + monome_apply(serialosc, buf.render) + @@ -417,10 +578,18 @@ + + +async def start(loop, lightsd, serialosc): ++ global _event_queue ++ ++ _event_queue = asyncio.Queue() ++ + hidden = True + + while True: + keypress = await _event_queue.get() ++ if keypress is _STOP_SENTINEL: ++ hide(serialosc) ++ _event_queue = None ++ return + + if not hidden: + draw(serialosc) @@ -437,22 +606,37 @@ + if hidden: + hide(serialosc) + continue -+ await lightsd.power_off(["*"]) ++ await lightsd.apply(PowerOff(["*"])) + if keypress.y != 7: + continue + if keypress.x == 1: -+ await lightsd.power_on(["*"]) ++ await lightsd.apply(PowerOn(["*"])) + elif keypress.x == 2: -+ await lightsd.power_toggle(["neko"]) ++ await lightsd.apply(PowerToggle(["neko"])) + elif keypress.x == 3: -+ await lightsd.power_toggle(["fugu"]) ++ await lightsd.apply(PowerToggle(["fugu"])) + elif keypress.x == 4: -+ # TODO pipeline to impl orange -+ await lightsd.power_toggle(["fugu"]) ++ async with lightsd.batch() as batch: ++ batch.apply(SetLightFromHSBK( ++ ["#tower"], 37.469443, 1.0, 0.25, 3500, 600 ++ )) ++ batch.apply(SetLightFromHSBK( ++ ["fugu", "buzz"], 47.469443, 0.2, 0.2, 3500, 600 ++ )) ++ batch.apply(SetLightFromHSBK( ++ ["candle"], 47.469443, 0.2, 0.15, 3500, 600 ++ )) ++ batch.apply(PowerOn(["#br"])) ++ ++ ++def stop(): ++ if _event_queue is not None: ++ _event_queue.put_nowait(_STOP_SENTINEL) + + +def submit_keypress(x, y, state): -+ _event_queue.put_nowait(_KeyPress(x, y, state)) ++ if _event_queue is not None: ++ _event_queue.put_nowait(_KeyPress(x, y, state)) diff --git a/monolight/setup.py b/monolight/setup.py new file mode 100644 --- /dev/null