Mercurial > louis > mq > lightsd
view add_monolight.patch @ 502:a78f7f19d40f
keep breaking things
author | Louis Opter <kalessin@kalessin.fr> |
---|---|
date | Sun, 23 Oct 2016 16:35:02 -0700 |
parents | c38b0b9612cd |
children | 84c8260875c2 |
line wrap: on
line source
# HG changeset patch # Parent b6aea48f2f5da909251852b9864d616b3e2fe7de Start an experimental GUI for a Monome 128 Varibright Written in Python >= 3.5. diff --git a/.hgignore b/.hgignore --- a/.hgignore +++ b/.hgignore @@ -2,3 +2,4 @@ .*\.py[co]$ ^build ^pcaps +.*\.egg-info$ diff --git a/apps/monolight/monolight/__init__.py b/apps/monolight/monolight/__init__.py new file mode 100644 diff --git a/apps/monolight/monolight/cli.py b/apps/monolight/monolight/cli.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/cli.py @@ -0,0 +1,82 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +import asyncio +import click +import functools +import lightsc +import locale +import logging +import monome +import signal + +from . import osc +from . import ui + +ENCODING = locale.getpreferredencoding() + + +@click.command() +@click.option("--serialoscd-host", default="127.0.0.1") +@click.option("--serialoscd-port", type=click.IntRange(0, 2**16 - 1)) +@click.option("--monome-id", default="*", help="The id of the monome to use") +@click.option( + "--lightsd-url", help="tcp+jsonrpc://host:port or unix+jsonrpc:///a/path" +) +def main( + serialoscd_host: str, + serialoscd_port: int, + monome_id: str, + lightsd_url: str, +) -> None: + logging.basicConfig(level=logging.INFO) + + # NOTE: this isn't good enough on Windows unless you pass --lightsd-url: + # discovering lightsd's socket involves using asyncio.subprocess which + # requires an IOCP event loop, which doesn't support UDP connections. + loop = asyncio.get_event_loop() + + # Connect to lightsd, serialoscd and wait for everything to come online: + grid = ui.MonomeGrid() + monome_ready = asyncio.Future() + App = functools.partial( + osc.MonomeApplication, monome_ready, grid.submit_input + ) + tasks = asyncio.gather( + loop.create_task(lightsc.create_async_lightsd_connection(lightsd_url)), + loop.create_task(monome.create_serialosc_connection({monome_id: App})), + asyncio.ensure_future(monome_ready) + ) + loop.run_until_complete(tasks) + + lightsd, serialosc, monome_app = tasks.result() + # We don't need this anymore, MonomeGrid.monome maintains its own (UDP) + # connection once the Monome has been discovered: + serialosc.disconnect() + grid.connect_monome(monome_app, loop) + + ui_task = ui.start(loop, lightsd, grid) + + if hasattr(loop, "add_signal_handler"): + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT): + loop.add_signal_handler(signum, ui_task.cancel) + + loop.run_until_complete(ui_task) + + grid.disconnect_monome() + loop.run_until_complete(lightsd.close()) + loop.close() diff --git a/apps/monolight/monolight/osc.py b/apps/monolight/monolight/osc.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/osc.py @@ -0,0 +1,80 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +import asyncio +import logging +import monome + +from enum import IntEnum +from typing import Callable + +logger = logging.getLogger("monolight.osc") + + +class MonomeKeyState(IntEnum): + + DOWN = 1 + UP = 0 + + +class MonomeLedLevel(IntEnum): + + OFF = 0 + VERY_LOW_1 = 1 + VERY_LOW_2 = 2 + VERY_LOW_3 = 3 + LOW = LOW_1 = 4 + LOW_2 = 5 + LOW_3 = 6 + LOW_4 = 7 + MEDIUM = MEDIUM_1 = 8 + MEDIUM_2 = 9 + MEDIUM_3 = 10 + MEDIUM_4 = 11 + HIGH = HIGH_1 = 12 + HIGH_2 = 13 + HIGH_3 = 14 + HIGH_4 = ON = 15 + + +class MonomeApplication(monome.Monome): + + def __init__( + self, + ready_future: asyncio.Future, + keypress_callback: Callable[[int, int, int], None] + ) -> None: + monome.Monome.__init__(self, "/monolight") + self._ready = False + self._ready_future = ready_future + self._keypress_cb = keypress_callback + + def ready(self) -> None: + if self._ready_future.done(): + logger.warning( + "More than one monome was discovered, monolight will use the " + "first one found, please pass --monome-id." + ) + return + + self.led_all(MonomeLedLevel.OFF) + self._ready = True + self._ready_future.set_result(self) + + def grid_key(self, x: int, y: int, s: int): + if self._ready is True: + self._keypress_cb(x, y, s) diff --git a/apps/monolight/monolight/types.py b/apps/monolight/monolight/types.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/types.py @@ -0,0 +1,18 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +TimeMonotonic = float diff --git a/apps/monolight/monolight/ui/__init__.py b/apps/monolight/monolight/ui/__init__.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +from .grid import MonomeGrid # noqa +from .ui import start, stop, submit_keypress # noqa diff --git a/apps/monolight/monolight/ui/components/__init__.py b/apps/monolight/monolight/ui/components/__init__.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/components/__init__.py @@ -0,0 +1,20 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +from .base import MonomeGrid # noqa +from .layers import Layer # noqa +from .button import button # noqa diff --git a/apps/monolight/monolight/ui/components/base.py b/apps/monolight/monolight/ui/components/base.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/components/base.py @@ -0,0 +1,110 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +from ...osc import MonomeLedLevel +from ..types import Dimensions, Position + + +class LedSprite: + + def __init__( + self, + size: Dimensions, + level: MonomeLedLevel = MonomeLedLevel.OFF + ) -> None: + self.size = size + self._levels = [level] * size.width * size.height + + def _index(self, offset: Position): + return self.size.height * offset.y + self.size.width * offset.x + + def set(self, offset: Position, level: MonomeLedLevel): + self._levels[self._index(offset)] = level + + def get(self, offset: Position): + return self._levels[self._index(offset)] + + def __iter__(self): + for off_x in range(self.size.width): + for off_y in range(self.size.height): + yield off_x, off_y, self.get(Position(x=off_x, y=off_y)) + + +class UIComponentInsertionError(Exception): + pass + + +class UIComponentBase: + + def __init__(self, name: str, size: Dimensions, offset: Position) -> None: + self.name = name + self.size = size + self.offset = offset + self._nw_corner = offset + self._se_corner = Position( + x=self.offset.x + self.size.width, + y=self.offset.y + self.size.height + ) + self.children = set() + + def __repr__(self): + return "<{}({}, size=({!r}), offset=({!r})>".format( + self.__class__.__name__, self.name, self.size, self.offset + ) + + def insert(self, new: "UIComponentBase") -> None: + if new in self.children: + raise UIComponentInsertionError( + "{!r} is already part of {!r}".format(new, self) + ) + if not new.within(self): + raise UIComponentInsertionError( + "{!r} doesn't fit into {!r}".format(new, self) + ) + for child in self.children: + if child.collides(new): + raise UIComponentInsertionError( + "{!r} conflicts with {!r}".format(new, child) + ) + self.children.add(new) + + def collides(self, other: "UIComponentBase") -> bool: + """Return True if ``self`` and ``other`` overlap in any way.""" + + return all( + self._nw_corner.x <= other._se_corner.x, + self._se_corner.x >= other._nw_corner.x, + self._nw_corner.y <= other._se_corner.y, + self._se_corner.y >= other._nw_corner.y, + ) + + def within(self, other: "UIComponentBase") -> bool: + """Return True if ``self`` fits within ``other``.""" + + return all( + other._nw_corner.x >= self._nw_corner.x, + other._nw_corner.y >= self._nw_corner.y, + other._se_corner.x <= self._se_corner.x, + other._se_corner.y <= self._se_corner.y + ) + + def to_sprite(self) -> LedSprite: + return LedSprite(self.size) + + # maybe that bool return type could become an enum or a composite: + def submit_input(self, offset: Position) -> bool: + return False diff --git a/apps/monolight/monolight/ui/components/button.py b/apps/monolight/monolight/ui/components/button.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/components/button.py @@ -0,0 +1,49 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +from .base import LedSprite, UIComponentBase +from ...osc import MONOME_LED_OFF, MONOME_LED_ON +from ..types import Dimensions, Position + + +class Button(UIComponentBase): + + OFF = 0 + ON = 1 + + # make the size configurable too? + def __init__(self, offset: Position, state: int) -> None: + self.offset = offset + self.state = Button.ON + self.children = None + + def toggle(self) -> bool: # previous state + rv = self.state + self.state = not self.state + return bool(rv) + + def to_sprite(self): + return LedSprite( + Dimensions(1, 1), + MONOME_LED_ON if self.state is Button.ON else MONOME_LED_OFF, + ) + + def submit_input(self, offset: Position) -> bool: + if self.offset == offset: + self.toggle() + return True + return False diff --git a/apps/monolight/monolight/ui/components/layer.py b/apps/monolight/monolight/ui/components/layer.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/components/layer.py @@ -0,0 +1,41 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +import monome + +from .base import UIComponentBase +from ...osc import MONOME_LED_OFF +from ..types import Dimensions +from ...types import TimeMonotonic + + +class Layer(UIComponentBase): + + def __init_(self, size: Dimensions): + self.size = size + self.led_buffer = monome.LedBuffer(width=size.width, height=size.height) + + def _blit(self, component: UIComponentBase): + for off_x, off_y, level in component.to_sprite(): + self.led_buffer.led_set( + component.offset.x + off_x, component.offset.y + off_y, level + ) + + def render(self, frame_ts: TimeMonotonic) -> None: + self.led_buffer.led_level_all(MONOME_LED_OFF) + for component in self.children: + self._blit(component) diff --git a/apps/monolight/monolight/ui/grid.py b/apps/monolight/monolight/ui/grid.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/grid.py @@ -0,0 +1,49 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +import asyncio + +from .. import osc +from .components.layer import Layer +from .types import Dimensions, Keypress, Position + + +class MonomeGrid: + + def __init__(self) -> None: + self.size = None # type: Dimensions + self.layers = None # z-order, type: List[Layer] + self.monome = None # type: osc.MonomeApplication + self.input_queue = None # type: asyncio.Queue + + def connect_monome( + self, + monome: osc.MonomeApplication, + loop: asyncio.AbstractEventLoop = None + ) -> None: + self.monome = monome + self.size = Dimensions(height=monome.height, width=monome.width) + self.layers = [Layer(self.size)] + self.input_queue = asyncio.Queue(loop=loop) + + def disconnect_monome(self) -> None: + self.monome.disconnect() + self.size = self.layers = self.momome = None + + def submit_input(self, x: int, y: int, s: int) -> None: + if self.input_queue is not None: + self.put_nowait(self.input_queue, Keypress(Position(x, y), s)) diff --git a/apps/monolight/monolight/ui/types.py b/apps/monolight/monolight/ui/types.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/types.py @@ -0,0 +1,57 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +from typing import NamedTuple + +from .. import osc + +_Dimensions = NamedTuple("Dimensions", [("height", int), ("width", int)]) +_Position = NamedTuple("Position", [("x", int), ("y", int)]) + + +class Dimensions(_Dimensions): + + def __repr__(self) -> str: + return "height={}, width={}".format(*self) + + +class Position(_Position): + + def __repr__(self) -> str: + return "{}, {}".format(*self) + +_Keypress = NamedTuple("KeyPress", [ + ("position", Position), ("state", osc.MonomeKeyState) +]) + + +class Keypress(_Keypress): + + @property + def x(self): + return self.position.x + + @property + def y(self): + return self.position.y + + @property + def s(self): + return self.state.value + + def __repr__(self) -> str: + return "{!r}, {}".format(self.position, self.state.name) diff --git a/apps/monolight/monolight/ui/ui.py b/apps/monolight/monolight/ui/ui.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/ui.py @@ -0,0 +1,46 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +import asyncio +import logging + +from lightsc import LightsClient + +from .grid import MonomeGrid + +logger = logging.getLogger("monolight.ui") + + +async def _refresh( + loop: asyncio.AbstractEventLoop, lightsd: LightsClient, grid: MonomeGrid, +) -> None: + pass + + +async def _process_inputs( + loop: asyncio.AbstractEventLoop, lightsd: LightsClient, grid: MonomeGrid, +) -> None: + pass + + +def start( + loop: asyncio.AbstractEventLoop, lightsd: LightsClient, grid: MonomeGrid, +) -> None: + return asyncio.gather( + loop.create_task(_refresh(loop, lightsd, grid)), + loop.create_task(_process_inputs(loop, lightsd, grid)) + ) diff --git a/apps/monolight/setup.py b/apps/monolight/setup.py new file mode 100644 --- /dev/null +++ b/apps/monolight/setup.py @@ -0,0 +1,52 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lighstd. +# +# lighstd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lighstd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lighstd. If not, see <http://www.gnu.org/licenses/>. + +import setuptools + +version = "0.0.1.dev0" + +setuptools.setup( + name="monolight", + version=version, + description="A Monome UI to control smart bulbs using lightsd", + author="Louis Opter", + author_email="louis@opter.org", + packages=setuptools.find_packages(exclude=['tests', 'tests.*']), + include_package_data=True, + entry_points={ + "console_scripts": [ + "monolight = monolight.cli:main", + ], + }, + install_requires=[ + "click~=6.6", + "pymonome~=0.8.2", + ], + tests_require=[ + "doubles~=1.1.3", + "freezegun~=0.3.5", + "pytest~=3.0", + ], + extras_require={ + "dev": [ + "flake8", + "ipython", + "pdbpp", + "pep8", + ], + }, +) diff --git a/clients/python/lightsc/README.rst b/clients/python/lightsc/README.rst new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/README.rst @@ -0,0 +1,63 @@ +A Python client to control your smart bulbs through lightsd +=========================================================== + +lightsd_ is a daemon (background service) to control your LIFX_ WiFi "smart" +bulbs. This package allows you to make RPC calls to lightsd to control your +light bulbs from Python. It is built on top of the ``asyncio`` module and +requires Python ≥ 3.5: + +.. code-block:: python + + import asyncio + import click + + from lightsc import LightsView, create_async_lightsd_connection + from lightsc.requests import ( + GetLightState, + PowerOff, + PowerOn, + SetLightFromHSBK, + ) + + async def example(url, targets): + async with create_async_lightsd_connection(url) as client: + click.echo("Connected to lightsd running at {}".format(client.url)) + + view = LightsView() + view.update(await client.apply(GetLightState(targets)) + click.echo("Discovered bulbs: {}".format(view)) + + transition_ms = 600 + red_hsbk = (0., 1., 1., 3500) + click.echo("Turning all bulbs to red in {}ms...".format(transition_ms)) + async with client.batch() as batch: + batch.apply(PowerOn(targets)) + batch.apply(SetLightFromHSBK(targets, *red_hsbk, transition_ms=transition_ms)) + + click.echo("Restoring original state") + async with client.batch() as batch: + for b in view.bulbs: + PowerState = PowerOn if b.power else PowerOff + hsbk = (b.h, b.s, b.b, b.k) + + batch.apply(PowerState([b.label])) + batch.apply(SetLightFromHSBK([b.label], *hsbk, transition_ms=transition_ms)) + + @click.command() + @click.option("--lightsd-url", help="supported schemes: tcp+jsonrpc://, unix+jsonrpc://") + @click.argument("bulb_targets", nargs=-1, required=True) + def main(lightsd_url, bulb_targets) + """This example will turn all your bulbs to red before restoring their + original state. + + If an URL is not provided this script will attempt to connect to + lightsd's UNIX socket. + """ + + evl = asyncio.get_event_loop() + evl.run_until_complete(evl.create_task(example(lightsd_url, bulb_targets))) + +.. _lightsd: https://www.lightsd.io/ +.. _LIFX: http://lifx.co/ + +.. vim: set tw=80 spelllang=en spell: diff --git a/clients/python/lightsc/lightsc/__init__.py b/clients/python/lightsc/lightsc/__init__.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/__init__.py @@ -0,0 +1,25 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +from .client import ( # noqa + LightsClient, + create_lightsd_connection, + create_async_lightsd_connection, +) +from .view import ( # noqa + LightsView, +) diff --git a/clients/python/lightsc/lightsc/client.py b/clients/python/lightsc/lightsc/client.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/client.py @@ -0,0 +1,326 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +import asyncio +import functools +import json +import locale +import logging +import os +import urllib +import uuid + +from typing import ( + Any, + Callable, + Dict, + List, + NamedTuple, + Sequence, +) +from typing import ( # noqa + Tuple, + Type, +) + +from . import ( + exceptions, + requests, + responses, + structs, +) + +logger = logging.getLogger("lightsd.client") + + +_JSONRPCMethod = NamedTuple("_JSONRPCMethod", [ + ("name", str), + ("map_result", Callable[[Any], responses.Response]), +]) +_JSONRPC_API = { + requests.GetLightState: _JSONRPCMethod( + name="get_light_state", + map_result=lambda result: responses.LightsState([ + structs.LightBulb( + b["label"], b["power"], *b["hsbk"], tags=b["tags"] + ) for b in result + ]) + ), + requests.SetLightFromHSBK: _JSONRPCMethod( + name="set_light_from_hsbk", + map_result=lambda result: responses.Bool(result) + ), + requests.PowerOn: _JSONRPCMethod( + name="power_on", + map_result=lambda result: responses.Bool(result) + ), + requests.PowerOff: _JSONRPCMethod( + name="power_off", + map_result=lambda result: responses.Bool(result) + ), + requests.PowerToggle: _JSONRPCMethod( + name="power_toggle", + map_result=lambda result: responses.Bool(result) + ), +} # type: Dict[Type[requests.RequestClass], _JSONRPCMethod] + + +class _JSONRPCCall: + + def __init__( + self, method: str, params: Sequence[Any], timeout: int = None + ) -> None: + self.id = str(uuid.uuid4()) + self.method = method + self.params = params + self.timeout = timeout + self.timeout_handle = None # type: asyncio.Handle + self.request = { + "id": self.id, + "jsonrpc": "2.0", + "method": method, + "params": params, + } + self.response = asyncio.Future() # type: asyncio.futures.Future + + +class AsyncJSONRPCLightsClient: + + READ_SIZE = 8192 + TIMEOUT = 2 # seconds + ENCODING = "utf-8" + + def __init__( + self, + url: str, + encoding: str = ENCODING, + timeout: int = TIMEOUT, + read_size: int = READ_SIZE, + loop: asyncio.AbstractEventLoop = None + ) -> None: + self.url = url + self.encoding = encoding + self.timeout = timeout + self.read_size = read_size + self._listen_task = None # type: asyncio.Task + self._pending_calls = {} # type: Dict[str, _JSONRPCCall] + self._reader = None # type: asyncio.StreamReader + self._writer = None # type: asyncio.StreamWriter + self._loop = loop or asyncio.get_event_loop() + + def _handle_response( + self, id: str, response: Any, timeout: bool = False + ) -> None: + call = self._pending_calls.pop(id) + if timeout is True: + call.response.set_exception(exceptions.LightsClientTimeoutError()) + return + call.timeout_handle.cancel() + call.response.set_result(response) + + async def _jsonrpc_execute( + self, pipeline: List[_JSONRPCCall] + ) -> Dict[str, Any]: + if not pipeline: + return {} + + requests = [call.request for call in pipeline] + for req in requests: + logger.info("Request {id}: {method}({params})".format(**req)) + + payload = json.dumps(requests[0] if len(requests) == 1 else requests) + self._writer.write(payload.encode(self.encoding, "surrogateescape")) + + await self._writer.drain() + + for call in pipeline: + call.timeout_handle = self._loop.call_later( + call.timeout, + functools.partial( + self._handle_response, call.id, response=None, timeout=True + ) + ) + self._pending_calls[call.id] = call + + futures = [call.response for call in pipeline] + await asyncio.wait(futures, loop=self._loop) + return {call.id: call.response.result() for call in pipeline} + + async def close(self) -> None: + if self._listen_task is not None: + self._listen_task.cancel() + self._listen_task = None + + if self._writer is not None: + if self._writer.can_write_eof(): + self._writer.write_eof() + self._writer.close() + if self._reader is not None: + self._reader.feed_eof() + if not self._reader.at_eof(): + await self._reader.read() + self._reader = self._writer = None + + self._pending_calls = {} + + async def _reconnect(self) -> None: + await self.close() + await self.connect() + + async def apply(self, req: requests.Request, timeout: int = TIMEOUT): + method = _JSONRPC_API[req.__class__] + call = _JSONRPCCall(method.name, req.params, timeout=timeout) + reps_by_id = await self._jsonrpc_execute([call]) + return method.map_result(reps_by_id[call.id]) + + async def connect(self) -> None: + parts = urllib.parse.urlparse(self.url) + if parts.scheme == "unix+jsonrpc": + path = os.path.join(parts.netloc, parts.path).rstrip(os.path.sep) + open_connection = functools.partial( + asyncio.open_unix_connection, path + ) + elif parts.scheme == "tcp+jsonrpc": + open_connection = functools.partial( + asyncio.open_connection, parts.hostname, parts.port + ) + else: + raise ValueError("Unsupported url {}".format(self.url)) + + try: + self._reader, self._writer = await asyncio.wait_for( + open_connection(limit=self.read_size, loop=self._loop), + self.timeout, + loop=self._loop, + ) + self._listen_task = self._loop.create_task(self._listen()) + except Exception: + logger.error("Couldn't open {}".format(self.url)) + raise + + async def _listen(self) -> None: + buf = bytearray() + + while True: + chunk = await self._reader.read(self.READ_SIZE) + if not len(chunk): # EOF, reconnect + logger.info("EOF, reconnecting...") + await self._reconnect() + return + + buf += chunk + try: + json.loads(buf.decode(self.encoding, "ignore")) + except Exception: + continue + response = json.loads(buf.decode(self.encoding, "surrogateescape")) + buf = bytearray() + + batch = response if isinstance(response, list) else [response] + for response in batch: + id = response["id"] + + error = response.get("error") + if error is not None: + code = error.get("code") + msg = error.get("msg") + logger.warning("Error {}: {} - {}".format(id, code, msg)) + call = self._pending_calls.pop(id) + ex = exceptions.LightsClientError(msg) + call.response.set_exception(ex) + call.timeout_handle.cancel() + continue + + logger.info("Response {}: {}".format(id, response["result"])) + self._handle_response(id, response["result"]) + + def batch(self) -> "_AsyncJSONRPCBatch": + return _AsyncJSONRPCBatch(self) + + +# LightsClient could eventually point to a different but api-compatible class +# someday: +LightsClient = AsyncJSONRPCLightsClient + + +class _AsyncJSONRPCBatch: + + def __init__(self, client: AsyncJSONRPCLightsClient) -> None: + self.responses = None # type: Tuple[responses.Response, ...] + self._client = client + self._batch = [] # type: List[_JSONRPCCall] + + async def __aenter__(self) -> "_AsyncJSONRPCBatch": + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if exc_type is None: + reps_by_id = await self._client._jsonrpc_execute(self._batch) + self.responses = ( + _JSONRPC_API[req.__class__].map_result(reps_by_id[req.id]) + for req in self._batch + ) + + def apply( + self, + req: requests.Request, + timeout: int = AsyncJSONRPCLightsClient.TIMEOUT + ) -> None: + method = _JSONRPC_API[req.__class__] + call = _JSONRPCCall(method.name, req.params, timeout=timeout) + self._batch.append(call) + + +async def get_lightsd_unix_socket_async( + loop: asyncio.AbstractEventLoop = None, +) -> str: + process = await asyncio.create_subprocess_exec( + "lightsd", "--rundir", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + loop=loop, + ) + stdout, stderr = await process.communicate() + stdout = stdout.decode(locale.getpreferredencoding()).strip() + if process.returncode == 0 and stdout: + lightsdrundir = stdout + else: + lightsdrundir = "build" + logger.warning( + "Couldn't infer lightsd's runtime directory, is " + "lightsd installed? Trying {}…".format(lightsdrundir) + ) + + return "unix+jsonrpc://" + os.path.join(lightsdrundir, "socket") + + +async def create_async_lightsd_connection( + url: str = None, + loop: asyncio.AbstractEventLoop = None +) -> AsyncJSONRPCLightsClient: + if loop is None: + loop = asyncio.get_event_loop() + if url is None: + url = await get_lightsd_unix_socket_async(loop) + + c = AsyncJSONRPCLightsClient(url, loop=loop) + await c.connect() + return c + + +def create_lightsd_connection(url: str = None) -> None: + raise NotImplementedError("Sorry, no synchronous client available yet") diff --git a/clients/python/lightsc/lightsc/exceptions.py b/clients/python/lightsc/lightsc/exceptions.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/exceptions.py @@ -0,0 +1,28 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + + +class LightsError(Exception): + pass + + +class LightsClientError(LightsError): + pass + + +class LightsClientTimeoutError(LightsClientError): + pass diff --git a/clients/python/lightsc/lightsc/requests.py b/clients/python/lightsc/lightsc/requests.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/requests.py @@ -0,0 +1,65 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +from typing import ( + Any, + List, + TypeVar, +) + + +class Request: + + def __init__(self, *args: Any) -> None: + self.params = args + +RequestClass = TypeVar("RequestClass", bound=Request) + + +class SetLightFromHSBK(Request): + + def __init__( + self, + targets: List[str], + h: float, s: float, b: float, k: int, + transition_ms: int + ) -> None: + Request.__init__(self, targets, h, s, b, k, transition_ms) + + +class GetLightState(Request): + + def __init__(self, targets: List[str]) -> None: + Request.__init__(self, targets) + + +class PowerOff(Request): + + def __init__(self, targets: List[str]) -> None: + Request.__init__(self, targets) + + +class PowerOn(Request): + + def __init__(self, targets: List[str]) -> None: + Request.__init__(self, targets) + + +class PowerToggle(Request): + + def __init__(self, targets: List[str]) -> None: + Request.__init__(self, targets) diff --git a/clients/python/lightsc/lightsc/responses.py b/clients/python/lightsc/lightsc/responses.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/responses.py @@ -0,0 +1,41 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +from typing import ( + List, + TypeVar, +) + +from . import structs + + +class Response: + pass + +ResponseClass = TypeVar("ResponseClass", bound=Response) + + +class Bool(Response): + + def __init__(self, bool: bool) -> None: + self.value = bool + + +class LightsState(Response): + + def __init__(self, bulbs: List[structs.LightBulb]) -> None: + self.bulbs = bulbs diff --git a/clients/python/lightsc/lightsc/structs.py b/clients/python/lightsc/lightsc/structs.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/structs.py @@ -0,0 +1,42 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +from typing import ( + List, +) + + +class Struct: + pass + + +class LightBulb(Struct): + + def __init__( + self, + label: str, + power: bool, + h: float, s: float, b: float, k: int, + tags: List[str] + ) -> None: + self.label = label + self.power = power + self.h = h + self.s = s + self.b = b + self.k = k + self.tags = tags diff --git a/clients/python/lightsc/lightsc/view.py b/clients/python/lightsc/lightsc/view.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/view.py @@ -0,0 +1,32 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +import pprint + + +class LightsView: + + def __init__(self): + self.bulbs = [] + self.bulbs_by_label = {} + + def __str__(self): + return pprint.pformat(self.bulbs_by_label) + + def update(self, lights_state): + self.bulbs = lights_state.bulbs + self.bulbs_by_label = {b.label for b in lights_state.bulbs.items()} diff --git a/clients/python/lightsc/setup.py b/clients/python/lightsc/setup.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/setup.py @@ -0,0 +1,53 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lighstd. +# +# lighstd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lighstd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lighstd. If not, see <http://www.gnu.org/licenses/>. + +import setuptools + +version = "0.0.1.dev0" + +with open("README.rst", "r") as fp: + long_description = fp.read() + +setuptools.setup( + name="lightsc", + version=version, + description="A client to interact with lightsd", + long_description=long_description, + author="Louis Opter", + author_email="louis@opter.org", + packages=setuptools.find_packages(exclude=['tests', 'tests.*']), + include_package_data=True, + entry_points={ + "console_scripts": [], + }, + install_requires=[], + tests_require=[ + "doubles~=1.1.3", + "freezegun~=0.3.5", + "pytest~=3.0", + ], + extras_require={ + "dev": [ + "flake8", + "mypy-lang", + "ipython", + "pdbpp", + "pep8", + "typed-ast", + ], + }, +)