diff --git a/README.md b/README.md index 325774c..96377c4 100644 --- a/README.md +++ b/README.md @@ -48,21 +48,29 @@ Buba is configured completely through environment variables: ## Creating Animation Plugins Buba instantiates objects of type `BubaAnimation` and runs through them in a loop. Each animation must implement the -`run()` method, which should send data to the display. The animation is run in its own thread, therefor, the animation +`show()` method, which should send data to the display. The animation is run in its own thread, therefore, the animation should sleep an appropriate time to let users take in the information. See the existing animations in [buba/animations](./buba/animations) for inspiration. -Note: if you need to fetch and update external information regularly, you should start your own thread when initalizing -your animation. +To implement your own animation, subclass [BubaAnimation](buba/bubaanimation.py). + +In the `__init__()` method, you will want to set `self.title`, and configure your data source. + +Implement `update()` to fetch any data and fill in `self.rows` (list of rows). Each row in `self.rows` is a list of +values for the columns in the layout. + +The default layout has a wide, left-aligned column for text, and a short, right-aligned column for a time period or a +value. You can define your own layout if necessary. + +If you do not want to show tabular data, you can override `show()` and implement sending text to the display yourself. ## Character Set The display uses [Code Page 437](https://en.wikipedia.org/wiki/Code_page_437), with a few exceptions. Due to the limited resolution of the segments, the display can deviate significantly. -Note that the [Python codecs](https://docs.python.org/3/library/codecs.html) for `CP437` do not map all special characters correctly. - - +Note that the [Python codecs](https://docs.python.org/3/library/codecs.html) for `CP437` do not map all special +characters correctly. | Code | CP437 | Geavision Spaltenschrift | |------|--------------|--------------------------| diff --git a/buba/__main__.py b/buba/__main__.py index 6b0f1d6..57b8432 100644 --- a/buba/__main__.py +++ b/buba/__main__.py @@ -1,14 +1,12 @@ import logging -from bottle import Bottle, static_file, TEMPLATE_PATH, jinja2_view +from bottle import Bottle, static_file, TEMPLATE_PATH, jinja2_view, request, HTTPResponse from bottle_log import LoggingPlugin from bottle_websocket import websocket, GeventWebSocketServer from geventwebsocket.websocket import WebSocket -from buba.animations.dbf import DBFAnimation -from buba.animations.icalevents import IcalEvents -from buba.animations.snake import SnakeAnimation -from buba.animations.time import BubaTime +from buba.animationconfig import setup_animations +from buba.animations.webqueue import WebQueue from buba.appconfig import AppConfig from buba.bubaanimator import BubaAnimator from buba.bubacmd import BubaCmd @@ -28,13 +26,9 @@ TEMPLATE_PATH.insert(0, config.templatepath) websocket_clients = WebSocketClients() buba = BubaCmd(config.serial, websocket_clients.send) animator = BubaAnimator(buba) -# animator.add(BubaCharset) #, single=12) -animator.add(BubaTime) -animator.add(DBFAnimation, ds100="AHST", station="Holstenstraße") -animator.add(DBFAnimation, ds100="AHS", station="Altona", count=9) -animator.add(IcalEvents, url="https://cloud.hamburg.ccc.de/remote.php/dav/public-calendars/QJAdExziSnNJEz5g?export", - title="CCCHH Events") -animator.add(SnakeAnimation) +webqueue = WebQueue(buba) +setup_animations(config, animator, webqueue) + @app.route("/static/") def server_static(filepath): @@ -44,7 +38,9 @@ def server_static(filepath): @app.get("/") @jinja2_view("home.html.j2") def root(): - return {} + return { + "user_forms": range(webqueue.maxrows), + } @app.get('/ws', apply=[websocket]) @@ -61,6 +57,49 @@ def websocket_endpoint(ws: WebSocket): websocket_clients.remove(ws) +def json_error(status: str, body: dict) -> HTTPResponse: + return HTTPResponse( + body=body, + status=status, + headers={ + "Content-Type": "application/json"} + ) + + +def json_error_400(error: str) -> HTTPResponse: + return json_error("400 Bad Request", { + "status": "error", + "error": error, + }) + + +@app.post("/user-entry") +def user_entry(): + log.debug(f"json: {request.json}") + title = request.json.get("title") + if not isinstance(title, str): + raise json_error_400("title must be a string") + title = title.strip() + if title == "": + raise json_error_400("title must not be empty") + if not isinstance(request.json.get("rows"), list): + raise json_error_400("rows must be a list") + rows = [] + for row in request.json.get("rows"): + if not isinstance(row[0], str) or not isinstance(row[0], str): + raise json_error_400("rows elements must be strings") + row = [v.strip() for v in row] + if row[0] != "" or row[1] != "": + rows.append(row) + rows = rows[:webqueue.maxrows] + if webqueue.add(title, rows): + return { + "status": "success", + } + else: + raise json_error_400("queue is full") + + if __name__ == '__main__': app.run(host=config.listen_host, port=config.listen_port, server=GeventWebSocketServer, debug=config.debug, quiet=not config.debug) diff --git a/buba/animationconfig.py b/buba/animationconfig.py new file mode 100644 index 0000000..c8a7584 --- /dev/null +++ b/buba/animationconfig.py @@ -0,0 +1,58 @@ +import datetime +import logging + +from buba.animations.charset import BubaCharset +from buba.animations.dbf import DBF +from buba.animations.homeassistant import HomeAssistant +from buba.animations.icalevents import IcalEvents +from buba.animations.snake import SnakeAnimation +from buba.animations.spaceapi import Spaceapi +from buba.animations.time import BubaTime +from buba.animations.webqueue import WebQueue +from buba.bubaanimator import BubaAnimator + +LOG = logging.getLogger(__name__) + + +def setup_animations(config, animator: BubaAnimator, webqueue: WebQueue): + cs = BubaCharset(animator.buba) + + bt = BubaTime(animator.buba, config.ip) + + snake = SnakeAnimation(animator.buba) + + dbf_ahst = DBF(animator.buba, ds100="AHST", station="Holstenstraße") + dbf_ahs = DBF(animator.buba, ds100="AHS", station="Altona", count=9) + + ccchh_events = IcalEvents(animator.buba, + url="https://cloud.hamburg.ccc.de/remote.php/dav/public-calendars/QJAdExziSnNJEz5g?export", + title="CCCHH Events") + ccc_events = IcalEvents(animator.buba, + url="https://events.ccc.de/calendar/events.ics", + title="CCC Events", range=datetime.timedelta(weeks=8)) + + fux_lichtspiele_events = IcalEvents(animator.buba, + url="https://cloud.hamburg.ccc.de/remote.php/dav/public-calendars/wdyaFEMeJiAK2jGc?export", + title="fux Lichtspiele", range=datetime.timedelta(weeks=8)) + + ccchh_spaceapi = Spaceapi(animator.buba, "https://spaceapi.hamburg.ccc.de", "CCCHH") + + ca = None + if config.clubassistant_token is not None: + ca = HomeAssistant(animator.buba, "https://club-assistant.ccchh.net", "Club Assistant", + config.clubassistant_token) + else: + LOG.warning("Club Assistant token not set, not activating animation") + + # animator.add(cs) + animator.add(bt) + animator.add(snake) + animator.add(dbf_ahst) + animator.add(dbf_ahs) + animator.add(ccchh_events) + animator.add(ccc_events) + animator.add(fux_lichtspiele_events) + animator.add(ccchh_spaceapi) + if ca is not None: + animator.add(ca) + animator.add(webqueue) diff --git a/buba/animations/charset.py b/buba/animations/charset.py index ce81518..8c283ba 100644 --- a/buba/animations/charset.py +++ b/buba/animations/charset.py @@ -1,6 +1,6 @@ from time import sleep -from buba.bubaanimator import BubaAnimation +from buba.bubaanimation import BubaAnimation from buba.bubacmd import BubaCmd @@ -10,7 +10,7 @@ class BubaCharset(BubaAnimation): self.charset = bytes(range(256)).decode("CP437") self.single = single - def run(self): + def show(self): if self.single is not None: while True: self.render(self.single) diff --git a/buba/animations/dbf.py b/buba/animations/dbf.py index 9c32980..e55b33b 100644 --- a/buba/animations/dbf.py +++ b/buba/animations/dbf.py @@ -4,71 +4,64 @@ Pull departure info from https://trains.xatlabs.com and display. See also https://github.com/derf/db-fakedisplay/blob/main/README.md """ import datetime +import logging from threading import Thread from time import sleep from deutschebahn.db_infoscreen import DBInfoscreen -from buba.bubaanimator import BubaAnimation +from buba.bubaanimator import LineLayoutColumn +from buba.bubaanimation import BubaAnimation from buba.bubacmd import BubaCmd +LOG = logging.getLogger(__name__) + +class DBF(BubaAnimation): + dbf_layout = [ + LineLayoutColumn(12, BubaCmd.ALIGN_LEFT), + LineLayoutColumn(81, BubaCmd.ALIGN_LEFT), + LineLayoutColumn(27, BubaCmd.ALIGN_RIGHT), + ] -class DBFAnimation(BubaAnimation): def __init__(self, buba: BubaCmd, ds100="AHST", station="Holstenstraße", count=3): super().__init__(buba) self.dbi = DBInfoscreen("trains.xatlabs.com") self.ds100 = ds100 - self.station = station + self.title = station + self.layout = self.dbf_layout self.trains = [] self.count = count - Thread(target=self.update, daemon=True).start() def __repr__(self): return f"<{type(self).__name__}, {self.ds100}>" - def fetch(self): + def update(self): trains = self.dbi.calc_real_times(self.dbi.get_trains(self.ds100)) # Station # trains = [t for t in trains] # if t['platform'] == "1"] # platform gleis trains.sort(key=self.dbi.time_sort) self.trains = trains self.log.info(f"Fetched {len(trains)} trains") - def update(self): - try: - self.fetch() - except Exception as e: - self.log.warning(f"Unable to fetch {self.station}: {e}") - pass - sleep(60) - @staticmethod - def countdown(dt: datetime): - now = datetime.datetime.now() + def countdown(dt: datetime, cancelled:bool): + if cancelled: + return "--" + now = datetime.datetime.now().astimezone() try: - dep_time = datetime.datetime.strptime(dt, "%H:%M").time() - except TypeError as e: + day = now.strftime("%y-%m-%d") + departure = datetime.datetime.strptime(f"{day} {dt}", "%y-%m-%d %H:%M").astimezone() + except ValueError as e: return "--" - # First, assume all departure times are on the current day - dep_date = now.date() - - # Calculate timedelta under the above assumption - dep_td = datetime.datetime.combine(dep_date, dep_time) - now - - # If the calculated timedelta is more than one hour in the past, - # assume that the day should actually be the next day - # (This will be the case e.g. when a train departs at 00:15 and it's currently 23:50) + dep_td = departure - now if dep_td.total_seconds() <= -3600: - dep_date += datetime.timedelta(days=1) - - # If the calculated timedelta is more than 23 hours in the future, - # assume that the day should actually be the previous day. - # (This will be the case e.g. when a train should have departed at 23:50 but it's already 00:15) + # dep_date += datetime.timedelta(days=1) + departure += datetime.timedelta(days=1) if dep_td.total_seconds() >= 3600 * 23: - dep_date -= datetime.timedelta(days=1) + # dep_date -= datetime.timedelta(days=1) + departure -= datetime.timedelta(days=1) - # Recalculate the timedelta - return BubaAnimation.countdown(datetime.datetime.combine(dep_date, dep_time)) + return BubaAnimation.countdown(departure) @staticmethod def short_station(station: str) -> str: @@ -95,6 +88,8 @@ class DBFAnimation(BubaAnimation): train = "EN" if train.startswith("ME"): train = "ME" + if train.startswith("NB"): + train = "NB" if train.startswith("NJ"): train = "NJ" if train.startswith("RB"): @@ -104,31 +99,10 @@ class DBFAnimation(BubaAnimation): train = train.replace(" ", "") return train - def run(self): - all = self.trains[:self.count] - all_len = int((len(all) + 1) / 3) - - if len(self.trains) == 0: - sleep(5) - - for page, trains in enumerate(self.chunk(all, 3)): - if all_len == 1: - title = self.station - else: - title = f"{self.station} ({page + 1}/{all_len})" - self.buba.text(page=0, row=0, col_start=0, col_end=92, text=title, align=BubaCmd.ALIGN_LEFT) - for i, train in enumerate(trains): - if train['isCancelled']: - when = "--" - else: - when = self.countdown(train['actualDeparture']) - self.buba.text(page=0, row=i + 1, col_start=0, col_end=11, text=self.short_train(train['train'])) - self.buba.text(page=0, row=i + 1, col_start=12, col_end=104, - text=self.short_station(train['destination'])) - self.buba.text(page=0, row=i + 1, col_start=105, col_end=119, - text=when, align=BubaCmd.ALIGN_RIGHT) - self.buba.set_page(0) - for i in range(5): - self.buba.text(page=0, row=0, col_start=93, col_end=119, text=datetime.datetime.now().strftime("%H:%M"), - align=BubaCmd.ALIGN_RIGHT) - sleep(2) + def show(self): + self.rows = [[ + self.short_train(train['train']), + self.short_station(train['destination']), + self.countdown(train['actualDeparture'], train['isCancelled']), + ] for train in self.trains[:self.count]] + self.show_pages() diff --git a/buba/animations/homeassistant.py b/buba/animations/homeassistant.py new file mode 100644 index 0000000..f1a1acb --- /dev/null +++ b/buba/animations/homeassistant.py @@ -0,0 +1,52 @@ +""" +Data from club-assistant.hamburg.ccc.de +""" +import json +from threading import Thread +from time import sleep + +import requests + +from buba.bubaanimation import BubaAnimation + +default_endpoints = [ + { + "endpoint": "sensor.total_measured_power", + "name": "Current Power", + }, + { + "endpoint": "sensor.hauptraum_scd41_co2", + "name": "Hauptraum CO2", + }, + { + "endpoint": "sensor.werkstatt_scd41_co2", + "name": "Werkstatt CO2", + }, + { + "endpoint": "sensor.temperatur_und_feuchtigkeitssensor_hauptraum_humidity", + "name": "Hauptraum Luftfeuchte", + }, + { + "endpoint": "sensor.temperatur_und_feuchtigkeitssensor_lotschlauch_humidity", + "name": "Lötschlauch Luftfeuchte", + } +] + +class HomeAssistant(BubaAnimation): + def __init__(self, buba, url, title, token, endpoints=None): + if endpoints is None: + endpoints = default_endpoints + super().__init__(buba) + self.url = url + self.title = title + self.token = token + self.endpoints = endpoints + + def update(self): + rows = [] + for e in self.endpoints: + res = requests.get(self.url + "/api/states/" + e["endpoint"], headers={'Authorization': 'Bearer ' + self.token}) + res.raise_for_status() + js = json.loads(res.text) + rows.append([e["name"], f"{js['state']}{js['attributes']['unit_of_measurement']}"]) + self.rows = rows diff --git a/buba/animations/icalevents.py b/buba/animations/icalevents.py index a51f16f..4a74e56 100644 --- a/buba/animations/icalevents.py +++ b/buba/animations/icalevents.py @@ -1,48 +1,34 @@ import os from datetime import timedelta, datetime -from threading import Thread -from time import sleep import icalevents.icalevents from pytz import timezone -from buba.bubaanimator import BubaAnimation -from buba.bubacmd import BubaCmd +from buba.bubaanimation import BubaAnimation class IcalEvents(BubaAnimation): - def __init__(self, buba, url, title): + def __init__(self, buba, url, title, range=timedelta(weeks=2)): super().__init__(buba) self.url = url self.title = title - self.events = [] - Thread(target=self.update, daemon=True).start() + self.range = range def __repr__(self): return f"<{type(self).__name__}, {self.url}>" def update(self): + """ + Fetch all events from now to the end of the range. + Filter out events that have ended. Apparently, the icalecvents list can contain entries + that are slightly in the past (by an hours or two). Only use entries that have not reached + their end date/time yet. + :return: + """ tz = timezone(os.getenv("TZ", "Europe/Berlin")) - events = icalevents.icalevents.events(self.url, tzinfo=tz, sort=True, end=datetime.now(tz) + timedelta(days=14)) + now = datetime.now(tz) + events = icalevents.icalevents.events(self.url, tzinfo=tz, sort=True, end=now + self.range) for event in events: event.start = event.start.astimezone(tz) - self.events = events - sleep(600) - - def run(self): - for (page, events) in enumerate(self.chunk(self.events, 3)): - if len(self.events) > 3: - self.buba.text(page=0, row=0, col_start=0, col_end=119, - text=f"{self.title} ({page + 1}/{int((len(self.events) + 2) / 3)})", - align=BubaCmd.ALIGN_LEFT) - else: - self.buba.text(page=0, row=0, col_start=0, col_end=119, text=self.title, align=BubaCmd.ALIGN_LEFT) - for i in range(3): - if i >= len(events): - self.buba.text(page=0, row=i + 1, col_start=0, col_end=119, text="") - else: - event = events[i] - self.buba.text(page=0, row=i + 1, col_start=0, col_end=100, text=self.ellipsis(event.summary, 25)) - self.buba.text(page=0, row=i + 1, col_start=101, col_end=119, - text=self.countdown(event.start), align=BubaCmd.ALIGN_RIGHT) - sleep(10) + events = filter(lambda e: e.end > now, events) + self.rows = [[e.summary, self.countdown(e.start)] for e in events] diff --git a/buba/animations/snake.py b/buba/animations/snake.py index 404cad6..6eb52e2 100644 --- a/buba/animations/snake.py +++ b/buba/animations/snake.py @@ -1,7 +1,8 @@ import random +from datetime import datetime, timedelta from time import sleep -from buba.bubaanimator import BubaAnimation +from buba.bubaanimation import BubaAnimation class SnakeAnimation(BubaAnimation): @@ -34,7 +35,7 @@ class SnakeAnimation(BubaAnimation): random.seed() - def run(self): + def show(self): self.grid = [list([0] * self.width) for i in range(self.height)] self.prev_grid = [list([0] * self.width) for i in range(self.height)] x = random.randrange(self.width) @@ -46,6 +47,7 @@ class SnakeAnimation(BubaAnimation): self.body = [(x, y)] self.render() iterations = 0 + last_update = datetime.now() while True: if self.is_blocked(x, y, d): end = True @@ -71,8 +73,11 @@ class SnakeAnimation(BubaAnimation): self.grid[ty][tx] = 0 (tx, ty) = self.body[0] self.grid[ty][tx] = 11 - self.render() - sleep(1) + if datetime.now() - last_update > timedelta(seconds=1): + last_update = datetime.now() + self.render() + else: + sleep(.5) sleep(5) @staticmethod diff --git a/buba/animations/spaceapi.py b/buba/animations/spaceapi.py new file mode 100644 index 0000000..e98a59a --- /dev/null +++ b/buba/animations/spaceapi.py @@ -0,0 +1,54 @@ +import json +import logging +from datetime import datetime +from threading import Thread +from time import sleep + +import requests + +from buba.bubaanimation import BubaAnimation +from buba.bubacmd import BubaCmd + + +class Spaceapi(BubaAnimation): + def __init__(self, buba, url, title): + super().__init__(buba) + self.url = url + self.title = title + + def update(self): + res = requests.get(self.url) + data = json.loads(res.text) + + open = "open" if data["state"]["open"] else "closed" + since = datetime.fromtimestamp(data["state"]["lastchange"]).astimezone() + temp = int(data["sensors"]["temperature"][0]["value"]) + hum = int(data["sensors"]["humidity"][0]["value"]) + printers = {} + for p in data["sensors"]["ext_3d_printer_busy_state"]: + printers[p["name"]] = { + "busy": p["value"] != 0, + } + for p in data["sensors"]["ext_3d_printer_minutes_remaining"]: + printers[p["name"]]["remaining"] = p["value"] + printstatus = [] + for n, p in sorted(printers.items()): + if p["busy"]: + printstatus.append(f"{n} {p['remaining']}m left") + else: + printstatus.append(f"{n} idle") + self.rows = [ + [f"CCCHH {open} {self.humanize(since)}"], + [f"Outside: {temp}°C at {hum}% rel.hum."], + [", ".join(printstatus)] + ] + + def humanize(self, dt): + td = dt - datetime.now().astimezone() + if td.total_seconds() > -60: + return "just now" + if td.total_seconds() > -3600: + return f"for {-int(td.total_seconds()/60)} minutes" + if td.total_seconds() >- 86400: + return f"for {-int(td.total_seconds()/3600)} hours" + return dt.strftime("since %y-%m-%d %H:%M") diff --git a/buba/animations/time.py b/buba/animations/time.py index f681171..78ef2f1 100644 --- a/buba/animations/time.py +++ b/buba/animations/time.py @@ -1,22 +1,19 @@ from datetime import datetime from time import sleep -from buba.bubaanimator import BubaAnimation +from buba.bubaanimation import BubaAnimation from buba.bubacmd import BubaCmd class BubaTime(BubaAnimation): - def __init__(self, buba: BubaCmd): + def __init__(self, buba: BubaCmd, ip:str): super().__init__(buba) + self.ip = ip - def run(self): - self.buba.text(page=0, row=0, col_start=0, col_end=92, text="Bus-Bahn-Anzeige") - self.buba.simple_text(page=0, row=1, col=0, text="Chaos Computer Club") - self.buba.simple_text(page=0, row=2, col=0, text="Hansestadt Hamburg") - self.buba.simple_text(page=0, row=3, col=0, text="Hello, world!") - self.buba.set_page(0) + def show(self): + self.buba.text(page=0, row=0, col_start=0, col_end=119, text="Chaos Computer Club", align=BubaCmd.ALIGN_CENTER) + self.buba.text(page=0, row=1, col_start=0, col_end=119, text="Hansestadt Hamburg", align=BubaCmd.ALIGN_CENTER) + self.buba.text(page=0, row=2, col_start=0, col_end=119, text=f"{self.ip}", align=BubaCmd.ALIGN_CENTER) + self.buba.text(page=0, row=3, col_start=0, col_end=119, text=datetime.now().strftime("%Y-%m-%d %H:%M"), align=BubaCmd.ALIGN_CENTER) - for i in range(3): - self.buba.text(page=0, row=0, col_start=93, col_end=119, text=datetime.now().strftime("%H:%M"), - align=BubaCmd.ALIGN_RIGHT) - sleep(2) + sleep(10) diff --git a/buba/animations/webqueue.py b/buba/animations/webqueue.py new file mode 100644 index 0000000..66bbde5 --- /dev/null +++ b/buba/animations/webqueue.py @@ -0,0 +1,48 @@ +from datetime import timedelta +from queue import Queue, Empty +from time import sleep + +from buba.bubaanimation import BubaAnimation +from buba.bubaanimator import LineLayoutColumn +from buba.bubacmd import BubaCmd + + +class WebQueue(BubaAnimation): + """ + + """ + def __init__(self, buba, maxlen:int=3, maxrows:int=6): + """ + A queue of pages to show. If + :param buba: + :param maxlen: + """ + super().__init__(buba) + self.title = "(waiting for input)" + self.queue = Queue() + self.maxlen = maxlen + self.maxrows = maxrows + + def show(self) -> None: + try: + (self.title, self.rows) = self.queue.get(block=False) + self.layout = self.default_layout + self.show_pages() + except Empty: + self.buba.text(page=0, row=0, col_start=0, col_end=119, text="\u0010\u0010 Your Data Here \u0011\u0011", align=BubaCmd.ALIGN_CENTER) + self.buba.text(page=0, row=1, col_start=0, col_end=119, text="Submit your own data to", align=BubaCmd.ALIGN_CENTER) + self.buba.text(page=0, row=2, col_start=0, col_end=119, text="this display! Go to", align=BubaCmd.ALIGN_CENTER) + self.buba.text(page=0, row=3, col_start=0, col_end=119, text="https://buba.ccchh.net", align=BubaCmd.ALIGN_CENTER) + sleep(7) + + def add(self, title, rows) -> bool: + """ + Add an entry to the queue. Returns False if the maximum number of entries has been reached. + :param title: + :param rows: + :return: + """ + if self.queue.qsize() >= self.maxlen: + return False + self.queue.put((title, rows)) + return True \ No newline at end of file diff --git a/buba/appconfig.py b/buba/appconfig.py index 9124a9d..89befaf 100644 --- a/buba/appconfig.py +++ b/buba/appconfig.py @@ -16,8 +16,15 @@ class AppConfig: self.url = getenv('BUBA_URL', 'http://localhost:3000') (self.listen_host, self.listen_port) = getenv('BUBA_LISTEN', '127.0.0.1:3000').split(':') self.serial = getenv('BUBA_SERIAL', '/dev/ttyUSB0') + self.clubassistant_token = getenv('BUBA_CLUBASSISTANT_TOKEN', None) if self.debug is not None and self.debug.lower not in ('0', 'f', 'false'): self.debug = True else: self.debug = False + + import socket + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + self.ip = s.getsockname()[0] + s.close() diff --git a/buba/bubaanimation.py b/buba/bubaanimation.py new file mode 100644 index 0000000..f601c39 --- /dev/null +++ b/buba/bubaanimation.py @@ -0,0 +1,180 @@ +import logging +from datetime import timedelta, datetime +from itertools import islice +from threading import Thread +from time import sleep + +from buba.bubaanimator import LineLayoutColumn, WEEKDAYS_DE +from buba.bubacmd import BubaCmd + + +class BubaAnimation: + default_layout = [ + LineLayoutColumn(90, BubaCmd.ALIGN_LEFT), + LineLayoutColumn(30, BubaCmd.ALIGN_RIGHT), + ] + + def __init__(self, buba: BubaCmd): + self.log = logging.getLogger(type(self).__name__) + self.buba = buba + self.update_interval = timedelta(minutes=1) + self.page_interval = timedelta(seconds=7) + self.title = None + self.layout = self.default_layout + self.rows = [] + self.thread = Thread(target=self.update_rows, daemon=True) + self.thread.start() + pass + + def __repr__(self): + return f"<{type(self).__name__}>" + + def update(self) -> None: + """ + Do everything necessary to produce the information to be displayed. + :return: + """ + + def update_rows(self) -> None: + """ + Update loop. This will be run continuously in a thread to call update(). + :return: + """ + sleep(1) # give the code a bit of time to configure stuff + while True: + try: + self.update() + except Exception as e: + self.log.warning(f"unable to update: {e}") + sleep(self.update_interval.total_seconds()) + + def show(self) -> None: + """ + Write information to the display. + You should also implement any waiting time for people to be able to take the information in. + """ + self.show_pages() + + @staticmethod + def chunk(it, size): + """ + Return list in groups of size. + :param it: list + :param size: chunk size + :return: list of chunks + """ + it = iter(it) + return iter(lambda: tuple(islice(it, size)), ()) + + @staticmethod + def humanize_delta(dt, now_delta, day_delta): + """ + Produce a short text representing a target date and time. + :param dt: the target date and time + :param now_delta: time delta of the target time to now + :param day_delta: delta rounded to the full day + :return: + """ + if now_delta < timedelta(seconds=60): + return "jetzt" + if now_delta < timedelta(minutes=90): + return f"{int(now_delta.seconds / 60)}m" + if day_delta < timedelta(hours=24): + return f"{int((now_delta.seconds + 3599) / 3600)}h" + if day_delta < timedelta(days=7): + # return dt.strftime("%a") # weekday + return WEEKDAYS_DE[dt.weekday()] + return dt.strftime("%d.%m.") + + @staticmethod + def countdown(dt: datetime): + """ + Compute a human-readable time specification until the target event starts. The day starts at 04:00. + :param dt: datetime timezone-aware datetime + :return: + """ + now = datetime.now(dt.tzinfo) + from_day_start = now.replace(hour=4, minute=0, second=0, microsecond=0) + now_delta = dt - now + day_delta = dt - from_day_start + h = BubaAnimation.humanize_delta(dt, now_delta, day_delta) + return h + + @staticmethod + def ellipsis(text, max=28): + """ + If the text is longer that max, shorten it and add ellipsis. + :param text: to be shortened + :param max: max length + :return: shortened text + """ + if len(text) > max: + text = text[:max - 2] + "..." # we can get away with just 2, since the periods are very narrow + return text + + def _write_row(self, nrow: int, row:list[str], layout: list[LineLayoutColumn]) -> None: + """ + Write one row to the display. + :param nrow: row number to write + :param row: contents of the row + :param layout: layout to use + :return: + """ + col = 0 + for i, ll in enumerate(layout): + t = row[i] if len(row) > i else "" + if ll.width > 24: + t = self.ellipsis(t, int(ll.width / 3.7)) + self.buba.text(page=0, row=nrow, col_start=col, col_end=col + ll.width - 1, text=t, align=ll.align) + col += ll.width + + def write_title(self, row: list[str], layout: list[LineLayoutColumn] = None) -> None: + """ + Write the title row. If row has one element, fill the entire row with that; if it has two, the second one + is right-aligned and shows a page number or the current time. + :param row: the title info + :param layout: the layout to use + :return: + """ + if len(row) == 1: + self._write_row(0, row, [LineLayoutColumn(120, BubaCmd.ALIGN_LEFT)]) + else: + self._write_row(0, row, [ + LineLayoutColumn(100, BubaCmd.ALIGN_LEFT), + LineLayoutColumn(20, BubaCmd.ALIGN_RIGHT), + ]) + + def write_row(self, nrow: int, row: list[str]) -> None: + """ + Write one row to the display. Use the layout specification to format the row. + :param nrow: row number to be written + :param row: contents of the row + :param layout: layout to use + :return: + """ + if len(row) == 1: + # if the row only has a single part, simply show that over the entire width + self._write_row(nrow, row, [LineLayoutColumn(120, BubaCmd.ALIGN_LEFT)]) + else: + self._write_row(nrow, row, self.layout) + + def show_pages(self) -> None: + """ + Show rows on the display. Paginate the rows if there are more rows than the display can show. + :return: + """ + pages = list(self.chunk(self.rows, 3)) + for n, page in enumerate(pages): + if len(pages) <= 1: + self.write_title([self.title]) + else: + self.write_title([self.title, f"({n + 1}/{len(pages)})"]) + for i in range(3): + if i >= len(page): + self.buba.text(page=0, row=i + 1, col_start=0, col_end=119, text="") + else: + p = page[i] + if isinstance(p, str): + p = [p, ] + self.write_row(i + 1, p) + sleep(self.page_interval.total_seconds()) diff --git a/buba/bubaanimator.py b/buba/bubaanimator.py index b34bef1..91b2e2b 100644 --- a/buba/bubaanimator.py +++ b/buba/bubaanimator.py @@ -1,67 +1,29 @@ import logging -from datetime import datetime, timedelta -from itertools import islice +import random +from dataclasses import dataclass from threading import Thread from time import sleep from buba.bubacmd import BubaCmd +LOG = logging.getLogger(__name__) -class BubaAnimation: - def __init__(self, buba: BubaCmd): - self.log = logging.getLogger(type(self).__name__) - self.buba = buba - pass +WEEKDAYS_DE = [ + "Mo", + "Di", + "Mi", + "Do", + "Fr", + "Sa", + "So" +] - def __repr__(self): - return f"<{type(self).__name__}>" - def run(self): - raise Exception("Your class must implement a run() method") - - @staticmethod - def chunk(it, size): - """ - Return list in groups of size. - :param it: list - :param size: chunk size - :return: list of chunks - """ - it = iter(it) - return iter(lambda: tuple(islice(it, size)), ()) - - @staticmethod - def countdown(dt: datetime): - """ - Compute a human-readable time specification until the target event starts. The day starts at 04:00. - :param dt: datetime timezone-aware datetime - :return: - """ - now = datetime.now(dt.tzinfo) - from_day_start = now.replace(hour=4, minute=0, second=0, microsecond=0) - now_delta = dt - now - day_delta = dt - from_day_start - if now_delta < timedelta(seconds=60): - return "now" - if now_delta < timedelta(minutes=30): - return f"{int(now_delta.seconds / 60)}m" - if day_delta < timedelta(hours=24): - return f"{int((now_delta.seconds + 3599) / 3600)}h" - if day_delta < timedelta(days=7): - return dt.strftime("%a") # weekday - return dt.strftime("%d.%m.") - - @staticmethod - def ellipsis(text, max=28): - """ - If the text is longer that max, shorten it and add ellipsis. - :param text: to be shortened - :param max: max length - :return: shortened text - """ - if len(text) > max: - text = text[:max - 2] + "..." # we can get away with just 2, since the periods are very narrow - return text +# class layout with width, alignment; list of layouts +@dataclass +class LineLayoutColumn: + width: int + align: int class BubaAnimator: @@ -72,14 +34,23 @@ class BubaAnimator: Thread(target=self.run, daemon=True).start() def run(self): + for nrow in range(4): + self.buba.text(page=0, row=nrow, col_start=0, col_end=119, text="") + self.buba.text(page=0, row=1, col_start=0, col_end=119, text="...booting...", align=BubaCmd.ALIGN_CENTER) + sleep(5) + while True: if len(self.animations) == 0: self.log.debug("No animations, sleeping...") sleep(2) else: - for a in self.animations: + for a in random.sample(self.animations, len(self.animations)): self.log.debug(f"Starting animation: {a}") - a.run() + try: + a.show() + except Exception as e: + self.log.warning(f"Exception while showing animation: {e}") + sleep(2) - def add(self, animation, *args, **kwargs): - self.animations.append(animation(self.buba, *args, **kwargs)) + def add(self, animation): + self.animations.append(animation) diff --git a/buba/bubacmd.py b/buba/bubacmd.py index 030e22e..67c35d5 100644 --- a/buba/bubacmd.py +++ b/buba/bubacmd.py @@ -64,6 +64,7 @@ class BubaCmd: :param align: alignment options, see MIS1TextDisplay.ALIGN_* :return: """ + text = text.replace("\u00ad", "") # remove soft hyphen text = text.encode("CP437", "replace").decode("CP437") # force text to be CP437 compliant if self.display is not None: self.display.text(page, row, col_start, col_end, text, align) diff --git a/buba/static/main.css b/buba/static/main.css index 19924ae..458f158 100644 --- a/buba/static/main.css +++ b/buba/static/main.css @@ -29,4 +29,27 @@ svg.geavision__row { .gvson { fill: #4f0; +} + +.info-form__row { + display: flex; + padding: 2px 0; +} + +.info-form__field { + display: flex; + flex-direction: column; + padding: 0 1em 0 0; +} + +.info-form__field * { + display: block; +} + +.info-form__field label { + font-size: 0.9em; +} + +.info-form__field input { + font-size: 1.1em; } \ No newline at end of file diff --git a/buba/static/main.js b/buba/static/main.js index 7edc355..b9966ab 100644 --- a/buba/static/main.js +++ b/buba/static/main.js @@ -11,7 +11,7 @@ if (container) { if (m.cmd === undefined) { console.log("undefined command", m) } - switch(m.cmd) { + switch (m.cmd) { case "set_pages": display.set_pages(m.pages); break; @@ -32,5 +32,44 @@ if (container) { ws.close(); }); } + connect(); } + +let info_send = document.getElementById("info-send"); +if (info_send) { + document.getElementById("info-form__form").addEventListener("submit", (e) => { + e.preventDefault(); + e.stopPropagation(); + }) + info_send.addEventListener("click", (e) => { + let info = { + "title": document.getElementById("info-form__title").value, + "rows": [] + } + for (let e of document.querySelectorAll(".info-form__row-data")) { + let t = e.querySelector(".info-row__field-text").value; + let v = e.querySelector(".info-row__field-value").value; + info.rows.push([t, v]); + } + fetch("/user-entry", { + method: "POST", + body: JSON.stringify(info), + headers: { + "Content-Type": "application/json" + } + }).then(res => { + if (res.status === 200) { + alert("Your entry has been sent!") + } else { + return res.json() + } + }).then(json => { + alert("Your entry could not be accepted: " + json.error) + }).catch((err) => { + console.log(err); + }) + e.preventDefault(); + e.stopPropagation(); + }) +} \ No newline at end of file diff --git a/buba/templates/base.html.j2 b/buba/templates/base.html.j2 index 28bb51c..5d0523d 100644 --- a/buba/templates/base.html.j2 +++ b/buba/templates/base.html.j2 @@ -8,7 +8,6 @@ -

{{ self.page_title() }}

{% block page_body %}{% endblock %} \ No newline at end of file diff --git a/buba/templates/home.html.j2 b/buba/templates/home.html.j2 index 7f878a6..b729ab6 100644 --- a/buba/templates/home.html.j2 +++ b/buba/templates/home.html.j2 @@ -2,4 +2,43 @@ {% block page_title %}CCCHH Buba{% endblock %} {% block page_body %}
...
+

Submit Your Own Info

+
+
+
+
+ + +
+
+ {% for r in user_forms %} +
+
+
+
+
+
+
+ +
+
+ {% endfor %} +
+
+ +
+
+
+

Submitting info via REST

+

You can also submit your info via a PUT to https://buba.ccchh.net/user-entry, with a JSON body like this:

+
+{
+    "title": "My great info",
+    "rows": [
+        ["line 1 info", "123"],
+        ["line 2 info", "23:42"]
+    ]
+}
+    
+
{% endblock %}