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