Mercurial > louis > mq > lightsd
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", + ], + }, +)