# HG changeset patch # User Louis Opter # Date 1478933379 28800 # Node ID ddce014cc6210e3cb220467486fa6b6a6f8875e2 # Parent 7431abeb5b7cac91340d19c54233465187d57b53 generic slider impl diff -r 7431abeb5b7c -r ddce014cc621 add_monolight.patch --- a/add_monolight.patch Thu Nov 10 13:02:27 2016 -0800 +++ b/add_monolight.patch Fri Nov 11 22:49:39 2016 -0800 @@ -573,7 +573,7 @@ + )) + + -+class Range(Action): ++class Slide(Action): + + def __init__(self, step: Union[float, int]) -> None: + Action.__init__(self) @@ -585,11 +585,42 @@ + + 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 +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.py -@@ -0,0 +1,457 @@ ++++ b/apps/monolight/monolight/ui/elements/__init__.py +@@ -0,0 +1,26 @@ ++# Copyright (c) 2016, Louis Opter ++# ++# 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 . ++ ++from .base import UILayer # noqa ++from .buttons import Button # noqa ++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,218 @@ +# Copyright (c) 2016, Louis Opter +# +# This file is part of lightsd. @@ -612,17 +643,13 @@ +import logging +import monome +import os -+import time ++ ++from typing import Dict + -+from lightsc import requests -+from lightsc.constants import HUE_RANGE, KELVIN_RANGE -+from typing import Dict, List, Tuple -+from typing import Set, Union # noqa ++from ... import grids ++from ...types import Dimensions, Position, TimeMonotonic + -+from .. import bulbs, grids -+from ..types import Dimensions, Position, TimeMonotonic -+ -+from . import actions ++from .. import actions + +logger = logging.getLogger("monolight.ui.elements") + @@ -762,12 +789,12 @@ + def submit_input( + self, position: Position, key_state: grids.KeyState + ) -> bool: -+ if not self.collides(UIPosition(position)): ++ if not self.collides(_UIPosition(position)): + return False + self._handle_input(position - self.offset, key_state) + + -+class UIPosition(UIComponent): ++class _UIPosition(UIComponent): + + def __init__(self, position: Position) -> None: + UIComponent.__init__( @@ -778,7 +805,7 @@ + pass + + -+class Layer(UIComponent): ++class UILayer(UIComponent): + + def __init__( + self, name: str, size: Dimensions, loop: asyncio.AbstractEventLoop @@ -812,6 +839,38 @@ + component.offset.y + off_y, + level.value + ) +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,77 @@ ++# Copyright (c) 2016, Louis Opter ++# ++# 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 . ++ ++import asyncio ++ ++from typing import Dict ++ ++from ... import grids ++from ...types import Dimensions, Position, TimeMonotonic ++ ++from .. import actions ++ ++from .base import UIActionEnum, UIComponent, logger + + +class Button(UIComponent): @@ -862,11 +921,78 @@ + self._action_queue.put_nowait(action) + except asyncio.QueueFull: + logger.warning("{!r}: action queue full".format(self)) +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,260 @@ ++# Copyright (c) 2016, Louis Opter ++# ++# 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 . ++ ++import asyncio ++import functools ++import lightsc ++import operator ++import statistics ++import time ++ ++from typing import Any, Callable, Dict, Iterable, List, TypeVar ++ ++from ... import bulbs, grids ++from ...types import Dimensions, Position, TimeMonotonic ++ ++from .. import actions ++ ++from .base import UIComponent, UIActionEnum, logger + + -+class Range(UIComponent): ++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. ++ """ + -+ RANGE = None ++ def __init__( ++ self, ++ range: range, ++ 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.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): + @@ -881,26 +1007,34 @@ + offset: Position, + size: Dimensions, + loop: asyncio.AbstractEventLoop, -+ actions: Dict[UIActionEnum, actions.Action], ++ actions: Dict[ActionEnum, actions.Action], ++ targets: List[str], ++ traits: SliderTraits, + ) -> None: + UIComponent.__init__(self, name, offset, size, loop, actions) -+ self.value = self.RANGE.start # XXX not ideal: type: Union[int, float] ++ self.value = float(traits.RANGE.start) ++ self.targets = targets ++ self.traits = traits ++ + 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, ++ (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 ++ self._sprite = grids.LedSprite(size, grids.LedLevel.OFF) + + def _handle_input( + self, offset: Position, key_state: grids.KeyState + ) -> None: -+ logger.info("Range {} pressed at {}".format(self.name, offset)) ++ logger.info("Slider {} 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( ++ logger.info("Slider {} action = {}".format( + self.name, self._action_map.get((offset.x, offset.y)) + )) + @@ -910,34 +1044,25 @@ + try: + self._action_queue.put_nowait(action) + except asyncio.QueueFull: -+ logger.warning("{!r}: action queue full".format(self)) -+ -+ -+class IntSlider(Range): -+ -+ def __init__( -+ self, -+ name: str, -+ offset: Position, -+ size: Dimensions, -+ loop: asyncio.AbstractEventLoop, -+ actions: Dict[UIActionEnum, actions.Action], -+ targets: List[str], # targets this slider tracks -+ ) -> None: -+ Range.__init__(self, name, offset, size, loop, actions) -+ 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 _update_value_from_bulbs(self) -> bool: -+ raise NotImplementedError ++ logger.warning("Slider {} action queue full".format(self.name)) + + def to_led_sprite(self, frame_ts_ms: TimeMonotonic) -> grids.LedSprite: -+ if not self._update_value_from_bulbs(): ++ new_value = self.traits.consolidate(self.traits.gather(self.targets)) ++ if new_value is None or new_value == self.value: + return self._sprite + -+ step = self.value / (self.RANGE.stop / self._steps) ++ 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), @@ -956,102 +1081,116 @@ + for y in range(y, -1, -1): + self._sprite.set(Position(0, y), grids.LedLevel.OFF) + -+ logger.info("step {}/{}, on_buttons={}".format( -+ step, self._steps, on_range -+ )) -+ + return self._sprite + -+ @classmethod -+ def _value_to_hsbk(cls, bulb, value) -> Tuple[int, float, float, int]: -+ raise NotImplementedError -+ + 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 = (self.value + change) % self.RANGE.stop -+ elif self.value + change < 0: -+ new_value = self.RANGE.stop + change + self.value ++ new_value = min(self.value + change, float(self.traits.RANGE.stop)) + else: -+ new_value = self.value + change ++ new_value = max(self.value + change, float(self.traits.RANGE.start)) + -+ # 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): -+ batch.append(requests.SetLightFromHSBK( -+ [target.label], -+ *self._value_to_hsbk(target, new_value), -+ transition_ms=transition_ms, -+ )) -+ batch_start = time.monotonic() -+ batch_end = time.monotonic() -+ transition_remaining = transition_ms / 1000 - batch_end - batch_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") + -+class HueSlider(IntSlider): -+ -+ RANGE = HUE_RANGE + -+ def _update_value_from_bulbs(self) -> bool: -+ hues = [bulb.h for bulb in bulbs.iter_targets(self.targets)] -+ if not hues: -+ return False ++def _gather_fn( ++ getter: Callable[[lightsc.structs.LightBulb], T], targets: List[str] ++) -> Iterable[T]: ++ return map(getter, bulbs.iter_targets(targets)) + -+ # 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 False ++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")) + -+ self.value = hue + -+ logger.info("hue for {} over {} targets: {}".format( -+ self.targets, len(hues), hue -+ )) -+ -+ return True -+ -+ @classmethod -+ def _value_to_hsbk(cls, bulb, value) -> Tuple[int, float, float, int]: -+ return value, bulb.s, bulb.b, bulb.k ++def mean_or_none(data: Iterable[T]) -> T: ++ try: ++ return statistics.mean(data) ++ except statistics.StatisticsError: # no data ++ return None + + -+class KelvinSlider(IntSlider): -+ -+ RANGE = KELVIN_RANGE -+ -+ def _update_value_from_bulbs(self) -> bool: -+ temps = [bulb.k for bulb in bulbs.iter_targets(self.targets)] -+ if not temps: -+ return False ++# 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)) + -+ # Find a better method when an operation is in progress (maybe 0 -+ # should be mapped at the middle of the slider): -+ temp = int(sum(temps) / len(temps)) -+ if temp == self.value: -+ return False -+ -+ self.value = temp ++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, ++ ) ++) + -+ logger.info("temperature for {} over {} targets: {}K".format( -+ self.targets, len(temps), temp -+ )) -+ -+ return True -+ -+ @classmethod -+ def _value_to_hsbk(cls, bulb, value) -> Tuple[int, float, float, int]: -+ return bulb.h, bulb.s, bulb.b, value ++HueSlider = functools.partial(Slider, traits=SliderTraits( ++ range=lightsc.constants.HUE_RANGE, ++ gather_fn=gather_hue, ++ consolidate_fn=mean_or_none, ++ scatter_fn=scatter_hue, ++)) ++SaturationSlider = functools.partial(Slider, traits=SliderTraits( ++ range=lightsc.constants.SATURATION_RANGE, ++ gather_fn=gather_saturation, ++ consolidate_fn=mean_or_none, ++ scatter_fn=scatter_saturation, ++)) ++BrightnessSlider = functools.partial(Slider, traits=SliderTraits( ++ range=lightsc.constants.BRIGHTNESS_RANGE, ++ gather_fn=gather_brightness, ++ consolidate_fn=mean_or_none, ++ scatter_fn=scatter_brightness, ++)) ++KelvinSlider = functools.partial(Slider, traits=SliderTraits( ++ range=lightsc.constants.KELVIN_RANGE, ++ 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,143 @@ +@@ -0,0 +1,144 @@ +# Copyright (c) 2016, Louis Opter +# +# This file is part of lightsd. @@ -1084,8 +1223,8 @@ + Button, + HueSlider, + KelvinSlider, -+ Layer, -+ Range, ++ Slider, ++ UILayer, +) + +logger = logging.getLogger("monolight.ui.layers") @@ -1106,8 +1245,8 @@ + show_ui.set() + + -+def root(loop: asyncio.AbstractEventLoop, grid: grids.MonomeGrid) -> Layer: -+ foreground_layer = Layer("root", grid.size, loop) ++def root(loop: asyncio.AbstractEventLoop, grid: grids.MonomeGrid) -> UILayer: ++ foreground_layer = UILayer("root", grid.size, loop) + + button = Button("show/hide ui", Position(15, 7), loop, actions={ + Button.ActionEnum.UP: _ToggleUI(grid), @@ -1164,10 +1303,10 @@ + Dimensions(width=1, height=6), + loop, + actions={ -+ Range.ActionEnum.COARSE_INC: actions.Range(coarse_temp_step), -+ Range.ActionEnum.FINE_INC: actions.Range(fine_temp_step), -+ Range.ActionEnum.FINE_DEC: actions.Range(-fine_temp_step), -+ Range.ActionEnum.COARSE_DEC: actions.Range(-coarse_temp_step), ++ Slider.ActionEnum.COARSE_INC: actions.Slide(coarse_temp_step), ++ Slider.ActionEnum.FINE_INC: actions.Slide(fine_temp_step), ++ Slider.ActionEnum.FINE_DEC: actions.Slide(-fine_temp_step), ++ Slider.ActionEnum.COARSE_DEC: actions.Slide(-coarse_temp_step), + }, + targets=["#kitchen"], + ) @@ -1175,25 +1314,26 @@ + + coarse_hue_step = HUE_RANGE.stop // 20 + fine_hue_step = 1 -+ + hue_slider = HueSlider( + "#tower hue slider", + Position(4, 1), + Dimensions(width=1, height=6), + loop, + actions={ -+ Range.ActionEnum.COARSE_INC: actions.Range(coarse_hue_step), -+ Range.ActionEnum.FINE_INC: actions.Range(fine_hue_step), -+ Range.ActionEnum.FINE_DEC: actions.Range(-fine_hue_step), -+ Range.ActionEnum.COARSE_DEC: actions.Range(-coarse_hue_step), ++ Slider.ActionEnum.COARSE_INC: actions.Slide(coarse_hue_step), ++ Slider.ActionEnum.FINE_INC: actions.Slide(fine_hue_step), ++ Slider.ActionEnum.FINE_DEC: actions.Slide(-fine_hue_step), ++ Slider.ActionEnum.COARSE_DEC: actions.Slide(-coarse_hue_step), + }, + 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 @@ -1759,7 +1899,7 @@ + if error is not None: + code = error.get("code") + msg = error.get("message") -+ logger.warning("Error {}: {} - {}".format( ++ logger.warning("Error on request {}: {} - {}".format( + id, code, msg + )) + call = self._pending_calls.pop(id) @@ -1866,7 +2006,7 @@ new file mode 100644 --- /dev/null +++ b/clients/python/lightsc/lightsc/constants.py -@@ -0,0 +1,34 @@ +@@ -0,0 +1,36 @@ +# Copyright (c) 2016, Louis Opter +# All rights reserved. +# @@ -1896,11 +2036,13 @@ +# 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) ++# 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): + -+# NOTE: figure out something else for brightness and saturation since -+# float/decimals can't be used with range. ++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