view add_monolight.patch @ 499:2da15caf4d44

Get monolight to work again with the refactored client
author Louis Opter <kalessin@kalessin.fr>
date Tue, 18 Oct 2016 10:15:19 -0700
parents ada36e135d0d
children d250169c1a69
line wrap: on
line source

# HG changeset patch
# Parent  7e908ae2e0c7088791fb4a442d86d7687a52b956
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,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/>.
+
+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()
+
+FADERS_MAX_VALUE = 100
+
+logging.basicConfig(level=logging.INFO)
+
+
+@click.command()
+@click.option("--serialoscd-host", default="127.0.0.1")
+@click.option("--serialoscd-port", type=click.IntRange(0, 2**16 - 1))
+@click.option("--lightsd-url")
+def main(serialoscd_host: str, serialoscd_port: int, lightsd_url: str):
+    loop = asyncio.get_event_loop()
+
+    tasks = asyncio.gather(
+        loop.create_task(lightsc.create_async_lightsd_connection(lightsd_url)),
+        loop.create_task(monome.create_serialosc_connection(
+            functools.partial(osc.MonomeApplication, ui.submit_keypress)
+        ))
+    )
+    loop.run_until_complete(tasks)
+    lightsd, serialosc = tasks.result()
+
+    if hasattr(loop, "add_signal_handler"):
+        for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT):
+            loop.add_signal_handler(signum, ui.stop)
+
+    # TODO: make which monome instance to use something configurable
+    ui_task = loop.create_task(ui.start(loop, lightsd, serialosc))
+
+    loop.run_until_complete(ui_task)
+
+    serialosc.disconnect()
+    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,45 @@
+# 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
+
+MONOME_KEYPRESS_DOWN = 1
+MONOME_KEYPRESS_UP = 0
+MONOME_KEYPRESS_STATES = frozenset({
+    MONOME_KEYPRESS_DOWN,
+    MONOME_KEYPRESS_UP,
+})
+
+
+class MonomeApplication(monome.Monome):
+
+    def __init__(self, keypress_callback):
+        self._keypress_callback = keypress_callback
+        monome.Monome.__init__(self, "/monolight")
+
+    def ready(self):
+        self.led_all(0)
+
+    def grid_key(self, x, y, s):
+        self._keypress_callback(x, y, s)
+
+
+def monome_apply(serialosc, method, *args, **kwargs):
+    for device in serialosc.app_instances.values():
+        for app in device:
+            if isinstance(app, MonomeApplication):
+                method(app, *args, **kwargs)
diff --git a/apps/monolight/monolight/ui.py b/apps/monolight/monolight/ui.py
new file mode 100644
--- /dev/null
+++ b/apps/monolight/monolight/ui.py
@@ -0,0 +1,114 @@
+# Copyright (c) 2016, Louis Opter <louis@opter.org>
+#
+# This file is part of lightsd.
+#
+# lightsd is free software: you can redistribute it and/or modify
+# 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 monome
+import logging
+
+from lightsc.requests import (
+    SetLightFromHSBK,
+    PowerOff,
+    PowerOn,
+    PowerToggle,
+)
+
+from .osc import (
+    MONOME_KEYPRESS_DOWN,
+    monome_apply,
+)
+logger = logging.getLogger("monolight.ui")
+
+_event_queue = None
+
+_KeyPress = collections.namedtuple("_KeyPress", ("x", "y", "state"))
+
+_STOP_SENTINEL = object()
+
+
+def draw(serialosc):
+    buf = monome.LedBuffer(8, 8)
+    buf.led_set(0, 0, 1)
+    for x in range(0, 5):
+        buf.led_set(x, 7, 1)
+    monome_apply(serialosc, buf.render)
+
+
+def hide(serialosc):
+    monome_apply(serialosc, monome.Monome.led_all, 0)
+
+
+async def start(loop, lightsd, serialosc):
+    global _event_queue
+
+    _event_queue = asyncio.Queue()
+
+    hidden = True
+
+    while True:
+        keypress = await _event_queue.get()
+        if keypress is _STOP_SENTINEL:
+            hide(serialosc)
+            _event_queue = None
+            return
+
+        if not hidden:
+            draw(serialosc)
+
+        logger.info("keypress: x={}, y={}, state={}".format(*keypress))
+
+        if keypress.state != MONOME_KEYPRESS_DOWN:
+            continue
+        if keypress.y != 7 and keypress.y != 0:
+            continue
+        if keypress.x == 0:
+            if keypress.y == 0:
+                hidden = not hidden
+                if hidden:
+                    hide(serialosc)
+                continue
+            await lightsd.apply(PowerOff(["*"]))
+        if keypress.y != 7:
+            continue
+        if keypress.x == 1:
+            await lightsd.apply(PowerOn(["*"]))
+        elif keypress.x == 2:
+            await lightsd.apply(PowerToggle(["neko"]))
+        elif keypress.x == 3:
+            await lightsd.apply(PowerToggle(["fugu"]))
+        elif keypress.x == 4:
+            async with lightsd.batch() as batch:
+                batch.apply(SetLightFromHSBK(
+                    ["#tower"], 37.469443, 1.0, 0.25, 3500, 600
+                ))
+                batch.apply(SetLightFromHSBK(
+                    ["fugu", "buzz"], 47.469443, 0.2, 0.2, 3500, 600
+                ))
+                batch.apply(SetLightFromHSBK(
+                    ["candle"], 47.469443, 0.2, 0.15, 3500, 600
+                ))
+                batch.apply(PowerOn(["#br"]))
+
+
+def stop():
+    if _event_queue is not None:
+        _event_queue.put_nowait(_STOP_SENTINEL)
+
+
+def submit_keypress(x, y, state):
+    if _event_queue is not None:
+        _event_queue.put_nowait(_KeyPress(x, y, state))
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,24 @@
+# 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
+    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,321 @@
+# 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)
+
+
+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",
+        ],
+    },
+)