Mercurial > louis > mq > lightsd
changeset 493:d9f90a882319
Wip monolight
author | Louis Opter <kalessin@kalessin.fr> |
---|---|
date | Tue, 04 Oct 2016 23:45:05 -0700 |
parents | 6fce645a848a |
children | 8f70d5539e5c |
files | add_monolight.patch |
diffstat | 1 files changed, 349 insertions(+), 55 deletions(-) [+] |
line wrap: on
line diff
--- a/add_monolight.patch Sat Oct 01 17:18:16 2016 -0700 +++ b/add_monolight.patch Tue Oct 04 23:45:05 2016 -0700 @@ -1,6 +1,8 @@ # HG changeset patch -# Parent 4734caf1ac9fbbb1cad0363e207b9b065db1fb57 -Start a script to control bulbs through a Monome +# Parent 355e10e803403054d9d8a453a072fdaa311ec115 +Start an experimental GUI for a Monome 128 Varibright + +Written in Python >= 3.5. diff --git a/.hgignore b/.hgignore --- a/.hgignore @@ -16,63 +18,41 @@ new file mode 100644 --- /dev/null +++ b/monolight/monolight/cli.py -@@ -0,0 +1,85 @@ +@@ -0,0 +1,76 @@ ++# 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 locale ++import logging +import monome +import os +import subprocess -+import random ++ ++from . import lightsc ++from . import osc ++from . import ui + +ENCODING = locale.getpreferredencoding() + -+ +FADERS_MAX_VALUE = 100 + -+ -+class Faders(monome.Monome): -+ def __init__(self): -+ super().__init__('/faders') -+ -+ def ready(self): -+ self.led_all(0) -+ self.led_row(0, self.height - 1, [1] * self.width) -+ -+ self.row_values = [] -+ row_value = 0 -+ for i in range(self.height): -+ self.row_values.append(int(round(row_value))) -+ row_value += FADERS_MAX_VALUE / (self.height - 1) -+ -+ self.values = [random.randint(0, FADERS_MAX_VALUE) for f in range(self.width)] -+ self.faders = [asyncio.async(self.fade_to(f, 0)) for f in range(self.width)] -+ -+ def grid_key(self, x, y, s): -+ if s == 1: -+ self.faders[x].cancel() -+ self.faders[x] = asyncio.async(self.fade_to(x, self.row_to_value(y))) -+ -+ def value_to_row(self, value): -+ return sorted([ -+ i for i in range(self.height)], -+ key=lambda i: abs(self.row_values[i] - value) -+ )[0] -+ -+ def row_to_value(self, row): -+ return self.row_values[self.height - 1 - row] -+ -+ @asyncio.coroutine -+ def fade_to(self, x, new_value): -+ while self.values[x] != new_value: -+ if self.values[x] < new_value: -+ self.values[x] += 1 -+ else: -+ self.values[x] -= 1 -+ col = [0 if c > self.value_to_row(self.values[x]) else 1 for c in range(self.height)] -+ col.reverse() -+ self.led_col(x, 0, col) -+ yield from asyncio.sleep(1/100) ++logging.basicConfig(level=logging.INFO) + + +def get_lightsd_rundir(): @@ -100,34 +80,347 @@ +@click.option("--lightsd-url", default=LIGHTSD_SOCKET) +def main(serialoscd_host, serialoscd_port, lightsd_url): + loop = asyncio.get_event_loop() -+ asyncio.async(monome.create_serialosc_connection(Faders), loop=loop) ++ ++ # TODO: add signal and EOF handling and shutdown tasks gracefully ++ ++ serialosc = loop.create_task(monome.create_serialosc_connection(osc.Monome)) ++ lightsd = loop.create_task(lightsc.create_lightsd_connection(lightsd_url)) ++ ++ loop.run_until_complete(lightsd) ++ loop.run_until_complete(serialosc) ++ ++ # TODO: make which monome instance to use something configurable ++ ui_task = loop.create_task(ui.start( # noqa ++ loop, lightsd.result(), serialosc.result() ++ )) ++ + loop.run_forever() diff --git a/monolight/monolight/lightsc.py b/monolight/monolight/lightsc.py new file mode 100644 --- /dev/null +++ b/monolight/monolight/lightsc.py -@@ -0,0 +1,16 @@ +@@ -0,0 +1,174 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# -+# This file is part of lighstd. ++# This file is part of lightsd. +# -+# lighstd is free software: you can redistribute it and/or modify ++# 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. +# -+# lighstd is distributed in the hope that it will be useful, ++# 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 lighstd. If not, see <http://www.gnu.org/licenses/>. ++# along with lightsd. If not, see <http://www.gnu.org/licenses/>. ++ ++import asyncio ++import collections ++import functools ++import json ++import logging ++import os ++import urllib ++import uuid ++ ++logger = logging.getLogger("monolight.lightsc") ++ ++PendingRequestEntry = collections.namedtuple( ++ "PendingRequestEntry", ("handler_cb", "timeout_handle") ++) ++ ++ ++class LightsClient: ++ ++ READ_SIZE = 8192 ++ REFRESH_INTERVAL = 250 / 1000 # seconds ++ TIMEOUT = 2 # seconds ++ ENCODING = "utf-8" ++ ++ def __init__(self, ++ url, ++ encoding=ENCODING, ++ timeout=TIMEOUT, ++ read_size=READ_SIZE, ++ refresh_interval=REFRESH_INTERVAL, ++ loop=None): ++ self.url = url ++ self.encoding = encoding ++ self.timeout = timeout ++ self.read_size = read_size ++ self.refresh_interval = refresh_interval ++ self._pending_requests = {} ++ self._reader = self._writer = None ++ self._loop = loop or asyncio.get_event_loop() ++ ++ async def connect(self): ++ parts = urllib.parse.urlparse(self.url) ++ if parts.scheme == "unix": ++ 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": ++ 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()) ++ self._poll_task = self._loop.create_task(self.poll()) ++ except Exception: ++ logger.error("Couldn't open {}".format(self.url)) ++ raise ++ ++ return self ++ ++ async def _reconnect(self): ++ # TODO: properly close everything ++ await self.connect() ++ ++ def _handle_response(self, id, response): ++ handler_cb, timeout_handle = self._pending_requests.pop(id) ++ timeout_handle.cancel() ++ ++ if response is None: ++ logger.info("Timeout on request {}".format(id)) ++ return ++ ++ logger.info("Response {}".format(id)) ++ if handler_cb is not None: ++ handler_cb(response) ++ ++ async def listen(self): ++ buf = bytearray() ++ while True: ++ logger.info("Reading...") ++ chunk = await self._reader.read(self.READ_SIZE) ++ buf += chunk ++ if not len(chunk): # EOF, reconnect ++ await self._reconnect() ++ return ++ ++ try: ++ json.loads(buf.decode(self.encoding, "ignore")) ++ except Exception: ++ # Decoding or parsing failed, data must be missing, try again: ++ continue ++ ++ # Decoding and JSON parsing were successful, we have received ++ # a full response: ++ response = json.loads(buf.decode(self.encoding, "surrogateescape")) ++ buf = bytearray() ++ ++ self._handle_response(response["id"], response["result"]) ++ ++ async def _jsonrpc_call(self, method, params, response_handler=None): ++ id = str(uuid.uuid4()) ++ payload = { ++ "method": method, ++ "params": params, ++ "jsonrpc": "2.0", ++ "id": id, ++ } ++ payload = json.dumps(payload) ++ payload = payload.encode(self.encoding, "surrogateescape") ++ self._writer.write(payload) ++ ++ logger.info("Request {}: {}({})".format(id, method, params)) ++ ++ await self._writer.drain() ++ ++ timeout_cb = functools.partial(self._handle_response, id, response=None) ++ self._pending_requests[id] = PendingRequestEntry( ++ handler_cb=response_handler, ++ timeout_handle=self._loop.call_later(self.TIMEOUT, timeout_cb) ++ ) ++ ++ async def get_light_state(self, targets): ++ await self._jsonrpc_call( ++ "get_light_state", targets, self._handle_light_state ++ ) ++ ++ async def power_off(self, targets): ++ await self._jsonrpc_call("power_off", targets) ++ ++ async def power_on(self, targets): ++ await self._jsonrpc_call("power_on", targets) ++ ++ async def power_toggle(self, targets): ++ await self._jsonrpc_call("power_toggle", targets) ++ ++ def _handle_light_state(self, response): ++ self._bulbs = {b["label"]: b for b in response} ++ ++ async def poll(self): ++ while True: ++ await self.get_light_state(["*"]) ++ await asyncio.sleep(self.refresh_interval) ++ ++ ++async def create_lightsd_connection(url, loop=None): ++ if loop is None: ++ loop = asyncio.get_event_loop() ++ ++ c = LightsClient(url, loop=loop) ++ return await c.connect() +diff --git a/monolight/monolight/osc.py b/monolight/monolight/osc.py +new file mode 100644 +--- /dev/null ++++ b/monolight/monolight/osc.py +@@ -0,0 +1,53 @@ ++# Copyright (c) 2016, Louis Opter <louis@opter.org> ++# ++# This file is part of lightsd. ++# ++# lightsd is free software: you can redistribute it and/or modify ++# it under the terms of the GNU General Public License as published by ++# the Free Software Foundation, either version 3 of the License, or ++# (at your option) any later version. ++# ++# lightsd is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# You should have received a copy of the GNU General Public License ++# along with lightsd. If not, see <http://www.gnu.org/licenses/>. ++ ++import monome ++ ++from . import ui ++ ++MONOME_KEYPRESS_DOWN = 1 ++MONOME_KEYPRESS_UP = 0 ++MONOME_KEYPRESS_STATES = frozenset({ ++ MONOME_KEYPRESS_DOWN, ++ MONOME_KEYPRESS_UP, ++}) ++ ++ ++class Monome(monome.Monome): ++ ++ def __init__(self): ++ monome.Monome.__init__(self, "/monolight") ++ ++ def ready(self): ++ self.led_all(0) ++ ++ def grid_key(self, x, y, s): ++ ui.grid_key(x, y, s) ++ ++ ++def monome_apply(serialosc, method, *args, **kwargs): ++ for device in serialosc.app_instances.values(): ++ for app in device: ++ if isinstance(app, Monome): ++ method(app, *args, **kwargs) ++ ++ ++def monome_apply_led_buffer(serialosc, buf): ++ for device in serialosc.app_instances.values(): ++ for app in device: ++ if isinstance(app, Monome): ++ buf.render(app) +diff --git a/monolight/monolight/ui.py b/monolight/monolight/ui.py +new file mode 100644 +--- /dev/null ++++ b/monolight/monolight/ui.py +@@ -0,0 +1,79 @@ ++# 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 logging ++ ++from . import osc ++ ++logger = logging.getLogger("monolight.ui") ++ ++_event_queue = asyncio.Queue() ++ ++_KeyPress = collections.namedtuple("_KeyPress", ("x", "y", "state")) ++ ++ ++def draw(serialosc): ++ buf = osc.monome.LedBuffer(8, 8) ++ buf.led_set(0, 0, 1) ++ for x in range(0, 4): ++ buf.led_set(x, 7, 1) ++ osc.monome_apply_led_buffer(serialosc, buf) ++ ++ ++def hide(serialosc): ++ osc.monome_apply(serialosc, osc.Monome.led_all, 0) ++ ++ ++async def start(loop, lightsd, serialosc): ++ hidden = True ++ ++ while True: ++ keypress = await _event_queue.get() ++ ++ if not hidden: ++ draw(serialosc) ++ ++ logger.info("keypress: x={}, y={}, state={}".format(*keypress)) ++ ++ if keypress.state != osc.MONOME_KEYPRESS_DOWN: ++ continue ++ if keypress.y != 7 and keypress.y != 0: ++ continue ++ if keypress.x == 0: ++ if keypress.y == 0: ++ hidden = not hidden ++ if hidden: ++ hide(serialosc) ++ continue ++ await lightsd.power_off(["*"]) ++ if keypress.y != 7: ++ continue ++ if keypress.x == 1: ++ await lightsd.power_on(["*"]) ++ elif keypress.x == 2: ++ await lightsd.power_toggle(["neko"]) ++ elif keypress.x == 3: ++ await lightsd.power_toggle(["fugu"]) ++ elif keypress.x == 4: ++ # TODO pipeline to impl orange ++ await lightsd.power_toggle(["fugu"]) ++ ++ ++def grid_key(x, y, state): ++ _event_queue.put_nowait(_KeyPress(x, y, state)) diff --git a/monolight/setup.py b/monolight/setup.py new file mode 100644 --- /dev/null +++ b/monolight/setup.py -@@ -0,0 +1,51 @@ +@@ -0,0 +1,52 @@ +# Copyright (c) 2016, Louis Opter <louis@opter.org> +# +# This file is part of lighstd. @@ -174,6 +467,7 @@ + extras_require={ + "dev": [ + "flake8", ++ "ipython", + "pdbpp", + "pep8", + ],