# HG changeset patch
# Parent  b6aea48f2f5da909251852b9864d616b3e2fe7de
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/cli.py b/apps/monolight/monolight/cli.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/cli.py
@@ -0,0 +1,82 @@
+# 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 functools
+import lightsc
+import locale
+import logging
+import monome
+import signal
+
+from . import osc
+from . import ui
+
+ENCODING = locale.getpreferredencoding()
+
+
+@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)
+
+    # 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()
+
+    # Connect to lightsd, serialoscd and wait for everything to come online:
+    grid = ui.MonomeGrid()
+    monome_ready = asyncio.Future()
+    App = functools.partial(
+        osc.MonomeApplication, monome_ready, grid.submit_input
+    )
+    tasks = asyncio.gather(
+        loop.create_task(lightsc.create_async_lightsd_connection(lightsd_url)),
+        loop.create_task(monome.create_serialosc_connection({monome_id: App})),
+        asyncio.ensure_future(monome_ready)
+    )
+    loop.run_until_complete(tasks)
+
+    lightsd, serialosc, monome_app = tasks.result()
+    # We don't need this anymore, MonomeGrid.monome maintains its own (UDP)
+    # connection once the Monome has been discovered:
+    serialosc.disconnect()
+    grid.connect_monome(monome_app, loop)
+
+    ui_task = ui.start(loop, lightsd, grid)
+
+    if hasattr(loop, "add_signal_handler"):
+        for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT):
+            loop.add_signal_handler(signum, ui_task.cancel)
+
+    loop.run_until_complete(ui_task)
+
+    grid.disconnect_monome()
+    loop.run_until_complete(lightsd.close())
+    loop.close()
diff --git a/apps/monolight/monolight/osc.py b/apps/monolight/monolight/osc.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/osc.py
@@ -0,0 +1,80 @@
+# 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 monome
+
+from enum import IntEnum
+from typing import Callable
+
+logger = logging.getLogger("monolight.osc")
+
+
+class MonomeKeyState(IntEnum):
+
+    DOWN = 1
+    UP = 0
+
+
+class MonomeLedLevel(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 MonomeApplication(monome.Monome):
+
+    def __init__(
+        self,
+        ready_future: asyncio.Future,
+        keypress_callback: Callable[[int, int, int], None]
+    ) -> None:
+        monome.Monome.__init__(self, "/monolight")
+        self._ready = False
+        self._ready_future = ready_future
+        self._keypress_cb = keypress_callback
+
+    def ready(self) -> None:
+        if self._ready_future.done():
+            logger.warning(
+                "More than one monome was discovered, monolight will use the "
+                "first one found, please pass --monome-id."
+            )
+            return
+
+        self.led_all(MonomeLedLevel.OFF)
+        self._ready = True
+        self._ready_future.set_result(self)
+
+    def grid_key(self, x: int, y: int, s: int):
+        if self._ready is True:
+            self._keypress_cb(x, y, s)
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,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/>.
+
+TimeMonotonic = float
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,19 @@
+# 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 .grid import MonomeGrid  # noqa
+from .ui import start, stop, submit_keypress  # noqa
diff --git a/apps/monolight/monolight/ui/components/__init__.py b/apps/monolight/monolight/ui/components/__init__.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/components/__init__.py
@@ -0,0 +1,20 @@
+# 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 MonomeGrid  # noqa
+from .layers import Layer  # noqa
+from .button import button  # noqa
diff --git a/apps/monolight/monolight/ui/components/base.py b/apps/monolight/monolight/ui/components/base.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/components/base.py
@@ -0,0 +1,110 @@
+# 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 ...osc import MonomeLedLevel
+from ..types import Dimensions, Position
+
+
+class LedSprite:
+
+    def __init__(
+        self,
+        size: Dimensions,
+        level: MonomeLedLevel = MonomeLedLevel.OFF
+    ) -> None:
+        self.size = size
+        self._levels = [level] * size.width * size.height
+
+    def _index(self, offset: Position):
+        return self.size.height * offset.y + self.size.width * offset.x
+
+    def set(self, offset: Position, level: MonomeLedLevel):
+        self._levels[self._index(offset)] = level
+
+    def get(self, offset: Position):
+        return self._levels[self._index(offset)]
+
+    def __iter__(self):
+        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 UIComponentInsertionError(Exception):
+    pass
+
+
+class UIComponentBase:
+
+    def __init__(self, name: str, size: Dimensions, offset: Position) -> None:
+        self.name = name
+        self.size = size
+        self.offset = offset
+        self._nw_corner = offset
+        self._se_corner = Position(
+            x=self.offset.x + self.size.width,
+            y=self.offset.y + self.size.height
+        )
+        self.children = set()
+
+    def __repr__(self):
+        return "<{}({}, size=({!r}), offset=({!r})>".format(
+            self.__class__.__name__, self.name, self.size, self.offset
+        )
+
+    def insert(self, new: "UIComponentBase") -> None:
+        if new in self.children:
+            raise UIComponentInsertionError(
+                "{!r} is already part of {!r}".format(new, self)
+            )
+        if not new.within(self):
+            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)
+                )
+        self.children.add(new)
+
+    def collides(self, other: "UIComponentBase") -> bool:
+        """Return True if ``self`` and ``other`` overlap in any way."""
+
+        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,
+        )
+
+    def within(self, other: "UIComponentBase") -> bool:
+        """Return True if ``self`` fits within ``other``."""
+
+        return all(
+            other._nw_corner.x >= self._nw_corner.x,
+            other._nw_corner.y >= self._nw_corner.y,
+            other._se_corner.x <= self._se_corner.x,
+            other._se_corner.y <= self._se_corner.y
+        )
+
+    def to_sprite(self) -> LedSprite:
+        return LedSprite(self.size)
+
+    # maybe that bool return type could become an enum or a composite:
+    def submit_input(self, offset: Position) -> bool:
+        return False
diff --git a/apps/monolight/monolight/ui/components/button.py b/apps/monolight/monolight/ui/components/button.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/components/button.py
@@ -0,0 +1,49 @@
+# 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 LedSprite, UIComponentBase
+from ...osc import MONOME_LED_OFF, MONOME_LED_ON
+from ..types import Dimensions, Position
+
+
+class Button(UIComponentBase):
+
+    OFF = 0
+    ON = 1
+
+    # make the size configurable too?
+    def __init__(self, offset: Position, state: int) -> None:
+        self.offset = offset
+        self.state = Button.ON
+        self.children = None
+
+    def toggle(self) -> bool:  # previous state
+        rv = self.state
+        self.state = not self.state
+        return bool(rv)
+
+    def to_sprite(self):
+        return LedSprite(
+            Dimensions(1, 1),
+            MONOME_LED_ON if self.state is Button.ON else MONOME_LED_OFF,
+        )
+
+    def submit_input(self, offset: Position) -> bool:
+        if self.offset == offset:
+            self.toggle()
+            return True
+        return False
diff --git a/apps/monolight/monolight/ui/components/layer.py b/apps/monolight/monolight/ui/components/layer.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/components/layer.py
@@ -0,0 +1,41 @@
+# 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 monome
+
+from .base import UIComponentBase
+from ...osc import MONOME_LED_OFF
+from ..types import Dimensions
+from ...types import TimeMonotonic
+
+
+class Layer(UIComponentBase):
+
+    def __init_(self, size: Dimensions):
+        self.size = size
+        self.led_buffer = monome.LedBuffer(width=size.width, height=size.height)
+
+    def _blit(self, component: UIComponentBase):
+        for off_x, off_y, level in component.to_sprite():
+            self.led_buffer.led_set(
+                component.offset.x + off_x, component.offset.y + off_y, level
+            )
+
+    def render(self, frame_ts: TimeMonotonic) -> None:
+        self.led_buffer.led_level_all(MONOME_LED_OFF)
+        for component in self.children:
+            self._blit(component)
diff --git a/apps/monolight/monolight/ui/grid.py b/apps/monolight/monolight/ui/grid.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/grid.py
@@ -0,0 +1,49 @@
+# 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 .. import osc
+from .components.layer import Layer
+from .types import Dimensions, Keypress, Position
+
+
+class MonomeGrid:
+
+    def __init__(self) -> None:
+        self.size = None  # type: Dimensions
+        self.layers = None  # z-order, type: List[Layer]
+        self.monome = None  # type: osc.MonomeApplication
+        self.input_queue = None  # type: asyncio.Queue
+
+    def connect_monome(
+        self,
+        monome: osc.MonomeApplication,
+        loop: asyncio.AbstractEventLoop = None
+    ) -> None:
+        self.monome = monome
+        self.size = Dimensions(height=monome.height, width=monome.width)
+        self.layers = [Layer(self.size)]
+        self.input_queue = asyncio.Queue(loop=loop)
+
+    def disconnect_monome(self) -> None:
+        self.monome.disconnect()
+        self.size = self.layers = self.momome = None
+
+    def submit_input(self, x: int, y: int, s: int) -> None:
+        if self.input_queue is not None:
+            self.put_nowait(self.input_queue, Keypress(Position(x, y), s))
diff --git a/apps/monolight/monolight/ui/types.py b/apps/monolight/monolight/ui/types.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/types.py
@@ -0,0 +1,57 @@
+# 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 typing import NamedTuple
+
+from .. import osc
+
+_Dimensions = NamedTuple("Dimensions", [("height", int), ("width", int)])
+_Position = NamedTuple("Position", [("x", int), ("y", int)])
+
+
+class Dimensions(_Dimensions):
+
+    def __repr__(self) -> str:
+        return "height={}, width={}".format(*self)
+
+
+class Position(_Position):
+
+    def __repr__(self) -> str:
+        return "{}, {}".format(*self)
+
+_Keypress = NamedTuple("KeyPress", [
+    ("position", Position), ("state", osc.MonomeKeyState)
+])
+
+
+class Keypress(_Keypress):
+
+    @property
+    def x(self):
+        return self.position.x
+
+    @property
+    def y(self):
+        return self.position.y
+
+    @property
+    def s(self):
+        return self.state.value
+
+    def __repr__(self) -> str:
+        return "{!r}, {}".format(self.position, self.state.name)
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,46 @@
+# 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
+
+from lightsc import LightsClient
+
+from .grid import MonomeGrid
+
+logger = logging.getLogger("monolight.ui")
+
+
+async def _refresh(
+    loop: asyncio.AbstractEventLoop, lightsd: LightsClient, grid: MonomeGrid,
+) -> None:
+    pass
+
+
+async def _process_inputs(
+    loop: asyncio.AbstractEventLoop, lightsd: LightsClient, grid: MonomeGrid,
+) -> None:
+    pass
+
+
+def start(
+    loop: asyncio.AbstractEventLoop, lightsd: LightsClient, grid: MonomeGrid,
+) -> None:
+    return asyncio.gather(
+        loop.create_task(_refresh(loop, lightsd, grid)),
+        loop.create_task(_process_inputs(loop, lightsd, grid))
+    )
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,52 @@
+# 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.cli: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",
+            "ipython",
+            "pdbpp",
+            "pep8",
+        ],
+    },
+)
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.apply(PowerOn(targets))
+               batch.apply(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.apply(PowerState([b.label]))
+                   batch.apply(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,25 @@
+# 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 .client import (  # noqa
+    LightsClient,
+    create_lightsd_connection,
+    create_async_lightsd_connection,
+)
+from .view import (  # noqa
+    LightsView,
+)
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,326 @@
+# 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 json
+import locale
+import logging
+import os
+import urllib
+import uuid
+
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    List,
+    NamedTuple,
+    Sequence,
+)
+from typing import (  # noqa
+    Tuple,
+    Type,
+)
+
+from . import (
+    exceptions,
+    requests,
+    responses,
+    structs,
+)
+
+logger = logging.getLogger("lightsd.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
+
+
+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.result() for call in pipeline}
+
+    async def close(self) -> None:
+        if self._listen_task is not None:
+            self._listen_task.cancel()
+            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)
+        reps_by_id = await self._jsonrpc_execute([call])
+        return method.map_result(reps_by_id[call.id])
+
+    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:
+        buf = bytearray()
+
+        while True:
+            chunk = await self._reader.read(self.READ_SIZE)
+            if not len(chunk):  # EOF, reconnect
+                logger.info("EOF, reconnecting...")
+                await self._reconnect()
+                return
+
+            buf += chunk
+            try:
+                json.loads(buf.decode(self.encoding, "ignore"))
+            except Exception:
+                continue
+            response = json.loads(buf.decode(self.encoding, "surrogateescape"))
+            buf = bytearray()
+
+            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("msg")
+                    logger.warning("Error {}: {} - {}".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: Tuple[responses.Response, ...]
+        self._client = client
+        self._batch = []  # type: List[_JSONRPCCall]
+
+    async def __aenter__(self) -> "_AsyncJSONRPCBatch":
+        return self
+
+    async def __aexit__(self, exc_type, exc_val, exc_tb):
+        if exc_type is None:
+            reps_by_id = await self._client._jsonrpc_execute(self._batch)
+            self.responses = (
+                _JSONRPC_API[req.__class__].map_result(reps_by_id[req.id])
+                for req in self._batch
+            )
+
+    def apply(
+        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(call)
+
+
+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/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,28 @@
+# Copyright (c) 2016, Louis Opter <louis@opter.org>
+#
+# This file is part of lightsd.
+#
+# lightsd is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# lightsd is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with lightsd.  If not, see <http://www.gnu.org/licenses/>.
+
+
+class 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,65 @@
+# 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 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,41 @@
+# 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 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,42 @@
+# 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 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
diff --git a/clients/python/lightsc/lightsc/view.py b/clients/python/lightsc/lightsc/view.py
new file mode 100644
--- /dev/null
+++ b/clients/python/lightsc/lightsc/view.py
@@ -0,0 +1,32 @@
+# 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 pprint
+
+
+class LightsView:
+
+    def __init__(self):
+        self.bulbs = []
+        self.bulbs_by_label = {}
+
+    def __str__(self):
+        return pprint.pformat(self.bulbs_by_label)
+
+    def update(self, lights_state):
+        self.bulbs = lights_state.bulbs
+        self.bulbs_by_label = {b.label for b in lights_state.bulbs.items()}
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,53 @@
+# 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"
+
+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",
+        ],
+    },
+)
