# HG changeset patch
# Parent  4e9a32c7035ac32ac88249ead2d14da461ece7dc
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,96 @@
+# 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.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.disconnect()
+    # 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,90 @@
+# 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...")
+
+    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,
+    ))
+
+    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,120 @@
+# 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 kitchen", Position(2, 7), loop, actions={
+            Button.ActionEnum.UP: actions.Lightsd(requests=[
+                functools.partial(
+                    requests.SetLightFromHSBK,
+                    ["#kitchen"], 0.0, 0.0, 1.0, 4000, 1000,
+                ),
+                functools.partial(requests.PowerOn, ["#kitchen"]),
+            ])
+        })
+    )
+    foreground_layer.insert(Button("orange", Position(3, 7), loop, actions={
+        Button.ActionEnum.UP: actions.Lightsd(requests=[
+            functools.partial(
+                requests.SetLightFromHSBK,
+                ["#tower"], 37.469443, 1.0, 0.25, 3500, 600,
+            ),
+            functools.partial(
+                requests.SetLightFromHSBK,
+                ["fugu", "buzz"], 47.469443, 0.2, 0.2, 3500, 600,
+            ),
+            functools.partial(
+                requests.SetLightFromHSBK,
+                ["candle"], 47.469443, 0.2, 0.15, 3500, 600,
+            ),
+            functools.partial(requests.PowerOn, ["#br"])
+        ]),
+    }))
+    foreground_layer.insert(PowerButton(
+        "#br", Position(4, 7), loop, targets=["#br"]
+    ))
+    foreground_layer.insert(PowerButton(
+        "white", Position(5, 7), loop, targets=["white"]
+    ))
+
+    # some control blocks:
+    BulbControlPad = functools.partial(
+        groups.BulbControlPad,
+        loop=loop,
+        sliders_size=Dimensions(width=1, height=6),
+    )
+    foreground_layer.insert(BulbControlPad(
+        "#kitchen control", Position(0, 0), targets=["#kitchen"],
+    ))
+    foreground_layer.insert(BulbControlPad(
+        "#tower control", Position(4, 0), targets=["#tower"],
+    ))
+    foreground_layer.insert(BulbControlPad(
+        "fugu control", Position(8, 0), targets=["fugu"],
+    ))
+    foreground_layer.insert(BulbControlPad(
+        "candle control", Position(12, 0), targets=["candle"],
+    ))
+
+    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,391 @@
+# 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)
+    ),
+}  # 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:
+    process = await asyncio.create_subprocess_exec(
+        "lightsd", "--rundir",
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.DEVNULL,
+        loop=loop,
+    )
+    stdout, stderr = await process.communicate()
+    stdout = stdout.decode(locale.getpreferredencoding()).strip()
+    if process.returncode == 0 and stdout:
+        lightsdrundir = stdout
+    else:
+        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,77 @@
+# 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)
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
     );
 }
