Mercurial > louis > mq > lightsd
view add_monolight.patch @ 516:47f016a40cf7
got that slider moving
author | Louis Opter <kalessin@kalessin.fr> |
---|---|
date | Tue, 08 Nov 2016 00:50:01 -0800 |
parents | 21e7fc05e967 |
children | 24a8464934ff |
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,186 @@ +# 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, Iterator, Tuple, NamedTuple +from typing import List, Set # noqa + +from .types import Dimensions, Position +if TYPE_CHECKING: + from .ui.elements.layer import Layer # 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 LedSprite(collections.abc.Iterable): # TODO: make it a real Sequence + + 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 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: AIOSCMonolightApp) -> None: + self.loop = monome.loop + self.size = Dimensions(height=monome.height, width=monome.width) + self.layers = [] # type: List[Layer] + 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 + + def shutdown(self): + self._queue_get.cancel() + self.show_ui.clear() + 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: + try: + 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 + except asyncio.CancelledError: + pass + + @property + def foreground_layer(self): + return self.layers[-1] if self.layers else None + + +_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,48 @@ +# 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 + +_Dimensions = NamedTuple("Dimensions", [("height", int), ("width", int)]) + + +class Dimensions(_Dimensions): + + def __repr__(self) -> str: + return "height={}, width={}".format(*self) + + @property + def area(self) -> int: + return self.height * self.width + + +class Position: # can't be a NamedTuple to support __add__ and __sub__ + + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y + + 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) + +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,103 @@ +# 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): + + 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 Range(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.py b/apps/monolight/monolight/ui/elements.py new file mode 100644 --- /dev/null +++ b/apps/monolight/monolight/ui/elements.py @@ -0,0 +1,406 @@ +# 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 enum +import logging +import monome +import os +import time + +from lightsc import requests +from lightsc.constants import HUE_RANGE +from typing import Dict, List +from typing import Set # noqa + +from .. import bulbs, 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._action_queue = asyncio.Queue( + self.ACTION_QUEUE_SIZE + ) # type: asyncio.Queue + if loop is not None: + 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): + return "<{}(\"{}\", size=({!r}), offset=({!r})>".format( + self.__class__.__name__, self.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 insert(self, new: "UIComponent") -> 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: "UIComponent") -> 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: "UIComponent") -> 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_led_sprite(self, frame_ts_ms: TimeMonotonic) -> grids.LedSprite: + raise NotImplementedError + + 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() + 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 _handle_input( + self, offset: Position, key_state: grids.KeyState + ) -> None: + return None + + # maybe that bool return type could become an enum or a composite: + def submit_input( + self, position: Position, key_state: grids.KeyState + ) -> bool: + if not self.collides(UIPosition(position)): + return False + self._handle_input(position - self.offset, key_state) + + +class UIPosition(UIComponent): + + def __init__(self, position: Position) -> None: + UIComponent.__init__( + self, "_ui_position", position, Dimensions(1, 1), loop=None + ) + + def shutdown(self) -> None: + pass + + +class Layer(UIComponent): + + def __init__( + self, name: str, size: Dimensions, loop: asyncio.AbstractEventLoop + ) -> None: + UIComponent.__init__(self, name, Position(0, 0), size, loop) + self.led_buffer = monome.LedBuffer(width=size.width, height=size.height) + + def __repr__(self) -> str: + linesep = ", {} ".format(os.linesep) + return "<{}(\"{}\", components=[{nl} {}{nl}])>".format( + self.__class__.__name__, + self.name, + linesep.join(repr(component) for component in self.children), + nl=os.linesep + ) + + def submit_input( + self, position: Position, key_state: grids.KeyState + ) -> bool: + for component in self.children: + if component.submit_input(position, key_state): + break + + def render(self, frame_ts_ms: TimeMonotonic) -> None: + self.led_buffer.led_level_all(grids.LedLevel.OFF.value) + for component in self.children: + led_sprite = component.to_led_sprite(frame_ts_ms) + for off_x, off_y, level in led_sprite: + self.led_buffer.led_level_set( + component.offset.x + off_x, + component.offset.y + off_y, + level.value + ) + + +class Button(UIComponent): + + class ActionEnum(UIActionEnum): + + DOWN = 1 + UP = 0 + + State = ActionEnum + + # make the size configurable too? + def __init__( + self, + name: str, + offset: Position, + loop: asyncio.AbstractEventLoop, + actions: Dict[UIActionEnum, actions.Action], + ) -> None: + UIComponent.__init__( + self, name, offset, Dimensions(1, 1), loop, actions + ) + self.state = self.State.UP + self._on_sprite = grids.LedSprite(self.size, grids.LedLevel.ON) + self._medium_sprite = grids.LedSprite(self.size, grids.LedLevel.MEDIUM) + + def to_led_sprite(self, frame_ts_ms: TimeMonotonic) -> grids.LedSprite: + if self.busy and frame_ts_ms % 1000 // 100 % 2: + return self._medium_sprite + return self._on_sprite + + def _handle_input( + self, offset: Position, key_state: grids.KeyState + ) -> None: + if key_state is grids.KeyState.DOWN: + logger.info("Button {} pressed".format(self.name)) + action = self.actions.get(Button.ActionEnum.DOWN) + self.state = self.State.DOWN + else: + logger.info("Button {} depressed".format(self.name)) + action = self.actions.get(Button.ActionEnum.UP) + self.state = self.State.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 Range(UIComponent): + + 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, + actions: Dict[UIActionEnum, actions.Action], + minmaxstep: range, + ) -> None: + UIComponent.__init__(self, name, offset, size, loop, actions) + self.range = minmaxstep + self.value = self.range.start + self._action_map = { + (0, size.height - 1): self.ActionEnum.COARSE_DEC, + (0, size.height - 2): self.ActionEnum.FINE_DEC, + (0, 0): self.ActionEnum.COARSE_INC, + (0, 1): self.ActionEnum.FINE_INC, + } + + def _handle_input( + self, offset: Position, key_state: grids.KeyState + ) -> None: + logger.info("Range {} pressed at {}".format(self.name, offset)) + if key_state is not grids.KeyState.UP: + return + + action = self.actions.get(self._action_map.get((offset.x, offset.y))) + logger.info("Range {} 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("{!r}: action queue full".format(self)) + + +class HueSlider(Range): + + def __init__( + self, + name: str, + offset: Position, + size: Dimensions, + loop: asyncio.AbstractEventLoop, + actions: Dict[UIActionEnum, actions.Action], + minmaxstep: range, + targets: List[str], # targets this slider tracks + ) -> None: + Range.__init__(self, name, offset, size, loop, actions, minmaxstep) + self.targets = targets + self._steps = size.area * (grids.LedLevel.ON + 1) + self._steps_per_button = self._steps / size.height + self._sprite = grids.LedSprite(size, grids.LedLevel.OFF) + + def to_led_sprite(self, frame_ts_ms: TimeMonotonic) -> grids.LedSprite: + hues = [bulb.h for bulb in bulbs.iter_targets(self.targets)] + if not hues: + return self._sprite + + # Find a better method when an operation is in progress (maybe 0 + # should be mapped at the middle of the slider): + hue = sum(hues) / len(hues) + if hue == self.value: + return self._sprite + + self.value = hue + + step = hue / (HUE_RANGE.stop / self._steps) + 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: + self._sprite.set(Position(0, y), grids.LedLevel.ON) + y -= 1 + if y >= 0: + self._sprite.set( + Position(0, y), + grids.LedLevel(int(step % self._steps_per_button)) + ) + y -= 1 + for y in range(y, -1, -1): + self._sprite.set(Position(0, y), grids.LedLevel.OFF) + + logger.info( + "hue for {} over {} targets: " + "{}, step {}/{}, on_buttons={}".format( + self.targets, len(hues), hue, step, self._steps, on_range + ) + ) + + return self._sprite + + async def update(self, change: int, transition_ms: int = 600) -> None: + if change == 0: + return + + if change > 0: + hue = (self.value + change) % HUE_RANGE.stop + elif self.value + change < 0: + hue = HUE_RANGE.stop + change + self.value + else: + hue = self.value + change + + # XXX: implement setting one component at a time in lightsd so + # you can re-use targets: + async with bulbs.lightsd.batch() as batch: + for target in bulbs.iter_targets(self.targets): + logger.info("{}, hue -> {}".format(target.label, hue)) + batch.append(requests.SetLightFromHSBK( + [target.label], + hue, target.s, target.b, target.k, + transition_ms + )) + batch_start = time.monotonic() + batch_end = time.monotonic() + transition_remaining = transition_ms / 1000 - batch_end - batch_start + if transition_remaining > 0: + await asyncio.sleep(transition_remaining) 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,122 @@ +# 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 lightsc.constants import HUE_RANGE + +from .. import grids +from ..types import Dimensions, Position + +from . import actions +from .elements import Button, HueSlider, Layer + +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: + show_ui = self.grid.show_ui + if show_ui.is_set(): + show_ui.clear() + self.grid.monome.led_level_all(grids.LedLevel.OFF.value) + else: + show_ui.set() + + +def root(loop: asyncio.AbstractEventLoop, grid: grids.MonomeGrid) -> Layer: + foreground_layer = Layer("root", grid.size, loop) + + button = Button("show/hide ui", Position(15, 7), loop, actions={ + Button.ActionEnum.UP: _ToggleUI(grid), + }) + foreground_layer.insert(button) + button = Button("off *", Position(0, 7), loop, actions={ + Button.ActionEnum.UP: actions.Lightsd( + requests=[requests.PowerOff], targets=["*"] + ) + }) + foreground_layer.insert(button) + button = Button("on *", Position(1, 7), loop, actions={ + Button.ActionEnum.UP: actions.Lightsd( + requests=[requests.PowerOn], targets=["*"] + ) + }) + foreground_layer.insert(button) + button = Button("toggle kitchen", Position(2, 7), loop, actions={ + Button.ActionEnum.UP: actions.Lightsd( + requests=[requests.PowerToggle], targets=["#kitchen"] + ) + }) + foreground_layer.insert(button) + button = Button("toggle fugu", Position(3, 7), loop, actions={ + Button.ActionEnum.UP: actions.Lightsd( + requests=[requests.PowerToggle], targets=["fugu"] + ) + }) + foreground_layer.insert(button) + button = Button("orange", Position(4, 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(button) + + coarse_hue_step = HUE_RANGE.stop // 20 + fine_hue_step = 1 + + hue_slider = HueSlider( + "#tower hue slider", + Position(0, 1), + Dimensions(width=1, height=6), + loop, + actions={ + HueSlider.ActionEnum.COARSE_INC: actions.Range(coarse_hue_step), + HueSlider.ActionEnum.FINE_INC: actions.Range(fine_hue_step), + HueSlider.ActionEnum.FINE_DEC: actions.Range(-fine_hue_step), + HueSlider.ActionEnum.COARSE_DEC: actions.Range(-coarse_hue_step), + }, + minmaxstep=HUE_RANGE, + targets=["#tower"], + ) + foreground_layer.insert(hue_slider) + + grid.layers.append(foreground_layer) + logger.info("UI initialized on grid {}: {!r}".format( + grid.monome.id, foreground_layer + )) + 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,91 @@ +# 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.base") + + +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.is_set() for grid in grids.running): + await asyncio.wait( + [grid.show_ui.wait() 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.is_set(): + continue + layer = grid.foreground_layer + if layer is None: + layer = layers.root(loop, grid) + layer.render(frame_ts_ms=int(time.monotonic() * 1000)) + layer.led_buffer.render(grid.monome) + + render_latency = time.monotonic() - render_starts_at + 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, state in keypresses: + logger.info("Keypress {} on grid {} at {}".format( + state, grid.monome.id, position + )) + if grid.foreground_layer is not None: + grid.foreground_layer.submit_input(position, state) + + +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,42 @@ +# 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, + 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,352 @@ +# 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, +) +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 + + +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() + 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) + resp_by_id = await self._jsonrpc_execute([call]) + response = method.map_result(resp_by_id[call.id]) + if isinstance(response, Exception): + raise response + return response + + 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: 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,34 @@ +# 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. + +HUE_RANGE = range(0, 360, 1) +KELVIN_RANGE = range(0, 9000, 1) + +# NOTE: figure out something else for brightness and saturation since +# float/decimals can't be used with range. 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", + ], + }, +)