view add_monolight.patch @ 532:de2d968228a0

A few monolight fixups + add fosdem demo script and slides monolight fixups: - fix when lightsd isn't installed; - fix for * targeting - add set_waveform Also this adds the slides for fosdem since the build is unlikely to be reproducible in the future (e.g. scaling bugs within tikz will probably get fixed at some point and I'm working around them here).
author Louis Opter <kalessin@kalessin.fr>
date Wed, 01 Feb 2017 11:23:23 +0100
parents 154cc5d504b3
children
line wrap: on
line source

# HG changeset patch
# Parent  dedc8f412d885abef3da2acb9153998f409c3a2a
Start an experimental GUI for a Monome 128 Varibright

Written in Python >= 3.5.

diff --git a/.hgignore b/.hgignore
--- a/.hgignore
+++ b/.hgignore
@@ -2,3 +2,4 @@
 .*\.py[co]$
 ^build
 ^pcaps
+.*\.egg-info$
diff --git a/apps/monolight/monolight/__init__.py b/apps/monolight/monolight/__init__.py
new file mode 100644
diff --git a/apps/monolight/monolight/bulbs.py b/apps/monolight/monolight/bulbs.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/bulbs.py
@@ -0,0 +1,99 @@
+# 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/>.
+
+import asyncio
+import collections
+import lightsc
+import logging
+
+from typing import List
+
+from lightsc.requests import GetLightState
+from lightsc.structs import LightBulb  # noqa
+
+from . import grids
+
+DEFAULT_REFRESH_DELAY = 0.1
+KEEPALIVE_DELAY = 60
+
+logger = logging.getLogger("monolight.bulbs")
+
+lightsd = None  # type: lightsc.LightsClient
+
+bulbs_by_label = {}  # type: Dict[str, LightBulb]
+bulbs_by_group = collections.defaultdict(set)  # type: Dict[str, Set[LightBulb]]
+
+_refresh_task = None  # type: asyncio.Task
+
+
+def iter_targets(targets: List[str]):
+    for target in targets:
+        if target == "*":
+            for bulb in bulbs_by_label.values():
+                yield bulb
+        elif target.startswith("#"):
+            for bulb in bulbs_by_group.get(target[1:], set()):
+                yield bulb
+        elif target in bulbs_by_label:
+            yield bulbs_by_label[target]
+
+
+async def _poll(
+    loop: asyncio.AbstractEventLoop,
+    refresh_delay_s: float
+) -> None:
+    global bulbs_by_label, bulbs_by_group
+
+    while True:
+        try:
+            state = await lightsd.apply(GetLightState(["*"]))
+        except lightsc.exceptions.LightsClientTimeoutError as ex:
+            logger.warning(
+                "lightsd timed out while trying to retrieve "
+                "the state of the bulbs: {}".format(ex)
+            )
+            continue
+
+        bulbs_by_label = {}
+        bulbs_by_group = collections.defaultdict(set)
+        for b in state.bulbs:
+            bulbs_by_label[b.label] = b
+            for t in b.tags:
+                bulbs_by_group[t].add(b)
+
+        delay = refresh_delay_s if grids.running else KEEPALIVE_DELAY
+        await asyncio.sleep(delay, loop=loop)
+
+
+async def start_lightsd_connection(
+    loop: asyncio.AbstractEventLoop,
+    lightsd_url: str,
+    refresh_delay_s: float = DEFAULT_REFRESH_DELAY,
+) -> None:
+    global _refresh_task, lightsd
+
+    lightsd = await lightsc.create_async_lightsd_connection(lightsd_url)
+    _refresh_task = loop.create_task(_poll(loop, refresh_delay_s))
+
+
+async def stop_all(loop: asyncio.AbstractEventLoop) -> None:
+    global _refresh_task, lightsd
+
+    _refresh_task.cancel()
+    await asyncio.wait([_refresh_task], loop=loop)
+    await lightsd.close()
+    lightsd = _refresh_task = None
diff --git a/apps/monolight/monolight/grids.py b/apps/monolight/monolight/grids.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/grids.py
@@ -0,0 +1,228 @@
+# 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/>.
+
+import asyncio
+import collections
+import enum
+import functools
+import logging
+import monome
+
+from typing import TYPE_CHECKING, Any, Iterator, Tuple, NamedTuple, cast
+from typing import List, Set  # noqa
+
+from .types import Dimensions, Position
+if TYPE_CHECKING:
+    from .ui.elements import UILayer  # noqa
+
+
+logger = logging.getLogger("monolight.grids")
+
+running = set()  # type: Set[MonomeGrid]
+running_event = None  # type: asyncio.Event
+_not_running_event = None  # type: asyncio.Event
+
+
+class KeyState(enum.IntEnum):
+
+    DOWN = 1
+    UP = 0
+
+
+KeyPress = NamedTuple("KeyPress", [
+    ("grid", "MonomeGrid"),
+    ("position", Position),
+    ("state", KeyState),
+])
+
+
+class LedLevel(enum.IntEnum):
+
+    OFF = 0
+    VERY_LOW_1 = 1
+    VERY_LOW_2 = 2
+    VERY_LOW_3 = 3
+    LOW = LOW_1 = 4
+    LOW_2 = 5
+    LOW_3 = 6
+    LOW_4 = 7
+    MEDIUM = MEDIUM_1 = 8
+    MEDIUM_2 = 9
+    MEDIUM_3 = 10
+    MEDIUM_4 = 11
+    HIGH = HIGH_1 = 12
+    HIGH_2 = 13
+    HIGH_3 = 14
+    HIGH_4 = ON = 15
+
+
+class LedCanvas(collections.abc.Iterable):
+
+    def __init__(
+        self, size: Dimensions, level: LedLevel = LedLevel.OFF
+    ) -> None:
+        self.size = size
+        self._levels = [level] * size.area
+
+    def _index(self, offset: Position) -> int:
+        return self.size.width * offset.y + offset.x
+
+    def set(self, offset: Position, level: LedLevel) -> None:
+        self._levels[self._index(offset)] = level
+
+    def shift(self, offset: Position) -> "LedCanvas":
+        class _Proxy:
+            def __init__(_self, canvas: LedCanvas, shift: Position):
+                _self._canvas = canvas
+                _self._shift = shift
+
+            def set(_self, offset: Position, level: LedLevel) -> None:
+                offset += _self._shift
+                return _self._canvas.set(offset, level)
+
+            def shift(_self, offset: Position) -> LedCanvas:
+                return cast(LedCanvas, _Proxy(_self, offset))
+
+            def __getattr__(self, name: str) -> Any:
+                return self._canvas.__getattribute__(name)
+        # I guess some kind of interface would avoid the cast, but whatever:
+        return cast(LedCanvas, _Proxy(self, offset))
+
+    def get(self, offset: Position) -> LedLevel:
+        return self._levels[self._index(offset)]
+
+    def __iter__(self) -> Iterator[Tuple[int, int, LedLevel]]:
+        for off_x in range(self.size.width):
+            for off_y in range(self.size.height):
+                yield off_x, off_y, self.get(Position(x=off_x, y=off_y))
+
+
+class AIOSCMonolightApp(monome.Monome):
+
+    def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
+        monome.Monome.__init__(self, "/monolight")
+        self._grid = None  # type: MonomeGrid
+        self.loop = loop
+
+    def ready(self) -> None:
+        self._grid = MonomeGrid(self)
+        running.add(self._grid)
+        logger.info("Grid {} ready".format(self.id))
+        if len(running) == 1:
+            running_event.set()
+            _not_running_event.clear()
+
+    def disconnect(self) -> None:
+        if len(running) == 1:
+            running_event.clear()
+            _not_running_event.set()
+        running.remove(self._grid)
+        self._grid.shutdown()
+        monome.Monome.disconnect(self)
+        logger.info("Grid {} disconnected".format(self.id))
+
+    def grid_key(self, x: int, y: int, s: int) -> None:
+        if self._grid is not None:
+            keypress = KeyPress(self._grid, Position(x, y), KeyState(s))
+            self._grid.submit_input(keypress)
+
+
+class MonomeGrid:
+
+    def __init__(self, monome_app: AIOSCMonolightApp) -> None:
+        self.loop = monome_app.loop
+        self.size = Dimensions(height=monome_app.height, width=monome_app.width)
+        self.layers = []  # type: List[UILayer]
+        self._show_ui = asyncio.Event(loop=self.loop)
+        self._show_ui.set()
+        self._input_queue = asyncio.Queue(loop=self.loop)  # type: asyncio.Queue
+        self._queue_get = None  # type: asyncio.Future
+        self.monome = monome_app
+        self._led_buffer = monome.LedBuffer(
+            width=self.size.width, height=self.size.height
+        )
+
+    def shutdown(self):
+        self._queue_get.cancel()
+        self.show_ui = False
+        for layer in self.layers:
+            layer.shutdown()
+        self.monome.led_level_all(LedLevel.OFF.value)
+
+    def submit_input(self, keypress: KeyPress) -> None:
+        self._input_queue.put_nowait(keypress)
+
+    async def get_input(self) -> KeyPress:
+        self._queue_get = self.loop.create_task(self._input_queue.get())
+        keypress = await asyncio.wait_for(
+            self._queue_get, timeout=None, loop=self.loop
+        )
+        self._input_queue.task_done()
+        return keypress
+
+    def _hide_ui(self) -> None:
+        self._show_ui.clear()
+        self.monome.led_level_all(LedLevel.OFF.value)
+
+    def _display_ui(self) -> None:
+        self._show_ui.set()
+        self._led_buffer.render(self.monome)
+
+    @property
+    def show_ui(self) -> bool:
+        return self._show_ui.is_set()
+
+    @show_ui.setter
+    def show_ui(self, value: bool) -> None:
+        self._hide_ui() if value is False else self._display_ui()
+
+    async def wait_ui(self) -> None:
+        await self._show_ui.wait()
+
+    @property
+    def foreground_layer(self):
+        return self.layers[-1] if self.layers else None
+
+    def display(self, canvas: LedCanvas) -> None:
+        for off_x, off_y, level in canvas:
+            self._led_buffer.led_level_set(off_x, off_y, level.value)
+        self._led_buffer.render(self.monome)
+
+
+_serialosc = None
+
+async def start_serialosc_connection(
+    loop: asyncio.AbstractEventLoop, monome_id: str = "*",
+) -> None:
+    global _serialosc, running_event, _not_running_event
+
+    running_event = asyncio.Event(loop=loop)
+    _not_running_event = asyncio.Event(loop=loop)
+    _not_running_event.set()
+    App = functools.partial(AIOSCMonolightApp, loop)
+    _serialosc = await monome.create_serialosc_connection({monome_id: App})
+
+
+async def stop_all() -> None:
+    global running_event, _not_running_event
+
+    if _serialosc is not None:
+        _serialosc.transport.close()
+    # copy the set since we're gonna modify it as we iter through it:
+    for grid in list(running):
+        grid.monome.disconnect()
+    await _not_running_event.wait()
+    running_event = _not_running_event = None
diff --git a/apps/monolight/monolight/monolight.py b/apps/monolight/monolight/monolight.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/monolight.py
@@ -0,0 +1,98 @@
+# 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/>.
+
+import asyncio
+import click
+import logging
+import signal
+import sys
+import pdb
+import traceback
+
+from . import bulbs, grids, ui
+
+
+@click.command()
+@click.option("--serialoscd-host", default="127.0.0.1")
+@click.option("--serialoscd-port", type=click.IntRange(0, 2**16 - 1))
+@click.option("--monome-id", default="*", help="The id of the monome to use")
+@click.option(
+    "--lightsd-url", help="tcp+jsonrpc://host:port or unix+jsonrpc:///a/path"
+)
+def main(
+    serialoscd_host: str,
+    serialoscd_port: int,
+    monome_id: str,
+    lightsd_url: str,
+) -> None:
+    logging.basicConfig(level=logging.INFO)
+    logging.getLogger("monolight.ui").setLevel(logging.DEBUG)
+    logging.getLogger("monolight").setLevel(logging.DEBUG)
+    logging.getLogger("lightsc").setLevel(logging.WARN)
+
+    # NOTE: this isn't good enough on Windows unless you pass --lightsd-url:
+    # discovering lightsd's socket involves using asyncio.subprocess which
+    # requires an IOCP event loop, which doesn't support UDP connections.
+    loop = asyncio.get_event_loop()
+
+    click.echo("connecting to serialoscd and lightsd...")
+
+    try:
+        loop.run_until_complete(asyncio.gather(
+            loop.create_task(bulbs.start_lightsd_connection(loop, lightsd_url)),
+            loop.create_task(grids.start_serialosc_connection(loop, monome_id)),
+            loop=loop,
+        ))
+    except Exception as ex:
+        click.echo(
+            "couldn't connect to lightsd and/or serialoscd, please check that "
+            "they are properly setup."
+        )
+        loop.close()
+        sys.exit(1)
+
+    click.echo("serialoscd running at {}:{}".format(
+        serialoscd_host, serialoscd_port
+    ))
+    click.echo("lightsd running at {}".format(bulbs.lightsd.url))
+
+    click.echo("Starting ui engine...")
+
+    ui_task = ui.start(loop)
+
+    if hasattr(loop, "add_signal_handler"):
+        for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT):
+            loop.add_signal_handler(signum, ui_task.cancel)
+
+    try:
+        loop.run_until_complete(ui_task)
+        click.echo("ui stopped, disconnecting from serialoscd and lightsd...")
+    except asyncio.CancelledError:
+        pass
+    except Exception as ex:
+        tb = "".join(traceback.format_exception(*sys.exc_info()))
+        click.echo(tb, err=True, nl=False)
+        click.echo("ui crashed, disconnecting from serialoscd and lightsd...")
+        pdb.post_mortem()
+        sys.exit(1)
+    finally:
+        loop.run_until_complete(asyncio.gather(
+            loop.create_task(grids.stop_all()),
+            loop.create_task(bulbs.stop_all(loop)),
+            loop=loop,
+        ))
+        loop.close()
diff --git a/apps/monolight/monolight/types.py b/apps/monolight/monolight/types.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/types.py
@@ -0,0 +1,71 @@
+# 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/>.
+
+import itertools
+
+from typing import Iterator
+
+
+class Position:
+
+    def __init__(self, x: int, y: int) -> None:
+        self.x = x
+        self.y = y
+
+    def __copy__(self) -> "Position":
+        return Position(self.x, self.y)
+
+    __deepcopy__ = __copy__
+
+    def __repr__(self) -> str:
+        return "{}, {}".format(self.x, self.y)
+
+    def __sub__(self, other: "Position") -> "Position":
+        return Position(x=self.x - other.x, y=self.y - other.y)
+
+    def __add__(self, other: "Position") -> "Position":
+        return Position(x=self.x + other.x, y=self.y + other.y)
+
+
+class Dimensions:
+
+    def __init__(self, height: int, width: int) -> None:
+        self.height = height
+        self.width = width
+
+    def __repr__(self) -> str:
+        return "height={}, width={}".format(self.height, self.width)
+
+    def __sub__(self, other: "Dimensions") -> "Dimensions":
+        return Dimensions(
+            height=self.height - other.height, width=self.width - other.width
+        )
+
+    def __add__(self, other: "Dimensions") -> "Dimensions":
+        return Dimensions(
+            height=self.height + other.height, width=self.width + other.width
+        )
+
+    @property
+    def area(self) -> int:
+        return self.height * self.width
+
+    def iter_area(self) -> Iterator[Position]:
+        positions = itertools.product(range(self.width), range(self.height))
+        return itertools.starmap(Position, positions)
+
+TimeMonotonic = int
diff --git a/apps/monolight/monolight/ui/__init__.py b/apps/monolight/monolight/ui/__init__.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/__init__.py
@@ -0,0 +1,18 @@
+# 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/>.
+
+from .ui import start  # noqa
diff --git a/apps/monolight/monolight/ui/actions.py b/apps/monolight/monolight/ui/actions.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/actions.py
@@ -0,0 +1,107 @@
+# 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/>.
+
+import asyncio  # noqa
+import lightsc
+import logging
+
+from typing import TYPE_CHECKING, List, Type, Union
+
+from .. import bulbs
+
+if TYPE_CHECKING:
+    from ..elements import UIComponent  # noqa
+
+logger = logging.getLogger("monolight.ui.actions")
+
+
+class Action:
+
+    def __init__(self) -> None:
+        self.loop = None  # type: asyncio.AbstractEventLoop
+        self._source = None  # type: UIComponent
+
+    def set_source(self, source: "UIComponent") -> "Action":
+        self.loop = source.loop
+        self._source = source
+        return self
+
+    async def _run(self) -> None:
+        # NOTE: Must be re-entrant (which means that all attributes on
+        #       self are read-only.
+        pass
+
+    async def execute(self) -> None:
+        self._source.busy = True
+        try:
+            await self._run()
+        finally:
+            self._source.busy = False
+
+
+class Lightsd(Action):
+
+    # XXX:
+    #
+    # This isn't correct, as of now RequestType is just a "factory" that
+    # optionally takes a targets argument or not:
+    RequestType = Type[lightsc.requests.RequestClass]
+    RequestTypeList = List[RequestType]
+
+    def __init__(
+        self, requests: RequestTypeList = None, targets: List[str] = None
+    ) -> None:
+        Action.__init__(self)
+        self._targets = targets or []
+        self._batch = requests or []  # type: Lightsd.RequestTypeList
+
+    def add_target(self, target: str) -> "Lightsd":
+        self._targets.append(target)
+        return self
+
+    def add_request(self, type: RequestType) -> "Lightsd":
+        self._batch.append(type)
+        return self
+
+    async def _run(self) -> None:
+        requests = []
+        async with bulbs.lightsd.batch() as batch:
+            for RequestClass in self._batch:
+                if self._targets:
+                    req = RequestClass(self._targets)
+                else:
+                    req = RequestClass()
+                batch.append(req)
+                requests.append(req.__class__.__name__)
+        for ex in batch.exceptions:
+            logger.warning("Request {} failed on batch-[{}]".format(
+                ", ".join(requests)
+            ))
+
+
+class Slide(Action):
+
+    def __init__(self, step: Union[float, int]) -> None:
+        Action.__init__(self)
+        self.step = step
+        self._step = step
+
+    def set_step(self, step: int) -> None:
+        self._step = step
+
+    async def _run(self) -> None:
+        await self._source.update(self._step)
diff --git a/apps/monolight/monolight/ui/elements/__init__.py b/apps/monolight/monolight/ui/elements/__init__.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/elements/__init__.py
@@ -0,0 +1,33 @@
+# 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/>.
+
+from .base import UILayer  # noqa
+from .buttons import (  # noqa
+    Button,
+    PowerButton,
+)
+from .groups import (  # noqa
+    HSBKControlPad,
+    BulbControlPad,
+)
+from .sliders import (  # noqa
+    BrightnessSlider,
+    HueSlider,
+    KelvinSlider,
+    SaturationSlider,
+    Slider,
+)
diff --git a/apps/monolight/monolight/ui/elements/base.py b/apps/monolight/monolight/ui/elements/base.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/elements/base.py
@@ -0,0 +1,259 @@
+# 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/>.
+
+import asyncio
+import copy
+import enum
+import logging
+import os
+
+from typing import Dict, List
+
+from ... import grids
+from ...types import Dimensions, Position, TimeMonotonic
+
+from .. import actions
+
+logger = logging.getLogger("monolight.ui.elements")
+
+
+class UIComponentInsertionError(Exception):
+    pass
+
+
+UIActionEnum = enum.Enum
+
+
+class UIComponent:
+
+    ACTION_QUEUE_SIZE = 1
+
+    def __init__(
+        self,
+        name: str,
+        offset: Position,
+        size: Dimensions,
+        loop: asyncio.AbstractEventLoop,
+        actions: Dict[UIActionEnum, actions.Action] = None,
+    ) -> None:
+        self.name = name
+        self.size = size
+        self.offset = offset
+        self.loop = loop
+        self.busy = False
+        self.children = set()  # type: Set[UIComponent]
+        self.actions = actions if actions is not None else {}
+        for action in self.actions.values():
+            action.set_source(self)
+        self.parent = None  # type: UIComponent
+
+        if loop is not None:
+            qsize = self.ACTION_QUEUE_SIZE
+            self._action_queue = asyncio.Queue(qsize)  # type: asyncio.Queue
+            self._action_runner = loop.create_task(self._process_actions())
+            self._action_queue_get = None  # type: asyncio.Future
+            self._current_action = None  # type: UIActionEnum
+
+        self._nw_corner = offset - Position(1, 1)
+        self._se_corner = Position(
+            x=self.offset.x + self.size.width - 1,
+            y=self.offset.y + self.size.height - 1,
+        )
+
+    def __repr__(self, indent=None):
+        if self.name:
+            return "<{}(\"{}\", size=({!r}), offset=({!r})>".format(
+                self.__class__.__name__, self.name, self.size, self.offset
+            )
+        return "<{}(size=({!r}), offset=({!r})>".format(
+            self.__class__.__name__, self.size, self.offset
+        )
+
+    def shutdown(self) -> None:
+        for children in self.children:
+            children.shutdown()
+        self._action_runner.cancel()
+        if self._action_queue_get is not None:
+            self._action_queue_get.cancel()
+
+    def collides(self, other: "UIComponent") -> bool:
+        """Return True if ``self`` and ``other`` overlap in any way.
+
+        .. important::
+
+           ``self`` and ``other`` must be in the same container otherwise
+           the result is undefined.
+        """
+
+        return all((
+            self._nw_corner.x < other._se_corner.x,
+            self._se_corner.x > other._nw_corner.x,
+            self._nw_corner.y < other._se_corner.y,
+            self._se_corner.y > other._nw_corner.y,
+        ))
+
+    async def _process_actions(self) -> None:
+        current_action = None
+        next_action = None
+        while True:
+            tasks = []
+            if next_action is None:
+                if current_action is None:
+                    next_action = (await self._action_queue.get()).execute()
+                    current_action = self.loop.create_task(next_action)
+                    next_action = None
+                tasks.append(current_action)
+                self._action_queue_get = next_action = self.loop.create_task(
+                    self._action_queue.get()
+                )
+            tasks.append(next_action)
+
+            done, pending = await asyncio.wait(
+                tasks, return_when=asyncio.FIRST_COMPLETED, loop=self.loop,
+            )
+
+            if current_action in done:
+                self._action_queue.task_done()
+                # always retrieve the result, we might have an error to raise:
+                current_action.result()
+                current_action = None
+            if next_action in done:
+                next_action = next_action.result()
+                if current_action is None:
+                    current_action = self.loop.create_task(
+                        next_action.execute()
+                    )
+                self._action_queue_get = next_action = None
+
+    def draw(self, frame_ts_ms: TimeMonotonic, canvas: grids.LedCanvas) -> bool:
+        raise NotImplementedError
+
+    def handle_input(self, offset: Position, value: grids.KeyState) -> None:
+        raise NotImplementedError
+
+
+class _UIPosition(UIComponent):
+
+    def __init__(self, position: Position) -> None:
+        UIComponent.__init__(
+            self, "_ui_position", position, Dimensions(1, 1), loop=None
+        )
+
+
+class _UIContainer(UIComponent):
+
+    def __init__(
+        self,
+        name: str,
+        offset: Position,
+        size: Dimensions,
+        loop: asyncio.AbstractEventLoop
+    ) -> None:
+        UIComponent.__init__(self, name, offset, size, loop)
+
+    def __repr__(self, indent=1) -> str:
+        linesep = ",{}{}".format(os.linesep, "  " * indent)
+        return (
+            "<{}(\"{}\", size=({!r}), offset=({!r}), "
+            "components=[{nl}  {indent}{}{nl}{indent}])>".format(
+                self.__class__.__name__,
+                self.name,
+                self.size,
+                self.offset,
+                linesep.join(
+                    component.__repr__(indent + 1)
+                    for component in self.children
+                ),
+                indent="  " * (indent - 1),
+                nl=os.linesep,
+            )
+        )
+
+    def fits(self, other: "UIComponent") -> bool:
+        """Return True if ``self`` has enough space to contain ``other``."""
+
+        return (
+            other._se_corner.x < self.size.width and
+            other._se_corner.y < self.size.height
+        )
+
+    def insert(self, new: "UIComponent") -> None:
+        if new in self.children:
+            raise UIComponentInsertionError(
+                "{!r} is already part of {!r}".format(new, self)
+            )
+        if not self.fits(new):
+            raise UIComponentInsertionError(
+                "{!r} doesn't fit into {!r}".format(new, self)
+            )
+        for child in self.children:
+            if child.collides(new):
+                raise UIComponentInsertionError(
+                    "{!r} conflicts with {!r}".format(new, child)
+                )
+
+        new.parent = self
+        self.children.add(new)
+
+    def submit_input(self, offset: Position, value: grids.KeyState) -> bool:
+        if self.collides(_UIPosition(offset)):
+            self.handle_input(offset - self.offset, value)
+            return True
+
+        return False
+
+    def handle_input(self, offset: Position, value: grids.KeyState) -> None:
+        for component in self.children:
+            if component.collides(_UIPosition(offset)):
+                component.handle_input(offset - component.offset, value)
+
+    def draw(self, frame_ts_ms: TimeMonotonic, canvas: grids.LedCanvas) -> bool:
+        dirty = False
+        for component in self.children:
+            vec = copy.copy(self.offset)
+            if not isinstance(component, _UIContainer):
+                vec += component.offset
+            shifted_canvas = canvas.shift(vec)
+            dirty = component.draw(frame_ts_ms, shifted_canvas) or dirty
+        return dirty
+
+
+class UIGroup(_UIContainer):
+
+    def __init__(
+        self,
+        name: str,
+        offset: Position,
+        size: Dimensions,
+        loop: asyncio.AbstractEventLoop,
+        members: List[UIComponent],
+    ) -> None:
+        UIComponent.__init__(self, name, offset, size, loop)
+        for member in members:
+            self.insert(member)
+
+
+class UILayer(_UIContainer):
+
+    def __init__(
+        self, name: str, size: Dimensions, loop: asyncio.AbstractEventLoop
+    ) -> None:
+        _UIContainer.__init__(self, name, Position(0, 0), size, loop)
+        self.canvas = grids.LedCanvas(self.size, grids.LedLevel.OFF)
+
+    def render(self, frame_ts_ms: TimeMonotonic) -> bool:
+        return self.draw(frame_ts_ms, self.canvas)
diff --git a/apps/monolight/monolight/ui/elements/buttons.py b/apps/monolight/monolight/ui/elements/buttons.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/elements/buttons.py
@@ -0,0 +1,111 @@
+# 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/>.
+
+import asyncio
+
+from lightsc import requests
+from typing import Dict, List
+
+from ... import bulbs, grids
+from ...types import Dimensions, Position, TimeMonotonic
+
+from .. import actions
+
+from .base import UIActionEnum, UIComponent, logger
+
+
+class Button(UIComponent):
+
+    class ActionEnum(UIActionEnum):
+
+        DOWN = 1
+        UP = 0
+
+    # make the size configurable too?
+    def __init__(
+        self,
+        name: str,
+        offset: Position,
+        loop: asyncio.AbstractEventLoop,
+        actions: Dict[UIActionEnum, actions.Action],
+    ) -> None:
+        size = Dimensions(1, 1)
+        UIComponent.__init__(self, name, offset, size, loop, actions)
+        self._last_level = None  # type: grids.LedLevel
+
+    def draw(self, frame_ts_ms: TimeMonotonic, canvas: grids.LedCanvas) -> bool:
+        animate_busy = self.busy and frame_ts_ms % 1000 // 100 % 2
+        level = grids.LedLevel.MEDIUM if animate_busy else grids.LedLevel.ON
+
+        if level == self._last_level:
+            return False
+
+        self._last_level = level
+        for offset in self.size.iter_area():
+            canvas.set(offset, level)
+
+        return True
+
+    def handle_input(self, offset: Position, value: grids.KeyState) -> None:
+        if value is grids.KeyState.DOWN:
+            logger.info("Button {} pressed".format(self.name))
+            action = self.actions.get(Button.ActionEnum.DOWN)
+        else:
+            logger.info("Button {} depressed".format(self.name))
+            action = self.actions.get(Button.ActionEnum.UP)
+
+        if action is None:
+            return
+
+        try:
+            self._action_queue.put_nowait(action)
+        except asyncio.QueueFull:
+            logger.warning("{!r}: action queue full".format(self))
+
+
+class PowerButton(Button):
+
+    def __init__(
+        self,
+        name: str,
+        offset: Position,
+        loop: asyncio.AbstractEventLoop,
+        targets: List[str],
+    ) -> None:
+        Button.__init__(self, name, offset, loop, actions={
+            Button.ActionEnum.UP: actions.Lightsd(
+                requests=[requests.PowerToggle], targets=targets
+            )
+        })
+        self.targets = targets
+
+    def draw(self, frame_ts_ms: TimeMonotonic, canvas: grids.LedCanvas) -> bool:
+        if self.busy and frame_ts_ms % 1000 // 100 % 2:
+            level = grids.LedLevel.MEDIUM
+        elif any(bulb.power for bulb in bulbs.iter_targets(self.targets)):
+            level = grids.LedLevel.ON
+        else:
+            level = grids.LedLevel.VERY_LOW_3
+
+        if level == self._last_level:
+            return False
+
+        self._last_level = level
+        for offset in self.size.iter_area():
+            canvas.set(offset, level)
+
+        return True
diff --git a/apps/monolight/monolight/ui/elements/groups.py b/apps/monolight/monolight/ui/elements/groups.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/elements/groups.py
@@ -0,0 +1,77 @@
+# 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/>.
+
+import asyncio
+import functools
+
+from typing import List
+
+from ...types import Dimensions, Position
+
+from .base import UIGroup
+from .buttons import PowerButton
+from .sliders import BrightnessSlider, HueSlider, KelvinSlider, SaturationSlider
+
+
+class HSBKControlPad(UIGroup):
+
+    def __init__(
+        self,
+        name: str,
+        offset: Position,
+        sliders_size: Dimensions,
+        loop: asyncio.AbstractEventLoop,
+        targets: List[str],
+    ) -> None:
+        sliders = [
+            functools.partial(HueSlider, name="hue"),
+            functools.partial(SaturationSlider, name="saturation"),
+            functools.partial(BrightnessSlider, name="brightness"),
+            functools.partial(KelvinSlider, name="temperature"),
+        ]
+        sliders = [
+            Slider(
+                offset=Position(i, 0),
+                size=sliders_size,
+                loop=loop,
+                targets=targets,
+            )
+            for i, Slider in enumerate(sliders)
+        ]
+        group_size = Dimensions(width=len(sliders), height=sliders_size.height)
+        UIGroup.__init__(self, name, offset, group_size, loop, sliders)
+
+
+class BulbControlPad(UIGroup):
+
+    def __init__(
+        self,
+        name: str,
+        offset: Position,
+        loop: asyncio.AbstractEventLoop,
+        targets: List[str],
+        sliders_size: Dimensions,
+    ) -> None:
+        power_btn = PowerButton("toggle power", Position(0, 0), loop, targets)
+        hsbk_pad = HSBKControlPad(
+            "hsbk pad", Position(0, 1), sliders_size, loop, targets
+        )
+
+        group_size = Dimensions(width=0, height=1) + hsbk_pad.size
+        UIGroup.__init__(self, name, offset, group_size, loop, [
+            power_btn, hsbk_pad,
+        ])
diff --git a/apps/monolight/monolight/ui/elements/sliders.py b/apps/monolight/monolight/ui/elements/sliders.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/elements/sliders.py
@@ -0,0 +1,272 @@
+# 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/>.
+
+import asyncio
+import functools
+import lightsc
+import operator
+import statistics
+import time
+
+from typing import Any, Callable, Iterable, List, NamedTuple, TypeVar
+
+from ... import bulbs, grids
+from ...types import Dimensions, Position, TimeMonotonic
+
+from .. import actions
+
+from .base import UIActionEnum, UIComponent, logger
+
+
+class SliderTraits:
+    """Configure the SliderBase class.
+
+    :param gather_fn: a function returning the data observed on the
+                      targets associated with this slider.
+    :param consolidation_fn: a function returning the current value of
+                             this slider using the data returned by gather_fn.
+    :param scatter_fn: an async function that can apply the value of the slider
+                       to the targets it tracks.
+    """
+
+    Controls = NamedTuple(
+        "Controls", [("coarse", float), ("fine", float)]
+    )
+
+    def __init__(
+        self,
+        range: range,
+        controls: Controls,
+        gather_fn: Callable[[List[str]], List[Any]],
+        consolidate_fn: Callable[[List[Any]], float],
+        scatter_fn: Callable[[List[str], Any, int], None],
+    ) -> None:
+        self.RANGE = range
+        self.controls = controls
+        self.gather = gather_fn
+        self.consolidate = consolidate_fn
+        self.scatter = scatter_fn
+
+
+class Slider(UIComponent):
+    """Base slider implementation.
+
+    :param size: the size of the slider.
+    :param offset: position of the slider in within its parent component.
+    :param targets: the list of targets this slider is tracking.
+
+    .. note:: Only vertical sliders of width 1 are currently supported.
+    """
+
+    class ActionEnum(UIActionEnum):
+
+        COARSE_INC = 0
+        FINE_INC = 1
+        FINE_DEC = 2
+        COARSE_DEC = 3
+
+    def __init__(
+        self,
+        name: str,
+        offset: Position,
+        size: Dimensions,
+        loop: asyncio.AbstractEventLoop,
+        targets: List[str],
+        traits: SliderTraits,
+    ) -> None:
+        controls = traits.controls
+        UIComponent.__init__(self, name, offset, size, loop, {
+            Slider.ActionEnum.COARSE_INC: actions.Slide(controls.coarse),
+            Slider.ActionEnum.FINE_INC: actions.Slide(controls.fine),
+            Slider.ActionEnum.FINE_DEC: actions.Slide(-controls.fine),
+            Slider.ActionEnum.COARSE_DEC: actions.Slide(-controls.coarse),
+        })
+        self.value = float(traits.RANGE.start)
+        self.targets = targets
+        self.traits = traits
+
+        self._action_map = {
+            (0, size.height - 1): Slider.ActionEnum.COARSE_DEC,
+            (0, size.height - 2): Slider.ActionEnum.FINE_DEC,
+            (0, 0): Slider.ActionEnum.COARSE_INC,
+            (0, 1): Slider.ActionEnum.FINE_INC,
+        }
+        self._steps = size.area * (grids.LedLevel.ON + 1)
+        self._steps_per_button = self._steps / size.height
+
+    def handle_input(self, offset: Position, value: grids.KeyState) -> None:
+        logger.info("Slider {} pressed at {}".format(self.name, offset))
+        if value is not grids.KeyState.UP:
+            return
+
+        action = self.actions.get(self._action_map.get((offset.x, offset.y)))
+        logger.info("Slider {} action = {}".format(
+            self.name, self._action_map.get((offset.x, offset.y))
+        ))
+
+        if action is None:
+            return
+
+        try:
+            self._action_queue.put_nowait(action)
+        except asyncio.QueueFull:
+            logger.warning("Slider {} action queue full".format(self.name))
+
+    def draw(self, frame_ts_ms: TimeMonotonic, canvas: grids.LedCanvas) -> bool:
+        new_value = self.traits.consolidate(self.traits.gather(self.targets))
+        if new_value is None or new_value == self.value:
+            return False
+
+        step = (new_value - self.traits.RANGE.start) / (
+            (self.traits.RANGE.stop - self.traits.RANGE.start)
+            / self._steps
+        )
+        logger.info(
+            "Slider {} updated from {}/{max} to {}/{max} ({:.4}/{})".format(
+                self.name, self.value, new_value, step, self._steps,
+                max=self.traits.RANGE.stop,
+            )
+        )
+        self.value = new_value
+
+        on_range = range(
+            self.size.height - 1,
+            self.size.height - 1 - int(step // self._steps_per_button),
+            -1
+        )
+        y = on_range.start
+        for each in on_range:
+            canvas.set(Position(0, y), grids.LedLevel.ON)
+            y -= 1
+        if y >= 0:
+            level = grids.LedLevel(int(step % self._steps_per_button))
+            canvas.set(Position(0, y), level)
+            y -= 1
+        for y in range(y, -1, -1):
+            canvas.set(Position(0, y), grids.LedLevel.OFF)
+
+        return True
+
+    async def update(self, change: int, transition_ms: int = 600) -> None:
+        if change == 0:
+            return
+
+        # min/max could eventually be traits.overflow and traits.underflow
+        if change > 0:
+            new_value = min(self.value + change, float(self.traits.RANGE.stop))
+        else:
+            new_value = max(self.value + change, float(self.traits.RANGE.start))
+
+        logger.info("Slider {} moving to {}".format(self.name, new_value))
+
+        scatter_starts_at = time.monotonic()
+        await self.traits.scatter(self.targets, new_value, transition_ms)
+        scatter_exec_time = time.monotonic() - scatter_starts_at
+
+        transition_remaining = transition_ms / 1000 - scatter_exec_time
+        if transition_remaining > 0:
+            await asyncio.sleep(transition_remaining)
+
+T = TypeVar("T")
+
+
+def _gather_fn(
+    getter: Callable[[lightsc.structs.LightBulb], T], targets: List[str]
+) -> Iterable[T]:
+    return map(getter, bulbs.iter_targets(targets))
+
+gather_hue = functools.partial(_gather_fn, operator.attrgetter("h"))
+gather_saturation = functools.partial(_gather_fn, operator.attrgetter("s"))
+gather_brightness = functools.partial(_gather_fn, operator.attrgetter("b"))
+gather_temperature = functools.partial(_gather_fn, operator.attrgetter("k"))
+
+
+def mean_or_none(data: Iterable[T]) -> T:
+    try:
+        return statistics.mean(data)
+    except statistics.StatisticsError:  # no data
+        return None
+
+
+# NOTE:
+#
+# This will become easier once lightsd supports updating one parameter
+# independently from the others:
+async def _scatter_fn(
+    setter: Callable[
+        [lightsc.structs.LightBulb, T, int],
+        lightsc.requests.Request
+    ],
+    targets: List[str],
+    value: T,
+    transition_ms: int,
+) -> None:
+    async with bulbs.lightsd.batch() as batch:
+        for target in bulbs.iter_targets(targets):
+            batch.append(setter(target, value, transition_ms))
+
+scatter_hue = functools.partial(
+    _scatter_fn, lambda b, h, t: lightsc.requests.SetLightFromHSBK(
+        [b.label], h, b.s, b.b, b.k, transition_ms=t,
+    )
+)
+scatter_saturation = functools.partial(
+    _scatter_fn, lambda b, s, t: lightsc.requests.SetLightFromHSBK(
+        [b.label], b.h, s, b.b, b.k, transition_ms=t,
+    )
+)
+scatter_brightness = functools.partial(
+    _scatter_fn, lambda b, br, t: lightsc.requests.SetLightFromHSBK(
+        [b.label], b.h, b.s, br, b.k, transition_ms=t,
+    )
+)
+scatter_temperature = functools.partial(
+    _scatter_fn, lambda b, k, t: lightsc.requests.SetLightFromHSBK(
+        [b.label], b.h, b.s, b.b, int(k), transition_ms=t,
+    )
+)
+
+HueSlider = functools.partial(Slider, traits=SliderTraits(
+    range=lightsc.constants.HUE_RANGE,
+    controls=SliderTraits.Controls(
+        coarse=lightsc.constants.HUE_RANGE.stop // 20, fine=1
+    ),
+    gather_fn=gather_hue,
+    consolidate_fn=mean_or_none,
+    scatter_fn=scatter_hue,
+))
+SaturationSlider = functools.partial(Slider, traits=SliderTraits(
+    range=lightsc.constants.SATURATION_RANGE,
+    controls=SliderTraits.Controls(coarse=0.1, fine=0.01),
+    gather_fn=gather_saturation,
+    consolidate_fn=mean_or_none,
+    scatter_fn=scatter_saturation,
+))
+BrightnessSlider = functools.partial(Slider, traits=SliderTraits(
+    range=lightsc.constants.BRIGHTNESS_RANGE,
+    controls=SliderTraits.Controls(coarse=0.1, fine=0.01),
+    gather_fn=gather_brightness,
+    consolidate_fn=mean_or_none,
+    scatter_fn=scatter_brightness,
+))
+KelvinSlider = functools.partial(Slider, traits=SliderTraits(
+    range=lightsc.constants.KELVIN_RANGE,
+    controls=SliderTraits.Controls(coarse=500, fine=100),
+    gather_fn=gather_temperature,
+    consolidate_fn=mean_or_none,
+    scatter_fn=scatter_temperature,
+))
diff --git a/apps/monolight/monolight/ui/layers.py b/apps/monolight/monolight/ui/layers.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/layers.py
@@ -0,0 +1,117 @@
+# 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/>.
+
+import asyncio
+import functools
+import logging
+
+from lightsc import requests
+
+from .. import grids
+from ..types import Dimensions, Position
+
+from . import actions
+from .elements import (
+    Button,
+    PowerButton,
+    UILayer,
+    groups,
+)
+
+logger = logging.getLogger("monolight.ui.layers")
+
+
+class _ToggleUI(actions.Action):
+
+    def __init__(self, grid: grids.MonomeGrid) -> None:
+        actions.Action.__init__(self)
+        self._grid = grid
+
+    async def _run(self) -> None:
+        self._grid.show_ui = not self._grid.show_ui
+
+
+def root(loop: asyncio.AbstractEventLoop, grid: grids.MonomeGrid) -> UILayer:
+    foreground_layer = UILayer("root", grid.size, loop)
+
+    foreground_layer.insert(Button("toggle ui", Position(15, 7), loop, actions={
+        Button.ActionEnum.UP: _ToggleUI(grid),
+    }))
+
+    # some shortcuts:
+    foreground_layer.insert(Button("off *", Position(0, 7), loop, actions={
+        Button.ActionEnum.UP: actions.Lightsd(
+            requests=[requests.PowerOff], targets=["*"]
+        )
+    }))
+    foreground_layer.insert(Button("on *", Position(1, 7), loop, actions={
+        Button.ActionEnum.UP: actions.Lightsd(
+            requests=[requests.PowerOn], targets=["*"]
+        )
+    }))
+    foreground_layer.insert(
+        Button("4000k *", Position(2, 7), loop, actions={
+            Button.ActionEnum.UP: actions.Lightsd(requests=[
+                functools.partial(
+                    requests.SetLightFromHSBK,
+                    ["*"], 0.0, 0.0, 1.0, 4000, 1000,
+                ),
+                functools.partial(requests.PowerOn, ["*"]),
+            ])
+        })
+    )
+    foreground_layer.insert(Button("blue", Position(3, 7), loop, actions={
+        Button.ActionEnum.UP: actions.Lightsd(requests=[
+            functools.partial(
+                requests.SetLightFromHSBK,
+                ["*"], 218, 1.0, 1.0, 3500, 600,
+            ),
+            functools.partial(requests.PowerOn, ["*"])
+        ]),
+    }))
+    foreground_layer.insert(
+        Button("pink breathe *", Position(4, 7), loop, actions={
+            Button.ActionEnum.UP: actions.Lightsd(requests=[
+                functools.partial(
+                    requests.SetWaveform,
+                    ["*"], "TRIANGLE", 285, 0.3, 0.8, 3500, 20000, 2, 0.5,
+                ),
+                functools.partial(requests.PowerOn, ["*"])
+            ]),
+        })
+    )
+    foreground_layer.insert(Button("alert *", Position(5, 7), loop, actions={
+        Button.ActionEnum.UP: actions.Lightsd(requests=[
+            functools.partial(
+                requests.SetWaveform,
+                ["*"], "SQUARE", 0, 1.0, 1, 3500, 500, 5, 0.5
+            ),
+            functools.partial(requests.PowerOn, ["*"]),
+        ]),
+    }))
+
+    # example control blocks:
+    BulbControlPad = functools.partial(
+        groups.BulbControlPad,
+        loop=loop,
+        sliders_size=Dimensions(width=1, height=6),
+    )
+    foreground_layer.insert(BulbControlPad(
+        "* control", Position(0, 0), targets=["*"],
+    ))
+
+    return foreground_layer
diff --git a/apps/monolight/monolight/ui/ui.py b/apps/monolight/monolight/ui/ui.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/ui.py
@@ -0,0 +1,101 @@
+# 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/>.
+
+import asyncio
+import logging
+import time
+
+from typing import Tuple  # noqa
+
+from .. import grids
+
+from . import layers
+
+DEFAULT_FRAMERATE = 40
+
+logger = logging.getLogger("monolight.ui")
+
+
+async def _ui_refresh(loop: asyncio.AbstractEventLoop, framerate: int) -> None:
+    while True:
+        if not grids.running_event.is_set():
+            await grids.running_event.wait()
+
+        if not any(grid.show_ui for grid in grids.running):
+            # TODO: handle clean-up when we get ^C while in there:
+            await asyncio.wait(
+                [grid.wait_ui() for grid in grids.running],
+                return_when=asyncio.FIRST_COMPLETED,
+                loop=loop
+            )
+
+        render_starts_at = time.monotonic()
+
+        for grid in grids.running:
+            if not grid.show_ui:
+                continue
+
+            layer = grid.foreground_layer
+            if layer is None:
+                layer = layers.root(loop, grid)
+                grid.layers.insert(0, layer)
+                logger.info("UI initialized on grid {}: {!r}".format(
+                    grid.monome.id, layer
+                ))
+
+            if layer.render(frame_ts_ms=int(time.monotonic() * 1000)):
+                logger.info("Refreshing UI on grid {}".format(grid.monome.id))
+                grid.display(layer.canvas)
+
+        render_latency = time.monotonic() - render_starts_at
+        # The plan is to have lightsd push updates and then make
+        # something smarter than this:
+        await asyncio.sleep(1000 / framerate / 1000 - render_latency, loop=loop)
+
+
+async def _process_inputs(loop: asyncio.AbstractEventLoop) -> None:
+    while True:
+        if not grids.running_event.is_set():
+            await grids.running_event.wait()
+
+        done, pending = await asyncio.wait(
+            [grid.get_input() for grid in grids.running],
+            return_when=asyncio.FIRST_COMPLETED,
+            loop=loop,
+        )  # type: Tuple[Set[asyncio.Future], Set[asyncio.Future]]
+        keypresses = []
+        for future in done:
+            try:
+                keypresses.append(future.result())
+            except asyncio.CancelledError:
+                continue
+        for grid, position, value in keypresses:
+            logger.info("Keypress {} on grid {} at {}".format(
+                value, grid.monome.id, position
+            ))
+            if grid.foreground_layer is not None:
+                grid.foreground_layer.submit_input(position, value)
+
+
+def start(
+    loop: asyncio.AbstractEventLoop, framerate: int = DEFAULT_FRAMERATE
+) -> asyncio.Future:
+    return asyncio.gather(
+        loop.create_task(_ui_refresh(loop, framerate)),
+        loop.create_task(_process_inputs(loop)),
+        loop=loop,
+    )
diff --git a/apps/monolight/setup.py b/apps/monolight/setup.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/setup.py
@@ -0,0 +1,54 @@
+# Copyright (c) 2016, Louis Opter <louis@opter.org>
+#
+# This file is part of lighstd.
+#
+# lighstd 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.
+#
+# lighstd 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 lighstd.  If not, see <http://www.gnu.org/licenses/>.
+
+import setuptools
+
+version = "0.0.1.dev0"
+
+setuptools.setup(
+    name="monolight",
+    version=version,
+    description="A Monome UI to control smart bulbs using lightsd",
+    author="Louis Opter",
+    author_email="louis@opter.org",
+    packages=setuptools.find_packages(exclude=['tests', 'tests.*']),
+    include_package_data=True,
+    entry_points={
+        "console_scripts": [
+            "monolight = monolight.monolight:main",
+        ],
+    },
+    install_requires=[
+        "click~=6.6",
+        "pymonome~=0.8.2",
+    ],
+    tests_require=[
+        "doubles~=1.1.3",
+        "freezegun~=0.3.5",
+        "pytest~=3.0",
+    ],
+    extras_require={
+        "dev": [
+            "flake8",
+            "mypy-lang",
+            "ipython",
+            "pdbpp",
+            "pep8",
+            "typed-ast",
+        ],
+    },
+)
diff --git a/clients/python/lightsc/README.rst b/clients/python/lightsc/README.rst
new file mode 100644
--- /dev/null
+++ b/clients/python/lightsc/README.rst
@@ -0,0 +1,63 @@
+A Python client to control your smart bulbs through lightsd
+===========================================================
+
+lightsd_ is a daemon (background service) to control your LIFX_ WiFi "smart"
+bulbs. This package allows you to make RPC calls to lightsd to control your
+light bulbs from Python. It is built on top of the ``asyncio`` module and
+requires Python ≥ 3.5:
+
+.. code-block:: python
+
+   import asyncio
+   import click
+
+   from lightsc import LightsView, create_async_lightsd_connection
+   from lightsc.requests import (
+       GetLightState,
+       PowerOff,
+       PowerOn,
+       SetLightFromHSBK,
+    )
+
+   async def example(url, targets):
+       async with create_async_lightsd_connection(url) as client:
+           click.echo("Connected to lightsd running at {}".format(client.url))
+
+           view = LightsView()
+           view.update(await client.apply(GetLightState(targets))
+           click.echo("Discovered bulbs: {}".format(view))
+
+           transition_ms = 600
+           red_hsbk = (0., 1., 1., 3500)
+           click.echo("Turning all bulbs to red in {}ms...".format(transition_ms))
+           async with client.batch() as batch:
+               batch.append(PowerOn(targets))
+               batch.append(SetLightFromHSBK(targets, *red_hsbk, transition_ms=transition_ms))
+
+           click.echo("Restoring original state")
+           async with client.batch() as batch:
+               for b in view.bulbs:
+                   PowerState = PowerOn if b.power else PowerOff
+                   hsbk = (b.h, b.s, b.b, b.k)
+
+                   batch.append(PowerState([b.label]))
+                   batch.append(SetLightFromHSBK([b.label], *hsbk, transition_ms=transition_ms))
+
+   @click.command()
+   @click.option("--lightsd-url", help="supported schemes: tcp+jsonrpc://, unix+jsonrpc://")
+   @click.argument("bulb_targets", nargs=-1, required=True)
+   def main(lightsd_url, bulb_targets)
+       """This example will turn all your bulbs to red before restoring their
+       original state.
+
+       If an URL is not provided this script will attempt to connect to
+       lightsd's UNIX socket.
+       """
+
+       evl = asyncio.get_event_loop()
+       evl.run_until_complete(evl.create_task(example(lightsd_url, bulb_targets)))
+
+.. _lightsd: https://www.lightsd.io/
+.. _LIFX: http://lifx.co/
+
+.. vim: set tw=80 spelllang=en spell:
diff --git a/clients/python/lightsc/lightsc/__init__.py b/clients/python/lightsc/lightsc/__init__.py
new file mode 100644
--- /dev/null
+++ b/clients/python/lightsc/lightsc/__init__.py
@@ -0,0 +1,43 @@
+# Copyright (c) 2016, Louis Opter <louis@opter.org>
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+#    this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its contributors
+#    may be used to endorse or promote products derived from this software
+#    without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+from . import (  # noqa
+    client,
+    constants,
+    exceptions,
+    requests,
+    responses,
+    structs,
+)
+from .client import (  # noqa
+    LightsClient,
+    LightsCommandBatch,
+    create_lightsd_connection,
+    create_async_lightsd_connection,
+)
diff --git a/clients/python/lightsc/lightsc/client.py b/clients/python/lightsc/lightsc/client.py
new file mode 100644
--- /dev/null
+++ b/clients/python/lightsc/lightsc/client.py
@@ -0,0 +1,401 @@
+# Copyright (c) 2016, Louis Opter <louis@opter.org>
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+#    this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its contributors
+#    may be used to endorse or promote products derived from this software
+#    without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+import asyncio
+import functools
+import json
+import locale
+import logging
+import os
+import urllib
+import uuid
+
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    List,
+    NamedTuple,
+    Sequence,
+    Tuple,
+)
+from typing import Type  # noqa
+
+from . import (
+    exceptions,
+    requests,
+    responses,
+    structs,
+)
+
+logger = logging.getLogger("lightsc.client")
+
+
+_JSONRPCMethod = NamedTuple("_JSONRPCMethod", [
+    ("name", str),
+    ("map_result", Callable[[Any], responses.Response]),
+])
+_JSONRPC_API = {
+    requests.GetLightState: _JSONRPCMethod(
+        name="get_light_state",
+        map_result=lambda result: responses.LightsState([
+            structs.LightBulb(
+                b["label"], b["power"], *b["hsbk"], tags=b["tags"]
+            ) for b in result
+        ])
+    ),
+    requests.SetLightFromHSBK: _JSONRPCMethod(
+        name="set_light_from_hsbk",
+        map_result=lambda result: responses.Bool(result)
+    ),
+    requests.PowerOn: _JSONRPCMethod(
+        name="power_on",
+        map_result=lambda result: responses.Bool(result)
+    ),
+    requests.PowerOff: _JSONRPCMethod(
+        name="power_off",
+        map_result=lambda result: responses.Bool(result)
+    ),
+    requests.PowerToggle: _JSONRPCMethod(
+        name="power_toggle",
+        map_result=lambda result: responses.Bool(result)
+    ),
+    requests.SetWaveform: _JSONRPCMethod(
+        name="set_waveform",
+        map_result=lambda result: responses.Bool(result)
+    ),
+}  # type: Dict[Type[requests.RequestClass], _JSONRPCMethod]
+
+
+class _JSONRPCCall:
+
+    def __init__(
+        self, method: str, params: Sequence[Any], timeout: int = None
+    ) -> None:
+        self.id = str(uuid.uuid4())
+        self.method = method
+        self.params = params
+        self.timeout = timeout
+        self.timeout_handle = None  # type: asyncio.Handle
+        self.request = {
+            "id": self.id,
+            "jsonrpc": "2.0",
+            "method": method,
+            "params": params,
+        }
+        self.response = asyncio.Future()  # type: asyncio.futures.Future
+
+    @property
+    def response_or_exception(self) -> Any:
+        ex = self.response.exception()
+        return ex if ex is not None else self.response.result()
+
+
+class AsyncJSONRPCLightsClient:
+
+    READ_SIZE = 8192
+    TIMEOUT = 2  # seconds
+    ENCODING = "utf-8"
+
+    def __init__(
+        self,
+        url: str,
+        encoding: str = ENCODING,
+        timeout: int = TIMEOUT,
+        read_size: int = READ_SIZE,
+        loop: asyncio.AbstractEventLoop = None
+    ) -> None:
+        self.url = url
+        self.encoding = encoding
+        self.timeout = timeout
+        self.read_size = read_size
+        self._listen_task = None  # type: asyncio.Task
+        self._pending_calls = {}  # type: Dict[str, _JSONRPCCall]
+        self._reader = None  # type: asyncio.StreamReader
+        self._writer = None  # type: asyncio.StreamWriter
+        self._loop = loop or asyncio.get_event_loop()
+
+    def _handle_response(
+        self, id: str, response: Any, timeout: bool = False
+    ) -> None:
+        call = self._pending_calls.pop(id)
+        if timeout is True:
+            call.response.set_exception(exceptions.LightsClientTimeoutError())
+            return
+        call.timeout_handle.cancel()
+        call.response.set_result(response)
+
+    async def _jsonrpc_execute(
+        self, pipeline: List[_JSONRPCCall]
+    ) -> Dict[str, Any]:
+        if not pipeline:
+            return {}
+
+        requests = [call.request for call in pipeline]
+        for req in requests:
+            logger.info("Request {id}: {method}({params})".format(**req))
+
+        payload = json.dumps(requests[0] if len(requests) == 1 else requests)
+        self._writer.write(payload.encode(self.encoding, "surrogateescape"))
+
+        await self._writer.drain()
+
+        for call in pipeline:
+            call.timeout_handle = self._loop.call_later(
+                call.timeout,
+                functools.partial(
+                    self._handle_response, call.id, response=None, timeout=True
+                )
+            )
+            self._pending_calls[call.id] = call
+
+        futures = [call.response for call in pipeline]
+        await asyncio.wait(futures, loop=self._loop)
+        return {call.id: call.response_or_exception for call in pipeline}
+
+    async def close(self) -> None:
+        if self._listen_task is not None:
+            self._listen_task.cancel()
+            await asyncio.wait([self._listen_task], loop=self._loop)
+            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
+
+        self._pending_calls = {}
+
+    async def _reconnect(self) -> None:
+        await self.close()
+        await self.connect()
+
+    async def apply(self, req: requests.Request, timeout: int = TIMEOUT):
+        method = _JSONRPC_API[req.__class__]
+        call = _JSONRPCCall(method.name, req.params, timeout=timeout)
+        result = (await self._jsonrpc_execute([call]))[call.id]
+        if isinstance(result, Exception):
+            raise result
+        return method.map_result(result)
+
+    async def connect(self) -> None:
+        parts = urllib.parse.urlparse(self.url)
+        if parts.scheme == "unix+jsonrpc":
+            path = os.path.join(parts.netloc, parts.path).rstrip(os.path.sep)
+            open_connection = functools.partial(
+                asyncio.open_unix_connection, path
+            )
+        elif parts.scheme == "tcp+jsonrpc":
+            open_connection = functools.partial(
+                asyncio.open_connection, parts.hostname, parts.port
+            )
+        else:
+            raise ValueError("Unsupported url {}".format(self.url))
+
+        try:
+            self._reader, self._writer = await asyncio.wait_for(
+                open_connection(limit=self.read_size, loop=self._loop),
+                self.timeout,
+                loop=self._loop,
+            )
+            self._listen_task = self._loop.create_task(self._listen())
+        except Exception:
+            logger.error("Couldn't open {}".format(self.url))
+            raise
+
+    async def _listen(self) -> None:
+        # FIXME:
+        #
+        # This method is fucked, we need to add a real streaming mode on
+        # lightsd's side and then an async version of ijson:
+
+        buf = bytearray()  # those bufs need to be bound to some max size
+        sbuf = str()
+
+        while True:
+            chunk = await self._reader.read(self.READ_SIZE)
+            if not len(chunk):  # EOF, reconnect
+                logger.info("EOF, reconnecting...")
+                # XXX: deadlock within the close call in _reconnect? (and if
+                # that's the case maybe you can use an event or something).
+                await self._reconnect()
+                return
+
+            buf += chunk
+            try:
+                sbuf += buf.decode(self.encoding, "strict")  # strict is fucked
+            except UnicodeError:
+                continue
+            buf = bytearray()
+
+            while sbuf:
+                # and this is completely fucked:
+                try:
+                    response = json.loads(sbuf)
+                    sbuf = str()
+                except Exception:
+                    def find_response(delim: str) -> Tuple[Dict[str, Any], str]:
+                        offset = sbuf.find(delim)
+                        while offset != -1:
+                            try:
+                                response = json.loads(sbuf[:offset + 1])
+                                return response, sbuf[offset + 1:]
+                            except Exception:
+                                offset = sbuf.find(delim, offset + 2)
+                        return None, sbuf
+
+                    for delim in {"}{", "}[", "]{", "]["}:
+                        response, sbuf = find_response(delim)
+                        if response is not None:
+                            break  # yay!
+                    else:
+                        break  # need more data
+
+                batch = response if isinstance(response, list) else [response]
+                for response in batch:
+                    id = response["id"]
+
+                    error = response.get("error")
+                    if error is not None:
+                        code = error.get("code")
+                        msg = error.get("message")
+                        logger.warning("Error on request {}: {} - {}".format(
+                            id, code, msg
+                        ))
+                        call = self._pending_calls.pop(id)
+                        ex = exceptions.LightsClientError(msg)
+                        call.response.set_exception(ex)
+                        call.timeout_handle.cancel()
+                        continue
+
+                    logger.info("Response {}: {}".format(
+                        id, response["result"]
+                    ))
+                    self._handle_response(id, response["result"])
+
+    def batch(self) -> "_AsyncJSONRPCBatch":
+        return _AsyncJSONRPCBatch(self)
+
+
+# LightsClient could eventually point to a different but api-compatible class
+# someday:
+LightsClient = AsyncJSONRPCLightsClient
+
+
+class _AsyncJSONRPCBatch:
+
+    def __init__(self, client: AsyncJSONRPCLightsClient) -> None:
+        self.responses = None  # type: List[responses.Response]
+        self.exceptions = None  # type: List[Exception]
+        self._client = client
+        self._batch = []  # type: List[Tuple[_JSONRPCMethod, _JSONRPCCall]]
+
+    async def __aenter__(self) -> "_AsyncJSONRPCBatch":
+        return self
+
+    async def __aexit__(self, exc_type, exc_val, exc_tb):
+        if exc_type is None:
+            await self.execute()
+
+    def append(
+        self,
+        req: requests.Request,
+        timeout: int = AsyncJSONRPCLightsClient.TIMEOUT
+    ) -> None:
+        method = _JSONRPC_API[req.__class__]
+        call = _JSONRPCCall(method.name, req.params, timeout=timeout)
+        self._batch.append((method, call))
+
+    async def execute(self) -> None:
+        resp_by_id = await self._client._jsonrpc_execute([
+            call for method, call in self._batch
+        ])
+        self.responses = []
+        self.exceptions = []
+        for method, call in self._batch:
+            raw_resp = resp_by_id[call.id]
+            if isinstance(raw_resp, Exception):
+                self.exceptions.append(raw_resp)
+            else:
+                self.responses.append(method.map_result(raw_resp))
+
+
+LightsCommandBatch = _AsyncJSONRPCBatch
+
+
+async def get_lightsd_unix_socket_async(
+    loop: asyncio.AbstractEventLoop = None,
+) -> str:
+    lightsdrundir = None
+    try:
+        process = await asyncio.create_subprocess_exec(
+            "lightsd", "--rundir",
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.DEVNULL,
+            loop=loop,
+        )
+    except FileNotFoundError:  # couldn't find the lightsd bin
+        pass
+    else:
+        stdout, stderr = await process.communicate()
+        stdout = stdout.decode(locale.getpreferredencoding()).strip()
+        if process.returncode == 0 and stdout:
+            lightsdrundir = stdout
+
+    if lightsdrundir is None:
+        lightsdrundir = "build"
+        logger.warning(
+            "Couldn't infer lightsd's runtime directory, is "
+            "lightsd installed? Trying {}…".format(lightsdrundir)
+        )
+
+    return "unix+jsonrpc://" + os.path.join(lightsdrundir, "socket")
+
+
+async def create_async_lightsd_connection(
+    url: str = None,
+    loop: asyncio.AbstractEventLoop = None
+) -> AsyncJSONRPCLightsClient:
+    if loop is None:
+        loop = asyncio.get_event_loop()
+    if url is None:
+        url = await get_lightsd_unix_socket_async(loop)
+
+    c = AsyncJSONRPCLightsClient(url, loop=loop)
+    await c.connect()
+    return c
+
+
+def create_lightsd_connection(url: str = None) -> None:
+    raise NotImplementedError("Sorry, no synchronous client available yet")
diff --git a/clients/python/lightsc/lightsc/constants.py b/clients/python/lightsc/lightsc/constants.py
new file mode 100644
--- /dev/null
+++ b/clients/python/lightsc/lightsc/constants.py
@@ -0,0 +1,36 @@
+# Copyright (c) 2016, Louis Opter <louis@opter.org>
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+#    this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its contributors
+#    may be used to endorse or promote products derived from this software
+#    without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+# In the future we might need something a bit more complex than range so we can
+# support float values (via the decimal package I guess):
+
+HUE_RANGE = range(0, 360)
+KELVIN_RANGE = range(2500, 9000)
+BRIGHTNESS_RANGE = range(0, 1)
+SATURATION_RANGE = range(0, 1)
diff --git a/clients/python/lightsc/lightsc/exceptions.py b/clients/python/lightsc/lightsc/exceptions.py
new file mode 100644
--- /dev/null
+++ b/clients/python/lightsc/lightsc/exceptions.py
@@ -0,0 +1,40 @@
+# Copyright (c) 2016, Louis Opter <louis@opter.org>
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+#    this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its contributors
+#    may be used to endorse or promote products derived from this software
+#    without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+
+class LightsError(Exception):
+    pass
+
+
+class LightsClientError(LightsError):
+    pass
+
+
+class LightsClientTimeoutError(LightsClientError):
+    pass
diff --git a/clients/python/lightsc/lightsc/requests.py b/clients/python/lightsc/lightsc/requests.py
new file mode 100644
--- /dev/null
+++ b/clients/python/lightsc/lightsc/requests.py
@@ -0,0 +1,101 @@
+# Copyright (c) 2016, Louis Opter <louis@opter.org>
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+#    this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its contributors
+#    may be used to endorse or promote products derived from this software
+#    without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+from typing import (
+    Any,
+    List,
+    TypeVar,
+)
+
+
+class Request:
+
+    def __init__(self, *args: Any) -> None:
+        self.params = args
+
+RequestClass = TypeVar("RequestClass", bound=Request)
+
+
+class SetLightFromHSBK(Request):
+
+    def __init__(
+        self,
+        targets: List[str],
+        h: float, s: float, b: float, k: int,
+        transition_ms: int
+    ) -> None:
+        Request.__init__(self, targets, h, s, b, k, transition_ms)
+
+
+class GetLightState(Request):
+
+    def __init__(self, targets: List[str]) -> None:
+        Request.__init__(self, targets)
+
+
+class PowerOff(Request):
+
+    def __init__(self, targets: List[str]) -> None:
+        Request.__init__(self, targets)
+
+
+class PowerOn(Request):
+
+    def __init__(self, targets: List[str]) -> None:
+        Request.__init__(self, targets)
+
+
+class PowerToggle(Request):
+
+    def __init__(self, targets: List[str]) -> None:
+        Request.__init__(self, targets)
+
+
+class SetWaveform(Request):
+
+    def __init__(
+        self,
+        targets: List[str],
+        waveform: str,
+        h: float, s: float, b: float, k: int,
+        period_ms: int,
+        cycles: int,
+        skew_ratio: float,
+        transient: bool = True,
+    ) -> None:
+        Request.__init__(
+            self,
+            targets,
+            waveform,
+            h, s, b, k,
+            period_ms,
+            cycles,
+            skew_ratio,
+            transient,
+        )
diff --git a/clients/python/lightsc/lightsc/responses.py b/clients/python/lightsc/lightsc/responses.py
new file mode 100644
--- /dev/null
+++ b/clients/python/lightsc/lightsc/responses.py
@@ -0,0 +1,53 @@
+# Copyright (c) 2016, Louis Opter <louis@opter.org>
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+#    this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its contributors
+#    may be used to endorse or promote products derived from this software
+#    without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+from typing import (
+    List,
+    TypeVar,
+)
+
+from . import structs
+
+
+class Response:
+    pass
+
+ResponseClass = TypeVar("ResponseClass", bound=Response)
+
+
+class Bool(Response):
+
+    def __init__(self, bool: bool) -> None:
+        self.value = bool
+
+
+class LightsState(Response):
+
+    def __init__(self, bulbs: List[structs.LightBulb]) -> None:
+        self.bulbs = bulbs
diff --git a/clients/python/lightsc/lightsc/structs.py b/clients/python/lightsc/lightsc/structs.py
new file mode 100644
--- /dev/null
+++ b/clients/python/lightsc/lightsc/structs.py
@@ -0,0 +1,60 @@
+# Copyright (c) 2016, Louis Opter <louis@opter.org>
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+#    this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its contributors
+#    may be used to endorse or promote products derived from this software
+#    without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+from typing import (
+    List,
+)
+
+
+class Struct:
+    pass
+
+
+class LightBulb(Struct):
+
+    def __init__(
+        self,
+        label: str,
+        power: bool,
+        h: float, s: float, b: float, k: int,
+        tags: List[str]
+    ) -> None:
+        self.label = label
+        self.power = power
+        self.h = h
+        self.s = s
+        self.b = b
+        self.k = k
+        self.tags = tags
+
+    def __repr__(self) -> str:
+        return "<{}(label={}, power={}, hsbk=({}, {}, {}, {}), tags={}".format(
+            self.__class__.__name__, self.label, self.power,
+            self.h, self.s, self.b, self.k, self.tags
+        )
diff --git a/clients/python/lightsc/setup.py b/clients/python/lightsc/setup.py
new file mode 100644
--- /dev/null
+++ b/clients/python/lightsc/setup.py
@@ -0,0 +1,65 @@
+# Copyright (c) 2016, Louis Opter <louis@opter.org>
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+#    this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its contributors
+#    may be used to endorse or promote products derived from this software
+#    without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+import setuptools
+
+version = "0.0.1.dev0"
+
+with open("README.rst", "r") as fp:
+    long_description = fp.read()
+
+setuptools.setup(
+    name="lightsc",
+    version=version,
+    description="A client to interact with lightsd",
+    long_description=long_description,
+    author="Louis Opter",
+    author_email="louis@opter.org",
+    packages=setuptools.find_packages(exclude=['tests', 'tests.*']),
+    include_package_data=True,
+    entry_points={
+        "console_scripts": [],
+    },
+    install_requires=[],
+    tests_require=[
+        "doubles~=1.1.3",
+        "freezegun~=0.3.5",
+        "pytest~=3.0",
+    ],
+    extras_require={
+        "dev": [
+            "flake8",
+            "mypy-lang",
+            "ipython",
+            "pdbpp",
+            "pep8",
+            "typed-ast",
+        ],
+    },
+)
diff --git a/lifx/wire_proto.c b/lifx/wire_proto.c
--- a/lifx/wire_proto.c
+++ b/lifx/wire_proto.c
@@ -95,7 +95,7 @@
     LGTD_LIFX_WIRE_PRINT_TARGET(hdr, target);
     lgtd_info(
         "%s <-- %s - (Unimplemented, header info: "
-        "addressable=%d, tagged=%d, protocol=%d, target=%s",
+        "addressable=%d, tagged=%d, protocol=%d, target=%s)",
         pkt_info->name, gw->peeraddr, addressable, tagged, protocol, target
     );
 }