changeset 519:ddce014cc621

generic slider impl
author Louis Opter <kalessin@kalessin.fr>
date Fri, 11 Nov 2016 22:49:39 -0800
parents 7431abeb5b7c
children 8386d8317085
files add_monolight.patch
diffstat 1 files changed, 282 insertions(+), 140 deletions(-) [+]
line wrap: on
line diff
--- 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 <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 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 <louis@opter.org>
 +#
 +# 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 <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 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 <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, 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 <louis@opter.org>
 +#
 +# 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 <louis@opter.org>
 +# 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