view add_monolight.patch @ 498:ada36e135d0d

Wip, break things appart
author Louis Opter <kalessin@kalessin.fr>
date Fri, 14 Oct 2016 17:10:24 -0700
parents 08ad69e0a7a7
children 2da15caf4d44
line wrap: on
line source

# HG changeset patch
# Parent  703102c9539ab4968c7cde3313c2f33ce0070a8a
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,101 @@
+# Copyright (c) 2016, Louis Opter <louis@opter.org>
+#
+# This file is part of lightsd.
+#
+# lightsd is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# lightsd is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with lightsd.  If not, see <http://www.gnu.org/licenses/>.
+
+import asyncio
+import click
+import contextlib
+import functools
+import locale
+import logging
+import monome
+import os
+import signal
+import subprocess
+
+from . import lightsc
+from . import osc
+from . import ui
+
+ENCODING = locale.getpreferredencoding()
+
+FADERS_MAX_VALUE = 100
+
+logging.basicConfig(level=logging.INFO)
+
+
+def get_lightsd_rundir():
+    try:
+        lightsdrundir = subprocess.check_output(["lightsd", "--rundir"])
+    except Exception as ex:
+        click.echo(
+            "Couldn't infer lightsd's runtime directory, is lightsd installed? "
+            "({})\nTrying build/socket…".format(ex),
+            err=True
+        )
+        lightscdir = os.path.realpath(__file__).split(os.path.sep)[:-2]
+        lightsdrundir = os.path.join(*[os.path.sep] + lightscdir + ["build"])
+    else:
+        lightsdrundir = lightsdrundir.decode(ENCODING).strip()
+
+    return lightsdrundir
+
+LIGHTSD_SOCKET = "unix://" + os.path.join(get_lightsd_rundir(), "socket")
+
+
+@contextlib.contextmanager
+def handle_unix_signals(loop):
+    SIGNALS = (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT)
+    if hasattr(loop, "add_signal_handler"):
+        for signum in SIGNALS:
+            loop.add_signal_handler(signum, ui.stop)
+    yield
+    # Workaround dumb bug in Python:
+    # Traceback (most recent call last):
+    #   File "/usr/lib64/python3.5/asyncio/base_events.py", line 431, in __del__
+    #   File "/usr/lib64/python3.5/asyncio/unix_events.py", line 58, in close
+    #   File "/usr/lib64/python3.5/asyncio/unix_events.py", line 139, in remove_signal_handler  # noqa
+    #   File "/usr/lib64/python3.5/signal.py", line 47, in signal
+    # TypeError: signal handler must be signal.SIG_IGN, signal.SIG_DFL, or a callable object  # noqa
+    if hasattr(loop, "remove_signal_handler"):
+        for signum in SIGNALS:
+            loop.remove_signal_handler(signum)
+
+
+@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", default=LIGHTSD_SOCKET)
+def main(serialoscd_host, serialoscd_port, lightsd_url):
+    loop = asyncio.get_event_loop()
+
+    tasks = asyncio.gather(
+        loop.create_task(lightsc.create_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()
+
+    with handle_unix_signals(loop):
+        # 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())
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 .osc import (
+    MONOME_KEYPRESS_DOWN,
+    monome_apply,
+)
+from .lightsc.commands import (
+    SetLightFromHSBK,
+    PowerOff,
+    PowerOn,
+    PowerToggle,
+)
+
+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/lightsd-python/README.rst b/clients/lightsd-python/README.rst
new file mode 100644
--- /dev/null
+++ b/clients/lightsd-python/README.rst
@@ -0,0 +1,30 @@
+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
+
+   from lightsd import create_lightsd_connection
+   from lightsd.commands import PowerOff, PowerOn, SetLightFromHSBK
+
+   async def example():
+       client = await create_lightsd_connection("unix:///run/lightsd/socket")
+       async with client.batch() as batch:
+           batch.apply(PowerOn(targets=["*"]))
+           batch.apply(SetLightFromHSBK(["*"], 0., 1., 1., 3500, transition=600))
+       client.apply(PowerOff(["*"]))
+       await client.close()
+
+   loop = asyncio.get_event_loop()
+   loop.run_until_complete(loop.create_task(example()))
+
+.. _lightsd: https://www.lightsd.io/
+.. _LIFX: http://lifx.co/
+
+.. vim: set tw=80 spelllang=en spell:
diff --git a/clients/lightsd-python/lightsd/__init__.py b/clients/lightsd-python/lightsd/__init__.py
new file mode 100644
--- /dev/null
+++ b/clients/lightsd-python/lightsd/__init__.py
@@ -0,0 +1,21 @@
+# 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,
+)
diff --git a/clients/lightsd-python/lightsd/client.py b/clients/lightsd-python/lightsd/client.py
new file mode 100644
--- /dev/null
+++ b/clients/lightsd-python/lightsd/client.py
@@ -0,0 +1,248 @@
+# 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 json
+import logging
+import os
+import urllib
+import uuid
+
+from . import commands
+
+logger = logging.getLogger("monolight.lightsc")
+
+PendingRequestEntry = collections.namedtuple(
+    "PendingRequestEntry", ("handler_cb", "timeout_handle")
+)
+
+
+class _JSONRPCCall:
+
+    def __init__(self, method, params, response_handler):
+        self.request = {
+            "id": str(uuid.uuid4()),
+            "jsonrpc": "2.0",
+            "method": method,
+            "params": params,
+        }
+        self.response_handler = response_handler
+
+
+class _JSONRPCBatch:
+
+    def __init__(self, client):
+        self._client = client
+        self._batch = []
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc_val, exc_tb):
+        if exc_type is None:
+            await self._client._jsonrpc_execute(self._batch)
+
+    def apply(self, command):
+        self._batch.append(_JSONRPCCall(
+            command.METHOD,
+            command.params,
+            response_handler=self._client._HANDLERS.get(command.__class__)
+        ))
+
+
+class LightsClient:
+
+    READ_SIZE = 8192
+    REFRESH_INTERVAL = 250 / 1000  # seconds
+    TIMEOUT = 2  # seconds
+    ENCODING = "utf-8"
+
+    def __init__(self,
+                 url,
+                 encoding=ENCODING,
+                 timeout=TIMEOUT,
+                 read_size=READ_SIZE,
+                 refresh_interval=REFRESH_INTERVAL,
+                 loop=None):
+        self.url = url
+        self.encoding = encoding
+        self.timeout = timeout
+        self.read_size = read_size
+        self.refresh_interval = refresh_interval
+        self._pending_requests = {}
+        self._reader = self._writer = None
+        self._listen_task = self._poll_task = None
+        self._loop = loop or asyncio.get_event_loop()
+
+    def _handle_light_state(self, response):
+        logger.info("Updating bulbs state")
+        self._bulbs = {b["label"]: b for b in response}
+
+    _HANDLERS = {
+        commands.GetLightState: _handle_light_state
+    }
+
+    def _handle_response(self, id, response):
+        handler_cb, timeout_handle = self._pending_requests.pop(id)
+        timeout_handle.cancel()
+
+        if response is None:
+            logger.info("Timeout on request {}".format(id))
+            return
+
+        if handler_cb is not None:
+            handler_cb(self, response)
+            return
+
+        logger.info("No handler for response {}: {}".format(id, response))
+
+    async def _jsonrpc_execute(self, pipeline):
+        calls = [call.request for call in pipeline]
+        payload = json.dumps(calls[0] if len(calls) == 1 else calls)
+        payload = payload.encode(self.encoding, "surrogateescape")
+
+        self._writer.write(payload)
+
+        for call in calls:
+            logger.info("Request {id}: {method}({params})".format(**call))
+
+        await self._writer.drain()
+
+        for call in pipeline:
+            id = call.request["id"]
+            timeout_cb = functools.partial(
+                self._handle_response, id, response=None
+            )
+            self._pending_requests[id] = PendingRequestEntry(
+                handler_cb=call.response_handler,
+                timeout_handle=self._loop.call_later(self.TIMEOUT, timeout_cb)
+            )
+
+    async def close(self):
+        futures = []
+        if self._poll_task is not None:
+            self._poll_task.cancel()
+            futures.append(self._poll_task)
+        if self._listen_task is not None:
+            self._listen_task.cancel()
+            futures.append(self._listen_task)
+        await asyncio.wait(futures, loop=self._loop)
+        self._poll_task = 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
+
+    async def _reconnect(self):
+        await self.close()
+        await self.connect()
+
+    async def apply(self, command):
+        await self._jsonrpc_execute([_JSONRPCCall(
+            command.METHOD,
+            command.params,
+            response_handler=self._HANDLERS.get(command.__class__),
+        )])
+
+    async def connect(self):
+        parts = urllib.parse.urlparse(self.url)
+        if parts.scheme == "unix":
+            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":
+            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())
+            self._poll_task = self._loop.create_task(self.poll())
+        except Exception:
+            logger.error("Couldn't open {}".format(self.url))
+            raise
+
+    async def poll(self):
+        while True:
+            await self.apply(commands.GetLightState(["*"]))
+            await asyncio.sleep(self.refresh_interval)
+
+    async def listen(self):
+        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:
+                # Decoding or parsing failed, data must be missing, try again:
+                continue
+
+            # Decoding and JSON parsing were successful, we have received
+            # a full response:
+            response = json.loads(buf.decode(self.encoding, "surrogateescape"))
+            buf = bytearray()
+
+            # Convert the response to a batch response if needed so we
+            # can always loop over it:
+            batch = response if isinstance(response, list) else [response]
+
+            for response in batch:
+                error = response.get("error")
+                if error is not None:
+                    id = response["id"]
+                    del self._pending_requests[id]
+                    logger.warning("Error {}: {code} - {message}".format(
+                        id, **error
+                    ))
+                    continue
+
+                self._handle_response(response["id"], response["result"])
+
+    def batch(self):
+        return _JSONRPCBatch(self)
+
+
+async def create_lightsd_connection(url, loop=None):
+    if loop is None:
+        loop = asyncio.get_event_loop()
+
+    c = LightsClient(url, loop=loop)
+    await c.connect()
+    return c
diff --git a/clients/lightsd-python/lightsd/commands.py b/clients/lightsd-python/lightsd/commands.py
new file mode 100644
--- /dev/null
+++ b/clients/lightsd-python/lightsd/commands.py
@@ -0,0 +1,64 @@
+# 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 Command:
+
+    METHOD = None
+
+    def __init__(self, *args):
+        self.params = args
+
+
+class SetLightFromHSBK(Command):
+
+    METHOD = "set_light_from_hsbk"
+
+    def __init__(self, targets, h, s, b, k, transition):
+        Command.__init__(self, targets, h, s, b, k, transition)
+
+
+class GetLightState(Command):
+
+    METHOD = "get_light_state"
+
+    def __init__(self, targets):
+        Command.__init__(self, targets)
+
+
+class PowerOff(Command):
+
+    METHOD = "power_off"
+
+    def __init__(self, targets):
+        Command.__init__(self, targets)
+
+
+class PowerOn(Command):
+
+    METHOD = "power_on"
+
+    def __init__(self, targets):
+        Command.__init__(self, targets)
+
+
+class PowerToggle(Command):
+
+    METHOD = "power_toggle"
+
+    def __init__(self, targets):
+        Command.__init__(self, targets)
diff --git a/clients/lightsd-python/setup.py b/clients/lightsd-python/setup.py
new file mode 100644
--- /dev/null
+++ b/clients/lightsd-python/setup.py
@@ -0,0 +1,56 @@
+# 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="lightsd",
+    version=version,
+    description="A client to interact with lighsd ",
+    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": [
+            "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",
+        ],
+    },
+)