view add_monolight.patch @ 510:fb5ff147a409

start fixing bugs with ^C but lightsc need fixes for timeouts, it doesn't wait on all tasks/futs
author Louis Opter <kalessin@kalessin.fr>
date Wed, 26 Oct 2016 20:20:45 -0700
parents b452bbfa9c17
children 5b770e279658
line wrap: on
line source

# HG changeset patch
# Parent  c55d0126affdf45c934ca57c5ef599778a753003
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,75 @@
+# 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
+
+from lightsc.requests import GetLightState
+from lightsc.structs import LightBulb  # noqa
+
+from . import grids
+
+DEFAULT_REFRESH_DELAY = 0.1
+KEEPALIVE_DELAY = 60
+
+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
+
+
+async def _poll(
+    loop: asyncio.AbstractEventLoop,
+    refresh_delay_s: float
+) -> None:
+    global bulbs_by_label, bulbs_by_group
+
+    while True:
+        state = await lightsd.apply(GetLightState(["*"]))
+
+        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,157 @@
+# 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 functools
+import logging
+import monome
+
+from enum import IntEnum
+from typing import TYPE_CHECKING, Iterator, Tuple, NamedTuple
+from typing import List, Set  # noqa
+
+from .types import Dimensions, Position
+if TYPE_CHECKING:
+    from .ui.elements.layer import Layer  # noqa
+
+
+logger = logging.getLogger("monolight.grids")
+
+running = set()  # type: Set[MonomeGrid]
+running_event = None  # type: asyncio.Event
+
+
+class KeyState(IntEnum):
+
+    DOWN = 1
+    UP = 0
+
+
+KeyPress = NamedTuple("KeyPress", [
+    ("grid", "MonomeGrid"),
+    ("position", Position),
+    ("state", KeyState),
+])
+
+
+class LedLevel(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 LedSprite(collections.abc.Iterable):  # TODO: make it a real Sequence
+
+    def __init__(
+        self, size: Dimensions, level: LedLevel = LedLevel.OFF
+    ) -> None:
+        self.size = size
+        self._levels = [level] * size.width * size.height
+
+    def _index(self, offset: Position) -> int:
+        return self.size.height * offset.y + self.size.width * offset.x
+
+    def set(self, offset: Position, level: LedLevel) -> None:
+        self._levels[self._index(offset)] = level
+
+    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()
+
+    def disconnect(self) -> None:
+        if len(running) == 1:
+            running_event.clear()
+        running.remove(self._grid)
+        self._grid.input_queue.join()
+        self._grid.queue_get.cancel()
+        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.input_queue.put_nowait(keypress)
+
+
+class MonomeGrid:
+
+    def __init__(self, monome: AIOSCMonolightApp) -> None:
+        loop = monome.loop
+        self.size = Dimensions(height=monome.height, width=monome.width)
+        self.layers = []  # type: List[Layer]
+        self.show_ui = asyncio.Event(loop=loop)
+        self.show_ui.set()
+        self.input_queue = asyncio.Queue(loop=loop)  # type: asyncio.Queue
+        self.queue_get = None  # type: asyncio.Future
+        self.monome = monome
+
+    @property
+    def foreground_layer(self):
+        return self.layers[-1] if self.layers else None
+
+
+_serialosc = None
+
+async def start_serialosc_connection(
+    loop: asyncio.AbstractEventLoop, monome_id: str = "*",
+) -> None:
+    global _serialosc, running_event
+
+    running_event = asyncio.Event(loop=loop)
+    App = functools.partial(AIOSCMonolightApp, loop)
+    _serialosc = await monome.create_serialosc_connection({monome_id: App})
+
+
+async def stop_all() -> None:
+    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()
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,81 @@
+# 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
+
+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("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)
+    except asyncio.CancelledError:
+        pass
+
+    click.echo("ui stopped, disconnecting from serialoscd and lightsd...")
+
+    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,44 @@
+# 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
+
+_Dimensions = NamedTuple("Dimensions", [("height", int), ("width", int)])
+
+
+class Dimensions(_Dimensions):
+
+    def __repr__(self) -> str:
+        return "height={}, width={}".format(*self)
+
+
+class Position:  # can't be a NamedTuple to support __add__ and __sub__
+
+    def __init__(self, x: int, y: int) -> None:
+        self.x = x
+        self.y = y
+
+    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)
+
+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,83 @@
+# 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 lightsc
+import logging
+
+from typing import TYPE_CHECKING, List, Type
+
+from .. import bulbs
+
+if TYPE_CHECKING:
+    from ..elements import UIComponent  # noqa
+
+logger = logging.getLogger("monolight.ui.actions")
+
+
+class Action:
+
+    def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
+        self._loop = loop
+        self._source = None  # type: UIComponent
+
+    def set_source(self, source: "UIComponent") -> "Action":
+        self._source = source
+        return self
+
+    async def _run(self) -> None:  # NOTE: must be re-entrant
+        pass
+
+    async def execute(self) -> None:
+        self._source.busy = True
+        try:
+            await self._run()
+        finally:
+            self._source.busy = False
+
+
+class LightsdAction(Action):
+
+    RequestType = Type[lightsc.requests.RequestClass]
+    RequestTypeList = List[RequestType]
+
+    def __init__(self, *args, **kwargs) -> None:
+        Action.__init__(self, *args, **kwargs)
+        self._targets = []  # type: List[str]
+        self._batch = []  # type: RequestTypeList
+
+    def add_target(self, target: str) -> "LightsdAction":
+        self._targets.append(target)
+        return self
+
+    def add_request(self, type: RequestType) -> "LightsdAction":
+        self._batch.append(type)
+        return self
+
+    async def _run(self) -> None:
+        try:
+            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__)
+        except lightsc.exceptions.LightsClientTimeoutError:
+            logging.error("Timeout on request [{}]".format(", ".join(requests)))
diff --git a/apps/monolight/monolight/ui/elements.py b/apps/monolight/monolight/ui/elements.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui/elements.py
@@ -0,0 +1,161 @@
+# 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 monome
+
+from typing import Dict
+from typing import Set  # noqa
+
+from .. import grids
+from ..types import Dimensions, Position, TimeMonotonic
+
+from . import actions
+
+
+class UIComponentInsertionError(Exception):
+    pass
+
+
+def UIPosition(position: Position) -> "UIComponent":
+    return UIComponent("_ui_position", position, Dimensions(1, 1))
+
+
+class UIComponent:
+
+    def __init__(
+        self,
+        name: str,
+        offset: Position,
+        size: Dimensions,
+        loop: asyncio.AbstractEventLoop = None,
+        actions: Dict[grids.KeyState, 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._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,
+        )
+        self._on_sprite = grids.LedSprite(size, grids.LedLevel.ON)
+        self._medium_sprite = grids.LedSprite(size, grids.LedLevel.MEDIUM)
+
+    def __repr__(self):
+        return "<{}(\"{}\", size=({!r}), offset=({!r})>".format(
+            self.__class__.__name__, self.name, self.size, self.offset
+        )
+
+    def insert(self, new: "UIComponent") -> 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: "UIComponent") -> 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: "UIComponent") -> 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_led_sprite(self, frame_ts_ms: int) -> grids.LedSprite:
+        if self.busy and frame_ts_ms % 1000 // 100 % 2:
+            return self._medium_sprite
+        return self._on_sprite
+
+    def _handle_input(
+        self, offset: Position, key_state: grids.KeyState
+    ) -> None:
+        action = self.actions is not None and self.actions.get(key_state)
+        if action:
+            self.loop.create_task(action.execute())
+
+    # maybe that bool return type could become an enum or a composite:
+    def submit_input(
+        self, position: Position, key_state: grids.KeyState
+    ) -> bool:
+        if not self.collides(UIPosition(position)):
+            return False
+        self._handle_input(position - self.offset, key_state)
+        return True
+
+
+class Layer(UIComponent):
+
+    def __init__(self, name: str, size: Dimensions) -> None:
+        UIComponent.__init__(self, name, Position(0, 0), size)
+        self.led_buffer = monome.LedBuffer(width=size.width, height=size.height)
+
+    def submit_input(
+        self, position: Position, key_state: grids.KeyState
+    ) -> bool:
+        for component in self.children:
+            if component.submit_input(position, key_state):
+                break
+
+    def render(self, frame_ts_ms: TimeMonotonic) -> None:
+        self.led_buffer.led_level_all(grids.LedLevel.OFF.value)
+        for component in self.children:
+            led_sprite = component.to_led_sprite(frame_ts_ms)
+            for off_x, off_y, level in led_sprite:
+                self.led_buffer.led_level_set(
+                    component.offset.x + off_x,
+                    component.offset.y + off_y,
+                    level.value
+                )
+
+
+# make the size configurable too?
+def Button(
+    name: str,
+    offset: Position,
+    loop: asyncio.AbstractEventLoop,
+    actions: Dict[grids.KeyState, actions.Action],
+) -> UIComponent:
+    return UIComponent(name, offset, Dimensions(1, 1), loop, actions)
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,177 @@
+# 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
+import time
+
+from lightsc import requests
+from typing import Tuple  # noqa
+
+from .. import grids
+from ..types import Position
+
+from . import actions, elements
+
+DEFAULT_FRAMERATE = 40
+
+logger = logging.getLogger("monolight.ui")
+
+
+class _ToggleUI(actions.Action):
+
+    def __init__(self, *args, **kwargs) -> None:
+        self._grid = None  # type: grids.MonomeGrid
+
+    def on_grid(self, grid: grids.MonomeGrid) -> "_ToggleUI":
+        self._grid = grid
+        return self
+
+    async def _run(self) -> None:
+        show_ui = self._grid.show_ui
+        if show_ui.is_set():
+            show_ui.clear()
+            self._grid.monome.led_level_all(grids.LedLevel.OFF.value)
+        else:
+            show_ui.set()
+
+
+def _init_ui(loop: asyncio.AbstractEventLoop, grid: grids.MonomeGrid) -> None:
+    foreground_layer = elements.Layer("root", grid.size)
+
+    def LightsdAction():
+        return actions.LightsdAction(loop)
+
+    button = elements.Button("show/hide ui", Position(0, 0), loop, actions={
+        grids.KeyState.DOWN: _ToggleUI(loop).on_grid(grid),
+    })
+    foreground_layer.insert(button)
+    button = elements.Button("off *", Position(0, 7), loop, actions={
+        grids.KeyState.DOWN: (
+            LightsdAction()
+            .add_request(requests.PowerOff)
+            .add_target("*")
+        )
+    })
+    foreground_layer.insert(button)
+    button = elements.Button("on *", Position(1, 7), loop, actions={
+        grids.KeyState.DOWN: (
+            LightsdAction()
+            .add_request(requests.PowerOn)
+            .add_target("*")
+        )
+    })
+    foreground_layer.insert(button)
+    button = elements.Button("toggle kitchen", Position(2, 7), loop, actions={
+        grids.KeyState.DOWN: (
+            LightsdAction()
+            .add_request(requests.PowerToggle)
+            .add_target("#kitchen")
+        )
+    })
+    foreground_layer.insert(button)
+    button = elements.Button("toggle fugu", Position(3, 7), loop, actions={
+        grids.KeyState.DOWN: (
+            LightsdAction()
+            .add_request(requests.PowerToggle)
+            .add_target("fugu")
+        )
+    })
+    foreground_layer.insert(button)
+    button = elements.Button("orange", Position(4, 7), loop, actions={
+        grids.KeyState.DOWN: (
+            LightsdAction()
+            .add_request(functools.partial(
+                requests.SetLightFromHSBK,
+                ["#tower"], 37.469443, 1.0, 0.25, 3500, 600,
+            )).add_request(functools.partial(
+                requests.SetLightFromHSBK,
+                ["fugu", "buzz"], 47.469443, 0.2, 0.2, 3500, 600,
+            )).add_request(functools.partial(
+                requests.SetLightFromHSBK,
+                ["candle"], 47.469443, 0.2, 0.15, 3500, 600,
+            )).add_request(functools.partial(requests.PowerOn, ["#br"]))
+        )
+    })
+    foreground_layer.insert(button)
+
+    grid.layers.append(foreground_layer)
+    logger.info("UI initialized on grid {}".format(grid.monome.id))
+    return foreground_layer
+
+
+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.is_set() for grid in grids.running):
+            await asyncio.wait(
+                [grid.show_ui.wait() 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.is_set():
+                continue
+            layer = grid.foreground_layer
+            if layer is None:
+                layer = _init_ui(loop, grid)
+            layer.render(frame_ts_ms=int(time.monotonic() * 1000))
+            layer.led_buffer.render(grid.monome)
+
+        render_latency = time.monotonic() - render_starts_at
+        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()
+
+        for grid in grids.running:
+            grid.queue_get = loop.create_task(grid.input_queue.get())
+        keypresses, _ = await asyncio.wait(
+            [grid.queue_get for grid in grids.running],
+            return_when=asyncio.FIRST_COMPLETED,
+            loop=loop,
+        )  # type: Tuple[Set[asyncio.Future], Set[asyncio.Future]]
+        try:
+            for grid, position, state in [each.result() for each in keypresses]:
+                grid.queue_get = None
+                grid.input_queue.task_done()
+                logger.info("Keypress {} on grid {} at {}".format(
+                    state, grid.monome.id, position
+                ))
+                if grid.foreground_layer is not None:
+                    grid.foreground_layer.submit_input(position, state)
+        except asyncio.CancelledError:
+            continue
+
+
+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,42 @@
+# 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,
+    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,345 @@
+# 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,
+)
+from typing import (  # noqa
+    Tuple,
+    Type,
+)
+
+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
+
+
+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()
+            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)
+        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:
+            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(call)
+
+    async def execute(self) -> 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
+        )
+
+
+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/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,54 @@
+# 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
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",
+        ],
+    },
+)