Mercurial > louis > mq > lightsd
view add_monolight.patch @ 524:154cc5d504b3
leftover
author | Louis Opter <kalessin@kalessin.fr> |
---|---|
date | Tue, 06 Dec 2016 00:50:30 -0800 |
parents | 48486249a3c5 |
children | de2d968228a0 |
line wrap: on
line source
# HG changeset patch # Parent 4e9a32c7035ac32ac88249ead2d14da461ece7dc Start an experimental GUI for a Monome 128 Varibright Written in Python >= 3.5. diff --git a/.hgignore b/.hgignore --- a/.hgignore +++ b/.hgignore @@ -2,3 +2,4 @@ .*\.py[co]$ ^build ^pcaps +.*\.egg-info$ diff --git a/apps/monolight/monolight/__init__.py b/apps/monolight/monolight/__init__.py new file mode 100644 diff --git a/apps/monolight/monolight/bulbs.py b/apps/monolight/monolight/bulbs.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/bulbs.py @@ -0,0 +1,96 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +import asyncio +import collections +import lightsc +import logging + +from typing import List + +from lightsc.requests import GetLightState +from lightsc.structs import LightBulb # noqa + +from . import grids + +DEFAULT_REFRESH_DELAY = 0.1 +KEEPALIVE_DELAY = 60 + +logger = logging.getLogger("monolight.bulbs") + +lightsd = None # type: lightsc.LightsClient + +bulbs_by_label = {} # type: Dict[str, LightBulb] +bulbs_by_group = collections.defaultdict(set) # type: Dict[str, Set[LightBulb]] + +_refresh_task = None # type: asyncio.Task + + +def iter_targets(targets: List[str]): + for target in targets: + if target.startswith("#"): + for bulb in bulbs_by_group.get(target[1:], set()): + yield bulb + elif target in bulbs_by_label: + yield bulbs_by_label[target] + + +async def _poll( + loop: asyncio.AbstractEventLoop, + refresh_delay_s: float +) -> None: + global bulbs_by_label, bulbs_by_group + + while True: + try: + state = await lightsd.apply(GetLightState(["*"])) + except lightsc.exceptions.LightsClientTimeoutError as ex: + logger.warning( + "lightsd timed out while trying to retrieve " + "the state of the bulbs: {}".format(ex) + ) + continue + + bulbs_by_label = {} + bulbs_by_group = collections.defaultdict(set) + for b in state.bulbs: + bulbs_by_label[b.label] = b + for t in b.tags: + bulbs_by_group[t].add(b) + + delay = refresh_delay_s if grids.running else KEEPALIVE_DELAY + await asyncio.sleep(delay, loop=loop) + + +async def start_lightsd_connection( + loop: asyncio.AbstractEventLoop, + lightsd_url: str, + refresh_delay_s: float = DEFAULT_REFRESH_DELAY, +) -> None: + global _refresh_task, lightsd + + lightsd = await lightsc.create_async_lightsd_connection(lightsd_url) + _refresh_task = loop.create_task(_poll(loop, refresh_delay_s)) + + +async def stop_all(loop: asyncio.AbstractEventLoop) -> None: + global _refresh_task, lightsd + + _refresh_task.cancel() + await asyncio.wait([_refresh_task], loop=loop) + await lightsd.close() + lightsd = _refresh_task = None diff --git a/apps/monolight/monolight/grids.py b/apps/monolight/monolight/grids.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/grids.py @@ -0,0 +1,228 @@ +# 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 enum +import functools +import logging +import monome + +from typing import TYPE_CHECKING, Any, Iterator, Tuple, NamedTuple, cast +from typing import List, Set # noqa + +from .types import Dimensions, Position +if TYPE_CHECKING: + from .ui.elements import UILayer # noqa + + +logger = logging.getLogger("monolight.grids") + +running = set() # type: Set[MonomeGrid] +running_event = None # type: asyncio.Event +_not_running_event = None # type: asyncio.Event + + +class KeyState(enum.IntEnum): + + DOWN = 1 + UP = 0 + + +KeyPress = NamedTuple("KeyPress", [ + ("grid", "MonomeGrid"), + ("position", Position), + ("state", KeyState), +]) + + +class LedLevel(enum.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 LedCanvas(collections.abc.Iterable): + + def __init__( + self, size: Dimensions, level: LedLevel = LedLevel.OFF + ) -> None: + self.size = size + self._levels = [level] * size.area + + def _index(self, offset: Position) -> int: + return self.size.width * offset.y + offset.x + + def set(self, offset: Position, level: LedLevel) -> None: + self._levels[self._index(offset)] = level + + def shift(self, offset: Position) -> "LedCanvas": + class _Proxy: + def __init__(_self, canvas: LedCanvas, shift: Position): + _self._canvas = canvas + _self._shift = shift + + def set(_self, offset: Position, level: LedLevel) -> None: + offset += _self._shift + return _self._canvas.set(offset, level) + + def shift(_self, offset: Position) -> LedCanvas: + return cast(LedCanvas, _Proxy(_self, offset)) + + def __getattr__(self, name: str) -> Any: + return self._canvas.__getattribute__(name) + # I guess some kind of interface would avoid the cast, but whatever: + return cast(LedCanvas, _Proxy(self, offset)) + + def get(self, offset: Position) -> LedLevel: + return self._levels[self._index(offset)] + + def __iter__(self) -> Iterator[Tuple[int, int, LedLevel]]: + for off_x in range(self.size.width): + for off_y in range(self.size.height): + yield off_x, off_y, self.get(Position(x=off_x, y=off_y)) + + +class AIOSCMonolightApp(monome.Monome): + + def __init__(self, loop: asyncio.AbstractEventLoop) -> None: + monome.Monome.__init__(self, "/monolight") + self._grid = None # type: MonomeGrid + self.loop = loop + + def ready(self) -> None: + self._grid = MonomeGrid(self) + running.add(self._grid) + logger.info("Grid {} ready".format(self.id)) + if len(running) == 1: + running_event.set() + _not_running_event.clear() + + def disconnect(self) -> None: + if len(running) == 1: + running_event.clear() + _not_running_event.set() + running.remove(self._grid) + self._grid.shutdown() + monome.Monome.disconnect(self) + logger.info("Grid {} disconnected".format(self.id)) + + def grid_key(self, x: int, y: int, s: int) -> None: + if self._grid is not None: + keypress = KeyPress(self._grid, Position(x, y), KeyState(s)) + self._grid.submit_input(keypress) + + +class MonomeGrid: + + def __init__(self, monome_app: AIOSCMonolightApp) -> None: + self.loop = monome_app.loop + self.size = Dimensions(height=monome_app.height, width=monome_app.width) + self.layers = [] # type: List[UILayer] + self._show_ui = asyncio.Event(loop=self.loop) + self._show_ui.set() + self._input_queue = asyncio.Queue(loop=self.loop) # type: asyncio.Queue + self._queue_get = None # type: asyncio.Future + self.monome = monome_app + self._led_buffer = monome.LedBuffer( + width=self.size.width, height=self.size.height + ) + + def shutdown(self): + self._queue_get.cancel() + self.show_ui = False + for layer in self.layers: + layer.shutdown() + self.monome.led_level_all(LedLevel.OFF.value) + + def submit_input(self, keypress: KeyPress) -> None: + self._input_queue.put_nowait(keypress) + + async def get_input(self) -> KeyPress: + self._queue_get = self.loop.create_task(self._input_queue.get()) + keypress = await asyncio.wait_for( + self._queue_get, timeout=None, loop=self.loop + ) + self._input_queue.task_done() + return keypress + + def _hide_ui(self) -> None: + self._show_ui.clear() + self.monome.led_level_all(LedLevel.OFF.value) + + def _display_ui(self) -> None: + self._show_ui.set() + self._led_buffer.render(self.monome) + + @property + def show_ui(self) -> bool: + return self._show_ui.is_set() + + @show_ui.setter + def show_ui(self, value: bool) -> None: + self._hide_ui() if value is False else self._display_ui() + + async def wait_ui(self) -> None: + await self._show_ui.wait() + + @property + def foreground_layer(self): + return self.layers[-1] if self.layers else None + + def display(self, canvas: LedCanvas) -> None: + for off_x, off_y, level in canvas: + self._led_buffer.led_level_set(off_x, off_y, level.value) + self._led_buffer.render(self.monome) + + +_serialosc = None + +async def start_serialosc_connection( + loop: asyncio.AbstractEventLoop, monome_id: str = "*", +) -> None: + global _serialosc, running_event, _not_running_event + + running_event = asyncio.Event(loop=loop) + _not_running_event = asyncio.Event(loop=loop) + _not_running_event.set() + App = functools.partial(AIOSCMonolightApp, loop) + _serialosc = await monome.create_serialosc_connection({monome_id: App}) + + +async def stop_all() -> None: + global running_event, _not_running_event + + if _serialosc is not None: + _serialosc.disconnect() + # copy the set since we're gonna modify it as we iter through it: + for grid in list(running): + grid.monome.disconnect() + await _not_running_event.wait() + running_event = _not_running_event = None diff --git a/apps/monolight/monolight/monolight.py b/apps/monolight/monolight/monolight.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/monolight.py @@ -0,0 +1,90 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +import asyncio +import click +import logging +import signal +import sys +import pdb +import traceback + +from . import bulbs, grids, ui + + +@click.command() +@click.option("--serialoscd-host", default="127.0.0.1") +@click.option("--serialoscd-port", type=click.IntRange(0, 2**16 - 1)) +@click.option("--monome-id", default="*", help="The id of the monome to use") +@click.option( + "--lightsd-url", help="tcp+jsonrpc://host:port or unix+jsonrpc:///a/path" +) +def main( + serialoscd_host: str, + serialoscd_port: int, + monome_id: str, + lightsd_url: str, +) -> None: + logging.basicConfig(level=logging.INFO) + logging.getLogger("monolight.ui").setLevel(logging.DEBUG) + logging.getLogger("monolight").setLevel(logging.DEBUG) + logging.getLogger("lightsc").setLevel(logging.WARN) + + # NOTE: this isn't good enough on Windows unless you pass --lightsd-url: + # discovering lightsd's socket involves using asyncio.subprocess which + # requires an IOCP event loop, which doesn't support UDP connections. + loop = asyncio.get_event_loop() + + click.echo("Connecting to serialoscd and lightsd...") + + loop.run_until_complete(asyncio.gather( + loop.create_task(bulbs.start_lightsd_connection(loop, lightsd_url)), + loop.create_task(grids.start_serialosc_connection(loop, monome_id)), + loop=loop, + )) + + click.echo("serialoscd running at {}:{}".format( + serialoscd_host, serialoscd_port + )) + click.echo("lightsd running at {}".format(bulbs.lightsd.url)) + + click.echo("Starting ui engine...") + + ui_task = ui.start(loop) + + if hasattr(loop, "add_signal_handler"): + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT): + loop.add_signal_handler(signum, ui_task.cancel) + + try: + loop.run_until_complete(ui_task) + click.echo("ui stopped, disconnecting from serialoscd and lightsd...") + except asyncio.CancelledError: + pass + except Exception as ex: + tb = "".join(traceback.format_exception(*sys.exc_info())) + click.echo(tb, err=True, nl=False) + click.echo("ui crashed, disconnecting from serialoscd and lightsd...") + pdb.post_mortem() + sys.exit(1) + finally: + loop.run_until_complete(asyncio.gather( + loop.create_task(grids.stop_all()), + loop.create_task(bulbs.stop_all(loop)), + loop=loop, + )) + loop.close() diff --git a/apps/monolight/monolight/types.py b/apps/monolight/monolight/types.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/types.py @@ -0,0 +1,71 @@ +# 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 itertools + +from typing import Iterator + + +class Position: + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y + + def __copy__(self) -> "Position": + return Position(self.x, self.y) + + __deepcopy__ = __copy__ + + def __repr__(self) -> str: + return "{}, {}".format(self.x, self.y) + + def __sub__(self, other: "Position") -> "Position": + return Position(x=self.x - other.x, y=self.y - other.y) + + def __add__(self, other: "Position") -> "Position": + return Position(x=self.x + other.x, y=self.y + other.y) + + +class Dimensions: + + def __init__(self, height: int, width: int) -> None: + self.height = height + self.width = width + + def __repr__(self) -> str: + return "height={}, width={}".format(self.height, self.width) + + def __sub__(self, other: "Dimensions") -> "Dimensions": + return Dimensions( + height=self.height - other.height, width=self.width - other.width + ) + + def __add__(self, other: "Dimensions") -> "Dimensions": + return Dimensions( + height=self.height + other.height, width=self.width + other.width + ) + + @property + def area(self) -> int: + return self.height * self.width + + def iter_area(self) -> Iterator[Position]: + positions = itertools.product(range(self.width), range(self.height)) + return itertools.starmap(Position, positions) + +TimeMonotonic = int diff --git a/apps/monolight/monolight/ui/__init__.py b/apps/monolight/monolight/ui/__init__.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +from .ui import start # noqa diff --git a/apps/monolight/monolight/ui/actions.py b/apps/monolight/monolight/ui/actions.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/actions.py @@ -0,0 +1,107 @@ +# 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 # noqa +import lightsc +import logging + +from typing import TYPE_CHECKING, List, Type, Union + +from .. import bulbs + +if TYPE_CHECKING: + from ..elements import UIComponent # noqa + +logger = logging.getLogger("monolight.ui.actions") + + +class Action: + + def __init__(self) -> None: + self.loop = None # type: asyncio.AbstractEventLoop + self._source = None # type: UIComponent + + def set_source(self, source: "UIComponent") -> "Action": + self.loop = source.loop + self._source = source + return self + + async def _run(self) -> None: + # NOTE: Must be re-entrant (which means that all attributes on + # self are read-only. + pass + + async def execute(self) -> None: + self._source.busy = True + try: + await self._run() + finally: + self._source.busy = False + + +class Lightsd(Action): + + # XXX: + # + # This isn't correct, as of now RequestType is just a "factory" that + # optionally takes a targets argument or not: + RequestType = Type[lightsc.requests.RequestClass] + RequestTypeList = List[RequestType] + + def __init__( + self, requests: RequestTypeList = None, targets: List[str] = None + ) -> None: + Action.__init__(self) + self._targets = targets or [] + self._batch = requests or [] # type: Lightsd.RequestTypeList + + def add_target(self, target: str) -> "Lightsd": + self._targets.append(target) + return self + + def add_request(self, type: RequestType) -> "Lightsd": + self._batch.append(type) + return self + + async def _run(self) -> None: + requests = [] + async with bulbs.lightsd.batch() as batch: + for RequestClass in self._batch: + if self._targets: + req = RequestClass(self._targets) + else: + req = RequestClass() + batch.append(req) + requests.append(req.__class__.__name__) + for ex in batch.exceptions: + logger.warning("Request {} failed on batch-[{}]".format( + ", ".join(requests) + )) + + +class Slide(Action): + + def __init__(self, step: Union[float, int]) -> None: + Action.__init__(self) + self.step = step + self._step = step + + def set_step(self, step: int) -> None: + self._step = step + + async def _run(self) -> None: + await self._source.update(self._step) diff --git a/apps/monolight/monolight/ui/elements/__init__.py b/apps/monolight/monolight/ui/elements/__init__.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/elements/__init__.py @@ -0,0 +1,33 @@ +# 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 UILayer # noqa +from .buttons import ( # noqa + Button, + PowerButton, +) +from .groups import ( # noqa + HSBKControlPad, + BulbControlPad, +) +from .sliders import ( # noqa + BrightnessSlider, + HueSlider, + KelvinSlider, + SaturationSlider, + Slider, +) diff --git a/apps/monolight/monolight/ui/elements/base.py b/apps/monolight/monolight/ui/elements/base.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/elements/base.py @@ -0,0 +1,259 @@ +# 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 copy +import enum +import logging +import os + +from typing import Dict, List + +from ... import grids +from ...types import Dimensions, Position, TimeMonotonic + +from .. import actions + +logger = logging.getLogger("monolight.ui.elements") + + +class UIComponentInsertionError(Exception): + pass + + +UIActionEnum = enum.Enum + + +class UIComponent: + + ACTION_QUEUE_SIZE = 1 + + def __init__( + self, + name: str, + offset: Position, + size: Dimensions, + loop: asyncio.AbstractEventLoop, + actions: Dict[UIActionEnum, actions.Action] = None, + ) -> None: + self.name = name + self.size = size + self.offset = offset + self.loop = loop + self.busy = False + self.children = set() # type: Set[UIComponent] + self.actions = actions if actions is not None else {} + for action in self.actions.values(): + action.set_source(self) + self.parent = None # type: UIComponent + + if loop is not None: + qsize = self.ACTION_QUEUE_SIZE + self._action_queue = asyncio.Queue(qsize) # type: asyncio.Queue + self._action_runner = loop.create_task(self._process_actions()) + self._action_queue_get = None # type: asyncio.Future + self._current_action = None # type: UIActionEnum + + self._nw_corner = offset - Position(1, 1) + self._se_corner = Position( + x=self.offset.x + self.size.width - 1, + y=self.offset.y + self.size.height - 1, + ) + + def __repr__(self, indent=None): + if self.name: + return "<{}(\"{}\", size=({!r}), offset=({!r})>".format( + self.__class__.__name__, self.name, self.size, self.offset + ) + return "<{}(size=({!r}), offset=({!r})>".format( + self.__class__.__name__, self.size, self.offset + ) + + def shutdown(self) -> None: + for children in self.children: + children.shutdown() + self._action_runner.cancel() + if self._action_queue_get is not None: + self._action_queue_get.cancel() + + def collides(self, other: "UIComponent") -> bool: + """Return True if ``self`` and ``other`` overlap in any way. + + .. important:: + + ``self`` and ``other`` must be in the same container otherwise + the result is undefined. + """ + + 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, + )) + + async def _process_actions(self) -> None: + current_action = None + next_action = None + while True: + tasks = [] + if next_action is None: + if current_action is None: + next_action = (await self._action_queue.get()).execute() + current_action = self.loop.create_task(next_action) + next_action = None + tasks.append(current_action) + self._action_queue_get = next_action = self.loop.create_task( + self._action_queue.get() + ) + tasks.append(next_action) + + done, pending = await asyncio.wait( + tasks, return_when=asyncio.FIRST_COMPLETED, loop=self.loop, + ) + + if current_action in done: + self._action_queue.task_done() + # always retrieve the result, we might have an error to raise: + current_action.result() + current_action = None + if next_action in done: + next_action = next_action.result() + if current_action is None: + current_action = self.loop.create_task( + next_action.execute() + ) + self._action_queue_get = next_action = None + + def draw(self, frame_ts_ms: TimeMonotonic, canvas: grids.LedCanvas) -> bool: + raise NotImplementedError + + def handle_input(self, offset: Position, value: grids.KeyState) -> None: + raise NotImplementedError + + +class _UIPosition(UIComponent): + + def __init__(self, position: Position) -> None: + UIComponent.__init__( + self, "_ui_position", position, Dimensions(1, 1), loop=None + ) + + +class _UIContainer(UIComponent): + + def __init__( + self, + name: str, + offset: Position, + size: Dimensions, + loop: asyncio.AbstractEventLoop + ) -> None: + UIComponent.__init__(self, name, offset, size, loop) + + def __repr__(self, indent=1) -> str: + linesep = ",{}{}".format(os.linesep, " " * indent) + return ( + "<{}(\"{}\", size=({!r}), offset=({!r}), " + "components=[{nl} {indent}{}{nl}{indent}])>".format( + self.__class__.__name__, + self.name, + self.size, + self.offset, + linesep.join( + component.__repr__(indent + 1) + for component in self.children + ), + indent=" " * (indent - 1), + nl=os.linesep, + ) + ) + + def fits(self, other: "UIComponent") -> bool: + """Return True if ``self`` has enough space to contain ``other``.""" + + return ( + other._se_corner.x < self.size.width and + other._se_corner.y < self.size.height + ) + + def insert(self, new: "UIComponent") -> None: + if new in self.children: + raise UIComponentInsertionError( + "{!r} is already part of {!r}".format(new, self) + ) + if not self.fits(new): + 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) + ) + + new.parent = self + self.children.add(new) + + def submit_input(self, offset: Position, value: grids.KeyState) -> bool: + if self.collides(_UIPosition(offset)): + self.handle_input(offset - self.offset, value) + return True + + return False + + def handle_input(self, offset: Position, value: grids.KeyState) -> None: + for component in self.children: + if component.collides(_UIPosition(offset)): + component.handle_input(offset - component.offset, value) + + def draw(self, frame_ts_ms: TimeMonotonic, canvas: grids.LedCanvas) -> bool: + dirty = False + for component in self.children: + vec = copy.copy(self.offset) + if not isinstance(component, _UIContainer): + vec += component.offset + shifted_canvas = canvas.shift(vec) + dirty = component.draw(frame_ts_ms, shifted_canvas) or dirty + return dirty + + +class UIGroup(_UIContainer): + + def __init__( + self, + name: str, + offset: Position, + size: Dimensions, + loop: asyncio.AbstractEventLoop, + members: List[UIComponent], + ) -> None: + UIComponent.__init__(self, name, offset, size, loop) + for member in members: + self.insert(member) + + +class UILayer(_UIContainer): + + def __init__( + self, name: str, size: Dimensions, loop: asyncio.AbstractEventLoop + ) -> None: + _UIContainer.__init__(self, name, Position(0, 0), size, loop) + self.canvas = grids.LedCanvas(self.size, grids.LedLevel.OFF) + + def render(self, frame_ts_ms: TimeMonotonic) -> bool: + return self.draw(frame_ts_ms, self.canvas) diff --git a/apps/monolight/monolight/ui/elements/buttons.py b/apps/monolight/monolight/ui/elements/buttons.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/elements/buttons.py @@ -0,0 +1,111 @@ +# 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 lightsc import requests +from typing import Dict, List + +from ... import bulbs, grids +from ...types import Dimensions, Position, TimeMonotonic + +from .. import actions + +from .base import UIActionEnum, UIComponent, logger + + +class Button(UIComponent): + + class ActionEnum(UIActionEnum): + + DOWN = 1 + UP = 0 + + # make the size configurable too? + def __init__( + self, + name: str, + offset: Position, + loop: asyncio.AbstractEventLoop, + actions: Dict[UIActionEnum, actions.Action], + ) -> None: + size = Dimensions(1, 1) + UIComponent.__init__(self, name, offset, size, loop, actions) + self._last_level = None # type: grids.LedLevel + + def draw(self, frame_ts_ms: TimeMonotonic, canvas: grids.LedCanvas) -> bool: + animate_busy = self.busy and frame_ts_ms % 1000 // 100 % 2 + level = grids.LedLevel.MEDIUM if animate_busy else grids.LedLevel.ON + + if level == self._last_level: + return False + + self._last_level = level + for offset in self.size.iter_area(): + canvas.set(offset, level) + + return True + + def handle_input(self, offset: Position, value: grids.KeyState) -> None: + if value is grids.KeyState.DOWN: + logger.info("Button {} pressed".format(self.name)) + action = self.actions.get(Button.ActionEnum.DOWN) + else: + logger.info("Button {} depressed".format(self.name)) + action = self.actions.get(Button.ActionEnum.UP) + + if action is None: + return + + try: + self._action_queue.put_nowait(action) + except asyncio.QueueFull: + logger.warning("{!r}: action queue full".format(self)) + + +class PowerButton(Button): + + def __init__( + self, + name: str, + offset: Position, + loop: asyncio.AbstractEventLoop, + targets: List[str], + ) -> None: + Button.__init__(self, name, offset, loop, actions={ + Button.ActionEnum.UP: actions.Lightsd( + requests=[requests.PowerToggle], targets=targets + ) + }) + self.targets = targets + + def draw(self, frame_ts_ms: TimeMonotonic, canvas: grids.LedCanvas) -> bool: + if self.busy and frame_ts_ms % 1000 // 100 % 2: + level = grids.LedLevel.MEDIUM + elif any(bulb.power for bulb in bulbs.iter_targets(self.targets)): + level = grids.LedLevel.ON + else: + level = grids.LedLevel.VERY_LOW_3 + + if level == self._last_level: + return False + + self._last_level = level + for offset in self.size.iter_area(): + canvas.set(offset, level) + + return True diff --git a/apps/monolight/monolight/ui/elements/groups.py b/apps/monolight/monolight/ui/elements/groups.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/elements/groups.py @@ -0,0 +1,77 @@ +# 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 + +from typing import List + +from ...types import Dimensions, Position + +from .base import UIGroup +from .buttons import PowerButton +from .sliders import BrightnessSlider, HueSlider, KelvinSlider, SaturationSlider + + +class HSBKControlPad(UIGroup): + + def __init__( + self, + name: str, + offset: Position, + sliders_size: Dimensions, + loop: asyncio.AbstractEventLoop, + targets: List[str], + ) -> None: + sliders = [ + functools.partial(HueSlider, name="hue"), + functools.partial(SaturationSlider, name="saturation"), + functools.partial(BrightnessSlider, name="brightness"), + functools.partial(KelvinSlider, name="temperature"), + ] + sliders = [ + Slider( + offset=Position(i, 0), + size=sliders_size, + loop=loop, + targets=targets, + ) + for i, Slider in enumerate(sliders) + ] + group_size = Dimensions(width=len(sliders), height=sliders_size.height) + UIGroup.__init__(self, name, offset, group_size, loop, sliders) + + +class BulbControlPad(UIGroup): + + def __init__( + self, + name: str, + offset: Position, + loop: asyncio.AbstractEventLoop, + targets: List[str], + sliders_size: Dimensions, + ) -> None: + power_btn = PowerButton("toggle power", Position(0, 0), loop, targets) + hsbk_pad = HSBKControlPad( + "hsbk pad", Position(0, 1), sliders_size, loop, targets + ) + + group_size = Dimensions(width=0, height=1) + hsbk_pad.size + UIGroup.__init__(self, name, offset, group_size, loop, [ + power_btn, hsbk_pad, + ]) diff --git a/apps/monolight/monolight/ui/elements/sliders.py b/apps/monolight/monolight/ui/elements/sliders.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/elements/sliders.py @@ -0,0 +1,272 @@ +# 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 lightsc +import operator +import statistics +import time + +from typing import Any, Callable, Iterable, List, NamedTuple, TypeVar + +from ... import bulbs, grids +from ...types import Dimensions, Position, TimeMonotonic + +from .. import actions + +from .base import UIActionEnum, UIComponent, logger + + +class SliderTraits: + """Configure the SliderBase class. + + :param gather_fn: a function returning the data observed on the + targets associated with this slider. + :param consolidation_fn: a function returning the current value of + this slider using the data returned by gather_fn. + :param scatter_fn: an async function that can apply the value of the slider + to the targets it tracks. + """ + + Controls = NamedTuple( + "Controls", [("coarse", float), ("fine", float)] + ) + + def __init__( + self, + range: range, + controls: Controls, + gather_fn: Callable[[List[str]], List[Any]], + consolidate_fn: Callable[[List[Any]], float], + scatter_fn: Callable[[List[str], Any, int], None], + ) -> None: + self.RANGE = range + self.controls = controls + self.gather = gather_fn + self.consolidate = consolidate_fn + self.scatter = scatter_fn + + +class Slider(UIComponent): + """Base slider implementation. + + :param size: the size of the slider. + :param offset: position of the slider in within its parent component. + :param targets: the list of targets this slider is tracking. + + .. note:: Only vertical sliders of width 1 are currently supported. + """ + + class ActionEnum(UIActionEnum): + + COARSE_INC = 0 + FINE_INC = 1 + FINE_DEC = 2 + COARSE_DEC = 3 + + def __init__( + self, + name: str, + offset: Position, + size: Dimensions, + loop: asyncio.AbstractEventLoop, + targets: List[str], + traits: SliderTraits, + ) -> None: + controls = traits.controls + UIComponent.__init__(self, name, offset, size, loop, { + Slider.ActionEnum.COARSE_INC: actions.Slide(controls.coarse), + Slider.ActionEnum.FINE_INC: actions.Slide(controls.fine), + Slider.ActionEnum.FINE_DEC: actions.Slide(-controls.fine), + Slider.ActionEnum.COARSE_DEC: actions.Slide(-controls.coarse), + }) + self.value = float(traits.RANGE.start) + self.targets = targets + self.traits = traits + + self._action_map = { + (0, size.height - 1): Slider.ActionEnum.COARSE_DEC, + (0, size.height - 2): Slider.ActionEnum.FINE_DEC, + (0, 0): Slider.ActionEnum.COARSE_INC, + (0, 1): Slider.ActionEnum.FINE_INC, + } + self._steps = size.area * (grids.LedLevel.ON + 1) + self._steps_per_button = self._steps / size.height + + def handle_input(self, offset: Position, value: grids.KeyState) -> None: + logger.info("Slider {} pressed at {}".format(self.name, offset)) + if value is not grids.KeyState.UP: + return + + action = self.actions.get(self._action_map.get((offset.x, offset.y))) + logger.info("Slider {} action = {}".format( + self.name, self._action_map.get((offset.x, offset.y)) + )) + + if action is None: + return + + try: + self._action_queue.put_nowait(action) + except asyncio.QueueFull: + logger.warning("Slider {} action queue full".format(self.name)) + + def draw(self, frame_ts_ms: TimeMonotonic, canvas: grids.LedCanvas) -> bool: + new_value = self.traits.consolidate(self.traits.gather(self.targets)) + if new_value is None or new_value == self.value: + return False + + step = (new_value - self.traits.RANGE.start) / ( + (self.traits.RANGE.stop - self.traits.RANGE.start) + / self._steps + ) + logger.info( + "Slider {} updated from {}/{max} to {}/{max} ({:.4}/{})".format( + self.name, self.value, new_value, step, self._steps, + max=self.traits.RANGE.stop, + ) + ) + self.value = new_value + + on_range = range( + self.size.height - 1, + self.size.height - 1 - int(step // self._steps_per_button), + -1 + ) + y = on_range.start + for each in on_range: + canvas.set(Position(0, y), grids.LedLevel.ON) + y -= 1 + if y >= 0: + level = grids.LedLevel(int(step % self._steps_per_button)) + canvas.set(Position(0, y), level) + y -= 1 + for y in range(y, -1, -1): + canvas.set(Position(0, y), grids.LedLevel.OFF) + + return True + + async def update(self, change: int, transition_ms: int = 600) -> None: + if change == 0: + return + + # min/max could eventually be traits.overflow and traits.underflow + if change > 0: + new_value = min(self.value + change, float(self.traits.RANGE.stop)) + else: + new_value = max(self.value + change, float(self.traits.RANGE.start)) + + logger.info("Slider {} moving to {}".format(self.name, new_value)) + + scatter_starts_at = time.monotonic() + await self.traits.scatter(self.targets, new_value, transition_ms) + scatter_exec_time = time.monotonic() - scatter_starts_at + + transition_remaining = transition_ms / 1000 - scatter_exec_time + if transition_remaining > 0: + await asyncio.sleep(transition_remaining) + +T = TypeVar("T") + + +def _gather_fn( + getter: Callable[[lightsc.structs.LightBulb], T], targets: List[str] +) -> Iterable[T]: + return map(getter, bulbs.iter_targets(targets)) + +gather_hue = functools.partial(_gather_fn, operator.attrgetter("h")) +gather_saturation = functools.partial(_gather_fn, operator.attrgetter("s")) +gather_brightness = functools.partial(_gather_fn, operator.attrgetter("b")) +gather_temperature = functools.partial(_gather_fn, operator.attrgetter("k")) + + +def mean_or_none(data: Iterable[T]) -> T: + try: + return statistics.mean(data) + except statistics.StatisticsError: # no data + return None + + +# NOTE: +# +# This will become easier once lightsd supports updating one parameter +# independently from the others: +async def _scatter_fn( + setter: Callable[ + [lightsc.structs.LightBulb, T, int], + lightsc.requests.Request + ], + targets: List[str], + value: T, + transition_ms: int, +) -> None: + async with bulbs.lightsd.batch() as batch: + for target in bulbs.iter_targets(targets): + batch.append(setter(target, value, transition_ms)) + +scatter_hue = functools.partial( + _scatter_fn, lambda b, h, t: lightsc.requests.SetLightFromHSBK( + [b.label], h, b.s, b.b, b.k, transition_ms=t, + ) +) +scatter_saturation = functools.partial( + _scatter_fn, lambda b, s, t: lightsc.requests.SetLightFromHSBK( + [b.label], b.h, s, b.b, b.k, transition_ms=t, + ) +) +scatter_brightness = functools.partial( + _scatter_fn, lambda b, br, t: lightsc.requests.SetLightFromHSBK( + [b.label], b.h, b.s, br, b.k, transition_ms=t, + ) +) +scatter_temperature = functools.partial( + _scatter_fn, lambda b, k, t: lightsc.requests.SetLightFromHSBK( + [b.label], b.h, b.s, b.b, int(k), transition_ms=t, + ) +) + +HueSlider = functools.partial(Slider, traits=SliderTraits( + range=lightsc.constants.HUE_RANGE, + controls=SliderTraits.Controls( + coarse=lightsc.constants.HUE_RANGE.stop // 20, fine=1 + ), + gather_fn=gather_hue, + consolidate_fn=mean_or_none, + scatter_fn=scatter_hue, +)) +SaturationSlider = functools.partial(Slider, traits=SliderTraits( + range=lightsc.constants.SATURATION_RANGE, + controls=SliderTraits.Controls(coarse=0.1, fine=0.01), + gather_fn=gather_saturation, + consolidate_fn=mean_or_none, + scatter_fn=scatter_saturation, +)) +BrightnessSlider = functools.partial(Slider, traits=SliderTraits( + range=lightsc.constants.BRIGHTNESS_RANGE, + controls=SliderTraits.Controls(coarse=0.1, fine=0.01), + gather_fn=gather_brightness, + consolidate_fn=mean_or_none, + scatter_fn=scatter_brightness, +)) +KelvinSlider = functools.partial(Slider, traits=SliderTraits( + range=lightsc.constants.KELVIN_RANGE, + controls=SliderTraits.Controls(coarse=500, fine=100), + gather_fn=gather_temperature, + consolidate_fn=mean_or_none, + scatter_fn=scatter_temperature, +)) diff --git a/apps/monolight/monolight/ui/layers.py b/apps/monolight/monolight/ui/layers.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/layers.py @@ -0,0 +1,120 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lightsd. +# +# lightsd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lightsd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lightsd. If not, see <http://www.gnu.org/licenses/>. + +import asyncio +import functools +import logging + +from lightsc import requests + +from .. import grids +from ..types import Dimensions, Position + +from . import actions +from .elements import ( + Button, + PowerButton, + UILayer, + groups, +) + +logger = logging.getLogger("monolight.ui.layers") + + +class _ToggleUI(actions.Action): + + def __init__(self, grid: grids.MonomeGrid) -> None: + actions.Action.__init__(self) + self._grid = grid + + async def _run(self) -> None: + self._grid.show_ui = not self._grid.show_ui + + +def root(loop: asyncio.AbstractEventLoop, grid: grids.MonomeGrid) -> UILayer: + foreground_layer = UILayer("root", grid.size, loop) + + foreground_layer.insert(Button("toggle ui", Position(15, 7), loop, actions={ + Button.ActionEnum.UP: _ToggleUI(grid), + })) + + # some shortcuts: + foreground_layer.insert(Button("off *", Position(0, 7), loop, actions={ + Button.ActionEnum.UP: actions.Lightsd( + requests=[requests.PowerOff], targets=["*"] + ) + })) + foreground_layer.insert(Button("on *", Position(1, 7), loop, actions={ + Button.ActionEnum.UP: actions.Lightsd( + requests=[requests.PowerOn], targets=["*"] + ) + })) + foreground_layer.insert( + Button("4000k kitchen", Position(2, 7), loop, actions={ + Button.ActionEnum.UP: actions.Lightsd(requests=[ + functools.partial( + requests.SetLightFromHSBK, + ["#kitchen"], 0.0, 0.0, 1.0, 4000, 1000, + ), + functools.partial(requests.PowerOn, ["#kitchen"]), + ]) + }) + ) + foreground_layer.insert(Button("orange", Position(3, 7), loop, actions={ + Button.ActionEnum.UP: actions.Lightsd(requests=[ + functools.partial( + requests.SetLightFromHSBK, + ["#tower"], 37.469443, 1.0, 0.25, 3500, 600, + ), + functools.partial( + requests.SetLightFromHSBK, + ["fugu", "buzz"], 47.469443, 0.2, 0.2, 3500, 600, + ), + functools.partial( + requests.SetLightFromHSBK, + ["candle"], 47.469443, 0.2, 0.15, 3500, 600, + ), + functools.partial(requests.PowerOn, ["#br"]) + ]), + })) + foreground_layer.insert(PowerButton( + "#br", Position(4, 7), loop, targets=["#br"] + )) + foreground_layer.insert(PowerButton( + "white", Position(5, 7), loop, targets=["white"] + )) + + # some control blocks: + BulbControlPad = functools.partial( + groups.BulbControlPad, + loop=loop, + sliders_size=Dimensions(width=1, height=6), + ) + foreground_layer.insert(BulbControlPad( + "#kitchen control", Position(0, 0), targets=["#kitchen"], + )) + foreground_layer.insert(BulbControlPad( + "#tower control", Position(4, 0), targets=["#tower"], + )) + foreground_layer.insert(BulbControlPad( + "fugu control", Position(8, 0), targets=["fugu"], + )) + foreground_layer.insert(BulbControlPad( + "candle control", Position(12, 0), targets=["candle"], + )) + + return foreground_layer 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,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 logging +import time + +from typing import Tuple # noqa + +from .. import grids + +from . import layers + +DEFAULT_FRAMERATE = 40 + +logger = logging.getLogger("monolight.ui") + + +async def _ui_refresh(loop: asyncio.AbstractEventLoop, framerate: int) -> None: + while True: + if not grids.running_event.is_set(): + await grids.running_event.wait() + + if not any(grid.show_ui for grid in grids.running): + # TODO: handle clean-up when we get ^C while in there: + await asyncio.wait( + [grid.wait_ui() for grid in grids.running], + return_when=asyncio.FIRST_COMPLETED, + loop=loop + ) + + render_starts_at = time.monotonic() + + for grid in grids.running: + if not grid.show_ui: + continue + + layer = grid.foreground_layer + if layer is None: + layer = layers.root(loop, grid) + grid.layers.insert(0, layer) + logger.info("UI initialized on grid {}: {!r}".format( + grid.monome.id, layer + )) + + if layer.render(frame_ts_ms=int(time.monotonic() * 1000)): + logger.info("Refreshing UI on grid {}".format(grid.monome.id)) + grid.display(layer.canvas) + + render_latency = time.monotonic() - render_starts_at + # The plan is to have lightsd push updates and then make + # something smarter than this: + await asyncio.sleep(1000 / framerate / 1000 - render_latency, loop=loop) + + +async def _process_inputs(loop: asyncio.AbstractEventLoop) -> None: + while True: + if not grids.running_event.is_set(): + await grids.running_event.wait() + + done, pending = await asyncio.wait( + [grid.get_input() for grid in grids.running], + return_when=asyncio.FIRST_COMPLETED, + loop=loop, + ) # type: Tuple[Set[asyncio.Future], Set[asyncio.Future]] + keypresses = [] + for future in done: + try: + keypresses.append(future.result()) + except asyncio.CancelledError: + continue + for grid, position, value in keypresses: + logger.info("Keypress {} on grid {} at {}".format( + value, grid.monome.id, position + )) + if grid.foreground_layer is not None: + grid.foreground_layer.submit_input(position, value) + + +def start( + loop: asyncio.AbstractEventLoop, framerate: int = DEFAULT_FRAMERATE +) -> asyncio.Future: + return asyncio.gather( + loop.create_task(_ui_refresh(loop, framerate)), + loop.create_task(_process_inputs(loop)), + loop=loop, + ) diff --git a/apps/monolight/setup.py b/apps/monolight/setup.py new file mode 100644 --- /dev/null +++ b/apps/monolight/setup.py @@ -0,0 +1,54 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lighstd. +# +# lighstd is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# lighstd is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with lighstd. If not, see <http://www.gnu.org/licenses/>. + +import setuptools + +version = "0.0.1.dev0" + +setuptools.setup( + name="monolight", + version=version, + description="A Monome UI to control smart bulbs using lightsd", + author="Louis Opter", + author_email="louis@opter.org", + packages=setuptools.find_packages(exclude=['tests', 'tests.*']), + include_package_data=True, + entry_points={ + "console_scripts": [ + "monolight = monolight.monolight:main", + ], + }, + install_requires=[ + "click~=6.6", + "pymonome~=0.8.2", + ], + tests_require=[ + "doubles~=1.1.3", + "freezegun~=0.3.5", + "pytest~=3.0", + ], + extras_require={ + "dev": [ + "flake8", + "mypy-lang", + "ipython", + "pdbpp", + "pep8", + "typed-ast", + ], + }, +) diff --git a/clients/python/lightsc/README.rst b/clients/python/lightsc/README.rst new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/README.rst @@ -0,0 +1,63 @@ +A Python client to control your smart bulbs through lightsd +=========================================================== + +lightsd_ is a daemon (background service) to control your LIFX_ WiFi "smart" +bulbs. This package allows you to make RPC calls to lightsd to control your +light bulbs from Python. It is built on top of the ``asyncio`` module and +requires Python ≥ 3.5: + +.. code-block:: python + + import asyncio + import click + + from lightsc import LightsView, create_async_lightsd_connection + from lightsc.requests import ( + GetLightState, + PowerOff, + PowerOn, + SetLightFromHSBK, + ) + + async def example(url, targets): + async with create_async_lightsd_connection(url) as client: + click.echo("Connected to lightsd running at {}".format(client.url)) + + view = LightsView() + view.update(await client.apply(GetLightState(targets)) + click.echo("Discovered bulbs: {}".format(view)) + + transition_ms = 600 + red_hsbk = (0., 1., 1., 3500) + click.echo("Turning all bulbs to red in {}ms...".format(transition_ms)) + async with client.batch() as batch: + batch.append(PowerOn(targets)) + batch.append(SetLightFromHSBK(targets, *red_hsbk, transition_ms=transition_ms)) + + click.echo("Restoring original state") + async with client.batch() as batch: + for b in view.bulbs: + PowerState = PowerOn if b.power else PowerOff + hsbk = (b.h, b.s, b.b, b.k) + + batch.append(PowerState([b.label])) + batch.append(SetLightFromHSBK([b.label], *hsbk, transition_ms=transition_ms)) + + @click.command() + @click.option("--lightsd-url", help="supported schemes: tcp+jsonrpc://, unix+jsonrpc://") + @click.argument("bulb_targets", nargs=-1, required=True) + def main(lightsd_url, bulb_targets) + """This example will turn all your bulbs to red before restoring their + original state. + + If an URL is not provided this script will attempt to connect to + lightsd's UNIX socket. + """ + + evl = asyncio.get_event_loop() + evl.run_until_complete(evl.create_task(example(lightsd_url, bulb_targets))) + +.. _lightsd: https://www.lightsd.io/ +.. _LIFX: http://lifx.co/ + +.. vim: set tw=80 spelllang=en spell: diff --git a/clients/python/lightsc/lightsc/__init__.py b/clients/python/lightsc/lightsc/__init__.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/__init__.py @@ -0,0 +1,43 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from . import ( # noqa + client, + constants, + exceptions, + requests, + responses, + structs, +) +from .client import ( # noqa + LightsClient, + LightsCommandBatch, + create_lightsd_connection, + create_async_lightsd_connection, +) diff --git a/clients/python/lightsc/lightsc/client.py b/clients/python/lightsc/lightsc/client.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/client.py @@ -0,0 +1,391 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import asyncio +import functools +import json +import locale +import logging +import os +import urllib +import uuid + +from typing import ( + Any, + Callable, + Dict, + List, + NamedTuple, + Sequence, + Tuple, +) +from typing import Type # noqa + +from . import ( + exceptions, + requests, + responses, + structs, +) + +logger = logging.getLogger("lightsc.client") + + +_JSONRPCMethod = NamedTuple("_JSONRPCMethod", [ + ("name", str), + ("map_result", Callable[[Any], responses.Response]), +]) +_JSONRPC_API = { + requests.GetLightState: _JSONRPCMethod( + name="get_light_state", + map_result=lambda result: responses.LightsState([ + structs.LightBulb( + b["label"], b["power"], *b["hsbk"], tags=b["tags"] + ) for b in result + ]) + ), + requests.SetLightFromHSBK: _JSONRPCMethod( + name="set_light_from_hsbk", + map_result=lambda result: responses.Bool(result) + ), + requests.PowerOn: _JSONRPCMethod( + name="power_on", + map_result=lambda result: responses.Bool(result) + ), + requests.PowerOff: _JSONRPCMethod( + name="power_off", + map_result=lambda result: responses.Bool(result) + ), + requests.PowerToggle: _JSONRPCMethod( + name="power_toggle", + map_result=lambda result: responses.Bool(result) + ), +} # type: Dict[Type[requests.RequestClass], _JSONRPCMethod] + + +class _JSONRPCCall: + + def __init__( + self, method: str, params: Sequence[Any], timeout: int = None + ) -> None: + self.id = str(uuid.uuid4()) + self.method = method + self.params = params + self.timeout = timeout + self.timeout_handle = None # type: asyncio.Handle + self.request = { + "id": self.id, + "jsonrpc": "2.0", + "method": method, + "params": params, + } + self.response = asyncio.Future() # type: asyncio.futures.Future + + @property + def response_or_exception(self) -> Any: + ex = self.response.exception() + return ex if ex is not None else self.response.result() + + +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_or_exception for call in pipeline} + + async def close(self) -> None: + if self._listen_task is not None: + self._listen_task.cancel() + await asyncio.wait([self._listen_task], loop=self._loop) + self._listen_task = None + + if self._writer is not None: + if self._writer.can_write_eof(): + self._writer.write_eof() + self._writer.close() + if self._reader is not None: + self._reader.feed_eof() + if not self._reader.at_eof(): + await self._reader.read() + self._reader = self._writer = None + + self._pending_calls = {} + + async def _reconnect(self) -> None: + await self.close() + await self.connect() + + async def apply(self, req: requests.Request, timeout: int = TIMEOUT): + method = _JSONRPC_API[req.__class__] + call = _JSONRPCCall(method.name, req.params, timeout=timeout) + result = (await self._jsonrpc_execute([call]))[call.id] + if isinstance(result, Exception): + raise result + return method.map_result(result) + + 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: + # FIXME: + # + # This method is fucked, we need to add a real streaming mode on + # lightsd's side and then an async version of ijson: + + buf = bytearray() # those bufs need to be bound to some max size + sbuf = str() + + while True: + chunk = await self._reader.read(self.READ_SIZE) + if not len(chunk): # EOF, reconnect + logger.info("EOF, reconnecting...") + # XXX: deadlock within the close call in _reconnect? (and if + # that's the case maybe you can use an event or something). + await self._reconnect() + return + + buf += chunk + try: + sbuf += buf.decode(self.encoding, "strict") # strict is fucked + except UnicodeError: + continue + buf = bytearray() + + while sbuf: + # and this is completely fucked: + try: + response = json.loads(sbuf) + sbuf = str() + except Exception: + def find_response(delim: str) -> Tuple[Dict[str, Any], str]: + offset = sbuf.find(delim) + while offset != -1: + try: + response = json.loads(sbuf[:offset + 1]) + return response, sbuf[offset + 1:] + except Exception: + offset = sbuf.find(delim, offset + 2) + return None, sbuf + + for delim in {"}{", "}[", "]{", "]["}: + response, sbuf = find_response(delim) + if response is not None: + break # yay! + else: + break # need more data + + 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("message") + logger.warning("Error on request {}: {} - {}".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: List[responses.Response] + self.exceptions = None # type: List[Exception] + self._client = client + self._batch = [] # type: List[Tuple[_JSONRPCMethod, _JSONRPCCall]] + + async def __aenter__(self) -> "_AsyncJSONRPCBatch": + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if exc_type is None: + await self.execute() + + def append( + self, + req: requests.Request, + timeout: int = AsyncJSONRPCLightsClient.TIMEOUT + ) -> None: + method = _JSONRPC_API[req.__class__] + call = _JSONRPCCall(method.name, req.params, timeout=timeout) + self._batch.append((method, call)) + + async def execute(self) -> None: + resp_by_id = await self._client._jsonrpc_execute([ + call for method, call in self._batch + ]) + self.responses = [] + self.exceptions = [] + for method, call in self._batch: + raw_resp = resp_by_id[call.id] + if isinstance(raw_resp, Exception): + self.exceptions.append(raw_resp) + else: + self.responses.append(method.map_result(raw_resp)) + + +LightsCommandBatch = _AsyncJSONRPCBatch + + +async def get_lightsd_unix_socket_async( + loop: asyncio.AbstractEventLoop = None, +) -> str: + process = await asyncio.create_subprocess_exec( + "lightsd", "--rundir", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + loop=loop, + ) + stdout, stderr = await process.communicate() + stdout = stdout.decode(locale.getpreferredencoding()).strip() + if process.returncode == 0 and stdout: + lightsdrundir = stdout + else: + lightsdrundir = "build" + logger.warning( + "Couldn't infer lightsd's runtime directory, is " + "lightsd installed? Trying {}…".format(lightsdrundir) + ) + + return "unix+jsonrpc://" + os.path.join(lightsdrundir, "socket") + + +async def create_async_lightsd_connection( + url: str = None, + loop: asyncio.AbstractEventLoop = None +) -> AsyncJSONRPCLightsClient: + if loop is None: + loop = asyncio.get_event_loop() + if url is None: + url = await get_lightsd_unix_socket_async(loop) + + c = AsyncJSONRPCLightsClient(url, loop=loop) + await c.connect() + return c + + +def create_lightsd_connection(url: str = None) -> None: + raise NotImplementedError("Sorry, no synchronous client available yet") diff --git a/clients/python/lightsc/lightsc/constants.py b/clients/python/lightsc/lightsc/constants.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/constants.py @@ -0,0 +1,36 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# In the future we might need something a bit more complex than range so we can +# support float values (via the decimal package I guess): + +HUE_RANGE = range(0, 360) +KELVIN_RANGE = range(2500, 9000) +BRIGHTNESS_RANGE = range(0, 1) +SATURATION_RANGE = range(0, 1) diff --git a/clients/python/lightsc/lightsc/exceptions.py b/clients/python/lightsc/lightsc/exceptions.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/exceptions.py @@ -0,0 +1,40 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + + +class LightsError(Exception): + pass + + +class LightsClientError(LightsError): + pass + + +class LightsClientTimeoutError(LightsClientError): + pass diff --git a/clients/python/lightsc/lightsc/requests.py b/clients/python/lightsc/lightsc/requests.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/requests.py @@ -0,0 +1,77 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from typing import ( + Any, + List, + TypeVar, +) + + +class Request: + + def __init__(self, *args: Any) -> None: + self.params = args + +RequestClass = TypeVar("RequestClass", bound=Request) + + +class SetLightFromHSBK(Request): + + def __init__( + self, + targets: List[str], + h: float, s: float, b: float, k: int, + transition_ms: int + ) -> None: + Request.__init__(self, targets, h, s, b, k, transition_ms) + + +class GetLightState(Request): + + def __init__(self, targets: List[str]) -> None: + Request.__init__(self, targets) + + +class PowerOff(Request): + + def __init__(self, targets: List[str]) -> None: + Request.__init__(self, targets) + + +class PowerOn(Request): + + def __init__(self, targets: List[str]) -> None: + Request.__init__(self, targets) + + +class PowerToggle(Request): + + def __init__(self, targets: List[str]) -> None: + Request.__init__(self, targets) diff --git a/clients/python/lightsc/lightsc/responses.py b/clients/python/lightsc/lightsc/responses.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/responses.py @@ -0,0 +1,53 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from typing import ( + List, + TypeVar, +) + +from . import structs + + +class Response: + pass + +ResponseClass = TypeVar("ResponseClass", bound=Response) + + +class Bool(Response): + + def __init__(self, bool: bool) -> None: + self.value = bool + + +class LightsState(Response): + + def __init__(self, bulbs: List[structs.LightBulb]) -> None: + self.bulbs = bulbs diff --git a/clients/python/lightsc/lightsc/structs.py b/clients/python/lightsc/lightsc/structs.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/structs.py @@ -0,0 +1,60 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from typing import ( + List, +) + + +class Struct: + pass + + +class LightBulb(Struct): + + def __init__( + self, + label: str, + power: bool, + h: float, s: float, b: float, k: int, + tags: List[str] + ) -> None: + self.label = label + self.power = power + self.h = h + self.s = s + self.b = b + self.k = k + self.tags = tags + + def __repr__(self) -> str: + return "<{}(label={}, power={}, hsbk=({}, {}, {}, {}), tags={}".format( + self.__class__.__name__, self.label, self.power, + self.h, self.s, self.b, self.k, self.tags + ) diff --git a/clients/python/lightsc/setup.py b/clients/python/lightsc/setup.py new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/setup.py @@ -0,0 +1,65 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import setuptools + +version = "0.0.1.dev0" + +with open("README.rst", "r") as fp: + long_description = fp.read() + +setuptools.setup( + name="lightsc", + version=version, + description="A client to interact with lightsd", + long_description=long_description, + author="Louis Opter", + author_email="louis@opter.org", + packages=setuptools.find_packages(exclude=['tests', 'tests.*']), + include_package_data=True, + entry_points={ + "console_scripts": [], + }, + install_requires=[], + tests_require=[ + "doubles~=1.1.3", + "freezegun~=0.3.5", + "pytest~=3.0", + ], + extras_require={ + "dev": [ + "flake8", + "mypy-lang", + "ipython", + "pdbpp", + "pep8", + "typed-ast", + ], + }, +) diff --git a/lifx/wire_proto.c b/lifx/wire_proto.c --- a/lifx/wire_proto.c +++ b/lifx/wire_proto.c @@ -95,7 +95,7 @@ LGTD_LIFX_WIRE_PRINT_TARGET(hdr, target); lgtd_info( "%s <-- %s - (Unimplemented, header info: " - "addressable=%d, tagged=%d, protocol=%d, target=%s", + "addressable=%d, tagged=%d, protocol=%d, target=%s)", pkt_info->name, gw->peeraddr, addressable, tagged, protocol, target ); }