From bdc7c2b1bcc0126414bb8b31d748307cd5fb5303 Mon Sep 17 00:00:00 2001 From: strdst Date: Tue, 3 Jun 2025 22:14:28 +0200 Subject: [PATCH 01/30] make the time animation look nice :3 --- buba/animations/time.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/buba/animations/time.py b/buba/animations/time.py index 5affeef..c1b70c2 100644 --- a/buba/animations/time.py +++ b/buba/animations/time.py @@ -10,12 +10,9 @@ class BubaTime(BubaAnimation): super().__init__(buba) def run(self): - self.buba.simple_text(page=0, row=0, col=91, 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) + self.buba.text(page=0, row=0, col_start=0, col_end=120, text="Chaos Computer Club", align=BubaCmd.ALIGN_CENTER) + self.buba.text(page=0, row=1, col_start=0, col_end=120, text="Hansestadt Hamburg", 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) + self.buba.text(page=0, row=3, col_start=0, col_end=120, text=datetime.now().strftime("%Y-%m-%d %H:%M"), align=BubaCmd.ALIGN_CENTER) + + sleep(10) From 8e9fa0b16e11c797d641e5318636f20c3e43b128 Mon Sep 17 00:00:00 2001 From: strdst Date: Tue, 3 Jun 2025 22:35:29 +0200 Subject: [PATCH 02/30] fix col_end --- buba/animations/time.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/buba/animations/time.py b/buba/animations/time.py index c1b70c2..904a5aa 100644 --- a/buba/animations/time.py +++ b/buba/animations/time.py @@ -10,9 +10,9 @@ class BubaTime(BubaAnimation): super().__init__(buba) def run(self): - self.buba.text(page=0, row=0, col_start=0, col_end=120, text="Chaos Computer Club", align=BubaCmd.ALIGN_CENTER) - self.buba.text(page=0, row=1, col_start=0, col_end=120, text="Hansestadt Hamburg", align=BubaCmd.ALIGN_CENTER) + 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=3, col_start=0, col_end=120, text=datetime.now().strftime("%Y-%m-%d %H:%M"), 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) sleep(10) From cc38689bfc31b0ebc6d2caf33114edddbbaad207 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Tue, 10 Jun 2025 22:22:56 +0200 Subject: [PATCH 03/30] Try to work with timezones --- buba/animations/dbf.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/buba/animations/dbf.py b/buba/animations/dbf.py index 9c32980..8db52d2 100644 --- a/buba/animations/dbf.py +++ b/buba/animations/dbf.py @@ -4,6 +4,7 @@ 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 @@ -12,6 +13,7 @@ from deutschebahn.db_infoscreen import DBInfoscreen from buba.bubaanimator import BubaAnimation from buba.bubacmd import BubaCmd +LOG = logging.getLogger(__name__) class DBFAnimation(BubaAnimation): def __init__(self, buba: BubaCmd, ds100="AHST", station="Holstenstraße", count=3): @@ -43,9 +45,9 @@ class DBFAnimation(BubaAnimation): @staticmethod def countdown(dt: datetime): - now = datetime.datetime.now() + now = datetime.datetime.now().astimezone() try: - dep_time = datetime.datetime.strptime(dt, "%H:%M").time() + dep_time = datetime.datetime.strptime(dt, "%H:%M").replace(tzinfo=now.tzinfo).time() except TypeError as e: return "--" @@ -53,7 +55,9 @@ class DBFAnimation(BubaAnimation): dep_date = now.date() # Calculate timedelta under the above assumption - dep_td = datetime.datetime.combine(dep_date, dep_time) - now + dep_td = datetime.datetime.combine(dep_date, dep_time,tzinfo=now.tzinfo) - now + + LOG.info(f"Processing time {datetime.datetime.combine(dep_date, dep_time, tzinfo=now.tzinfo)}, delta {dep_td}") # If the calculated timedelta is more than one hour in the past, # assume that the day should actually be the next day From 123803d61927723580abccb3d370bdbff76f2bc6 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Thu, 12 Jun 2025 21:15:10 +0200 Subject: [PATCH 04/30] Properly use date and timezones --- buba/animations/dbf.py | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/buba/animations/dbf.py b/buba/animations/dbf.py index 8db52d2..2ad4a0a 100644 --- a/buba/animations/dbf.py +++ b/buba/animations/dbf.py @@ -47,32 +47,20 @@ class DBFAnimation(BubaAnimation): def countdown(dt: datetime): now = datetime.datetime.now().astimezone() try: - dep_time = datetime.datetime.strptime(dt, "%H:%M").replace(tzinfo=now.tzinfo).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,tzinfo=now.tzinfo) - now - - LOG.info(f"Processing time {datetime.datetime.combine(dep_date, dep_time, tzinfo=now.tzinfo)}, delta {dep_td}") - - # 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: From c39bd1ce53b19d83d641f8a84bee753b70a8c12c Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Thu, 12 Jun 2025 22:15:13 +0200 Subject: [PATCH 05/30] Weekdays and "now" in German --- buba/bubaanimator.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/buba/bubaanimator.py b/buba/bubaanimator.py index b34bef1..aebe3d6 100644 --- a/buba/bubaanimator.py +++ b/buba/bubaanimator.py @@ -7,6 +7,16 @@ from time import sleep from buba.bubacmd import BubaCmd +WEEKDAYS_DE = [ + "Mo", + "Di", + "Mi", + "Do", + "Fr", + "Sa", + "So" +] + class BubaAnimation: def __init__(self, buba: BubaCmd): self.log = logging.getLogger(type(self).__name__) @@ -42,13 +52,14 @@ class BubaAnimation: now_delta = dt - now day_delta = dt - from_day_start if now_delta < timedelta(seconds=60): - return "now" + return "jetzt" 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("%a") # weekday + return WEEKDAYS_DE[dt.weekday()] return dt.strftime("%d.%m.") @staticmethod From 441b1a2890fde5e9182ddc9cc5a8cca4d16d6e5b Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Thu, 12 Jun 2025 22:19:14 +0200 Subject: [PATCH 06/30] Clear screen --- buba/animations/time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buba/animations/time.py b/buba/animations/time.py index 904a5aa..cfe44f0 100644 --- a/buba/animations/time.py +++ b/buba/animations/time.py @@ -12,7 +12,7 @@ class BubaTime(BubaAnimation): def run(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="", 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) sleep(10) From 2e4e0d072d5bf9701014fe49f60f9651a132a9f0 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Fri, 13 Jun 2025 18:45:40 +0200 Subject: [PATCH 07/30] Add debugging output to figure out why the times are wrong --- buba/bubaanimator.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/buba/bubaanimator.py b/buba/bubaanimator.py index aebe3d6..199df40 100644 --- a/buba/bubaanimator.py +++ b/buba/bubaanimator.py @@ -6,6 +6,7 @@ from time import sleep from buba.bubacmd import BubaCmd +LOG = logging.getLogger(__name__) WEEKDAYS_DE = [ "Mo", @@ -40,6 +41,19 @@ class BubaAnimation: it = iter(it) return iter(lambda: tuple(islice(it, size)), ()) + @staticmethod + def humanize_delta(dt, now_delta, day_delta): + 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): """ @@ -51,16 +65,9 @@ class BubaAnimation: 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 "jetzt" - 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 WEEKDAYS_DE[dt.weekday()] - return dt.strftime("%d.%m.") + h = BubaAnimation.humanize_delta(dt, now_delta, day_delta) + LOG.info(f"countdown({dt}) {now_delta} {day_delta} {h}") + return h @staticmethod def ellipsis(text, max=28): From 4310ea5b6075d9f8a04489a8bf86037f8d14e7ab Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Fri, 13 Jun 2025 19:05:35 +0200 Subject: [PATCH 08/30] Actually update trains continuously --- buba/animations/dbf.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/buba/animations/dbf.py b/buba/animations/dbf.py index 2ad4a0a..a444dbf 100644 --- a/buba/animations/dbf.py +++ b/buba/animations/dbf.py @@ -36,12 +36,13 @@ class DBFAnimation(BubaAnimation): 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) + while True: + 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): From 99574b020f3105bd9f72af1771a553ae650b0a74 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Fri, 13 Jun 2025 19:08:28 +0200 Subject: [PATCH 09/30] Down to debug level --- buba/bubaanimator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buba/bubaanimator.py b/buba/bubaanimator.py index 199df40..a9c7493 100644 --- a/buba/bubaanimator.py +++ b/buba/bubaanimator.py @@ -66,7 +66,7 @@ class BubaAnimation: now_delta = dt - now day_delta = dt - from_day_start h = BubaAnimation.humanize_delta(dt, now_delta, day_delta) - LOG.info(f"countdown({dt}) {now_delta} {day_delta} {h}") + LOG.debug(f"countdown({dt}) {now_delta} {day_delta} {h}") return h @staticmethod From 7e8ad576c487f1037f75ea4d02ee4bb6bd692a42 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Fri, 13 Jun 2025 19:38:24 +0200 Subject: [PATCH 10/30] Also update in a loop --- buba/animations/icalevents.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/buba/animations/icalevents.py b/buba/animations/icalevents.py index a51f16f..955095a 100644 --- a/buba/animations/icalevents.py +++ b/buba/animations/icalevents.py @@ -22,12 +22,13 @@ class IcalEvents(BubaAnimation): return f"<{type(self).__name__}, {self.url}>" def update(self): - tz = timezone(os.getenv("TZ", "Europe/Berlin")) - events = icalevents.icalevents.events(self.url, tzinfo=tz, sort=True, end=datetime.now(tz) + timedelta(days=14)) - for event in events: - event.start = event.start.astimezone(tz) - self.events = events - sleep(600) + while True: + tz = timezone(os.getenv("TZ", "Europe/Berlin")) + events = icalevents.icalevents.events(self.url, tzinfo=tz, sort=True, end=datetime.now(tz) + timedelta(days=14)) + 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)): From c7f1752fad09c628d951c51ddb9a51814ea332af Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Fri, 13 Jun 2025 20:15:01 +0200 Subject: [PATCH 11/30] Add SpaceAPI info --- buba/__main__.py | 2 ++ buba/animations/spaceapi.py | 65 +++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 buba/animations/spaceapi.py diff --git a/buba/__main__.py b/buba/__main__.py index 6b0f1d6..29cf283 100644 --- a/buba/__main__.py +++ b/buba/__main__.py @@ -8,6 +8,7 @@ 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.spaceapi import Spaceapi from buba.animations.time import BubaTime from buba.appconfig import AppConfig from buba.bubaanimator import BubaAnimator @@ -34,6 +35,7 @@ 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(Spaceapi, "https://spaceapi.hamburg.ccc.de", "CCCHH") animator.add(SnakeAnimation) @app.route("/static/") diff --git a/buba/animations/spaceapi.py b/buba/animations/spaceapi.py new file mode 100644 index 0000000..07bb0db --- /dev/null +++ b/buba/animations/spaceapi.py @@ -0,0 +1,65 @@ +import json +import logging +from datetime import datetime +from threading import Thread +from time import sleep + +import requests + +from buba.bubaanimator 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 + self.data = {} + self.load() + Thread(target=self.update, daemon=True).start() + + def load(self): + res = requests.get(self.url) + self.data = json.loads(res.text) + + def update(self): + while True: + self.load() + sleep(60) + + def humanize(self, dt): + td = dt - datetime.now().astimezone() + self.log.debug(f"dt {dt}, td {td}, {td.total_seconds()}") + 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") + + def run(self): + open = "open" if self.data["state"]["open"] else "closed" + since = datetime.fromtimestamp(self.data["state"]["lastchange"]).astimezone() + temp = int(self.data["sensors"]["temperature"][0]["value"]) + hum = int(self.data["sensors"]["humidity"][0]["value"]) + printers = {} + for p in self.data["sensors"]["ext_3d_printer_busy_state"]: + printers[p["name"]] = { + "busy": p["value"] != 0, + } + for p in self.data["sensors"]["ext_3d_printer_minutes_remaining"]: + printers[p["name"]]["remaining"] = p["value"] + printstatus = [] + for n, p in printers.items(): + if p["busy"]: + printstatus.append(f"{n} remaining {p['remaining']}") + else: + printstatus.append(f"{n} idle") + + self.buba.text(page=0, row=0, col_start=0, col_end=119, text=f"CCCHH {open} {self.humanize(since)}", align=BubaCmd.ALIGN_LEFT) + self.buba.text(page=0, row=1, col_start=0, col_end=119, text=f"Outside: {temp}°C at {hum}% rel.hum.", align=BubaCmd.ALIGN_LEFT) + self.buba.text(page=0, row=2, col_start=0, col_end=119, text=", ".join(printstatus), align=BubaCmd.ALIGN_LEFT) + + sleep(10) From 84925687ca38b1c55d10b5bf158560abd7156373 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 14 Jun 2025 13:36:35 +0200 Subject: [PATCH 12/30] Refactor paged display If oyu have a list of things you would like to show, you can simply use BubaAnimation.pages(). The default layout is a long title left-justified and a short info (date, time) right-justified. Overrride BubaAnimation.write_row() if you need a different layout. --- buba/animations/dbf.py | 47 ++++++++++++++--------------------- buba/animations/icalevents.py | 17 +------------ buba/animations/spaceapi.py | 14 +++++------ buba/bubaanimator.py | 27 ++++++++++++++++++++ 4 files changed, 54 insertions(+), 51 deletions(-) diff --git a/buba/animations/dbf.py b/buba/animations/dbf.py index a444dbf..0d74729 100644 --- a/buba/animations/dbf.py +++ b/buba/animations/dbf.py @@ -45,7 +45,9 @@ class DBFAnimation(BubaAnimation): sleep(60) @staticmethod - def countdown(dt: datetime): + def countdown(dt: datetime, cancelled:bool): + if cancelled: + return "--" now = datetime.datetime.now().astimezone() try: day = now.strftime("%y-%m-%d") @@ -97,31 +99,20 @@ class DBFAnimation(BubaAnimation): train = train.replace(" ", "") return train + def write_row(self, row: int, line: list[str], split=100) -> None: + if len(line) < 3: + super().write_row(row, line) + else: + self.buba.text(page=0, row=row, col_start=0, col_end=11, + text=line[0]) + self.buba.text(page=0, row=row, col_start=12, col_end=104, + text=line[1]) + self.buba.text(page=0, row=row, col_start=105, col_end=119, + text=line[2], align=BubaCmd.ALIGN_RIGHT) + 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) + self.pages(self.station, [[ + self.short_train(train['train']), + self.short_station(train['destination']), + self.countdown(train['actualDeparture'], train['isCancelled']), + ] for train in self.trains[:self.count]]) diff --git a/buba/animations/icalevents.py b/buba/animations/icalevents.py index 955095a..b476cab 100644 --- a/buba/animations/icalevents.py +++ b/buba/animations/icalevents.py @@ -31,19 +31,4 @@ class IcalEvents(BubaAnimation): 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) + self.pages(self.title, [[e.summary, self.countdown(e.start)] for e in self.events]) diff --git a/buba/animations/spaceapi.py b/buba/animations/spaceapi.py index 07bb0db..bd033f8 100644 --- a/buba/animations/spaceapi.py +++ b/buba/animations/spaceapi.py @@ -52,14 +52,14 @@ class Spaceapi(BubaAnimation): for p in self.data["sensors"]["ext_3d_printer_minutes_remaining"]: printers[p["name"]]["remaining"] = p["value"] printstatus = [] - for n, p in printers.items(): + for n, p in sorted(printers.items()): if p["busy"]: - printstatus.append(f"{n} remaining {p['remaining']}") + printstatus.append(f"{n} {p['remaining']}m left") else: printstatus.append(f"{n} idle") - self.buba.text(page=0, row=0, col_start=0, col_end=119, text=f"CCCHH {open} {self.humanize(since)}", align=BubaCmd.ALIGN_LEFT) - self.buba.text(page=0, row=1, col_start=0, col_end=119, text=f"Outside: {temp}°C at {hum}% rel.hum.", align=BubaCmd.ALIGN_LEFT) - self.buba.text(page=0, row=2, col_start=0, col_end=119, text=", ".join(printstatus), align=BubaCmd.ALIGN_LEFT) - - sleep(10) + self.pages("CCCHH Space API", [ + [f"CCCHH {open} {self.humanize(since)}"], + [f"Outside: {temp}°C at {hum}% rel.hum."], + [", ".join(printstatus)] + ]) diff --git a/buba/bubaanimator.py b/buba/bubaanimator.py index a9c7493..255eb1b 100644 --- a/buba/bubaanimator.py +++ b/buba/bubaanimator.py @@ -18,6 +18,7 @@ WEEKDAYS_DE = [ "So" ] + class BubaAnimation: def __init__(self, buba: BubaCmd): self.log = logging.getLogger(type(self).__name__) @@ -81,6 +82,32 @@ class BubaAnimation: text = text[:max - 2] + "..." # we can get away with just 2, since the periods are very narrow return text + def write_row(self, row: int, line: list[str], split=100) -> None: + if len(line) == 1: + self.buba.text(page=0, row=row, col_start=0, col_end=119, + text=self.ellipsis(line[0], 35), align=BubaCmd.ALIGN_LEFT) + else: + self.buba.text(page=0, row=row, col_start=0, col_end=split, text=self.ellipsis(line[0], 25)) + self.buba.text(page=0, row=row, col_start=split+1, col_end=119, + text=line[1], align=BubaCmd.ALIGN_RIGHT) + + def pages(self, title: str, lines: list[list[str]]) -> None: + pages = list(self.chunk(lines, 3)) + for n, page in enumerate(pages): + if len(pages) <= 1: + self.write_row(0, [title]) + else: + self.write_row(0, [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(10) + class BubaAnimator: def __init__(self, buba: BubaCmd): From 0adabf775863c537f080ea6ed98fbff3a589c34b Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Jun 2025 17:41:34 +0200 Subject: [PATCH 13/30] Further refactor By specifying a layout, you can format the page directly, instead of having to overrride write_row. --- buba/animations/dbf.py | 21 ++++++++------------- buba/bubaanimator.py | 38 ++++++++++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/buba/animations/dbf.py b/buba/animations/dbf.py index 0d74729..0bca3f1 100644 --- a/buba/animations/dbf.py +++ b/buba/animations/dbf.py @@ -10,12 +10,18 @@ from time import sleep from deutschebahn.db_infoscreen import DBInfoscreen -from buba.bubaanimator import BubaAnimation +from buba.bubaanimator import BubaAnimation, LineLayoutColumn from buba.bubacmd import BubaCmd LOG = logging.getLogger(__name__) class DBFAnimation(BubaAnimation): + dbf_layout = [ + LineLayoutColumn(12, BubaCmd.ALIGN_LEFT), + LineLayoutColumn(81, BubaCmd.ALIGN_LEFT), + LineLayoutColumn(27, BubaCmd.ALIGN_RIGHT), + ] + def __init__(self, buba: BubaCmd, ds100="AHST", station="Holstenstraße", count=3): super().__init__(buba) self.dbi = DBInfoscreen("trains.xatlabs.com") @@ -99,20 +105,9 @@ class DBFAnimation(BubaAnimation): train = train.replace(" ", "") return train - def write_row(self, row: int, line: list[str], split=100) -> None: - if len(line) < 3: - super().write_row(row, line) - else: - self.buba.text(page=0, row=row, col_start=0, col_end=11, - text=line[0]) - self.buba.text(page=0, row=row, col_start=12, col_end=104, - text=line[1]) - self.buba.text(page=0, row=row, col_start=105, col_end=119, - text=line[2], align=BubaCmd.ALIGN_RIGHT) - def run(self): self.pages(self.station, [[ self.short_train(train['train']), self.short_station(train['destination']), self.countdown(train['actualDeparture'], train['isCancelled']), - ] for train in self.trains[:self.count]]) + ] for train in self.trains[:self.count]], layout=self.dbf_layout) diff --git a/buba/bubaanimator.py b/buba/bubaanimator.py index 255eb1b..27b9fd9 100644 --- a/buba/bubaanimator.py +++ b/buba/bubaanimator.py @@ -1,4 +1,5 @@ import logging +from dataclasses import dataclass from datetime import datetime, timedelta from itertools import islice from threading import Thread @@ -19,7 +20,18 @@ WEEKDAYS_DE = [ ] +# class layout with width, alignment; list of layouts +@dataclass +class LineLayoutColumn: + width: int + align: int + + 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 @@ -82,22 +94,32 @@ class BubaAnimation: text = text[:max - 2] + "..." # we can get away with just 2, since the periods are very narrow return text - def write_row(self, row: int, line: list[str], split=100) -> None: + def write_row(self, row: int, line: list[str], layout: list[LineLayoutColumn]) -> None: + # pass a layout with n columns, or make it an instance variable if len(line) == 1: self.buba.text(page=0, row=row, col_start=0, col_end=119, text=self.ellipsis(line[0], 35), align=BubaCmd.ALIGN_LEFT) + elif len(line) < len(layout) and len(line) == 2: + self.write_row(row, line, self.default_layout) else: - self.buba.text(page=0, row=row, col_start=0, col_end=split, text=self.ellipsis(line[0], 25)) - self.buba.text(page=0, row=row, col_start=split+1, col_end=119, - text=line[1], align=BubaCmd.ALIGN_RIGHT) + col = 0 + for i, ll in enumerate(layout): + t = line[i] if len(line) > i else "" + if ll.width > 24: + t = self.ellipsis(t, int(ll.width / 3.7)) + self.buba.text(page=0, row=row, col_start=col, col_end=col+ll.width-1, text=t, align=ll.align) + col += ll.width - def pages(self, title: str, lines: list[list[str]]) -> None: + def pages(self, title: str, lines: list[list[str]], layout=None) -> None: + # pass the layout through to write_row + if layout is None: + layout = self.default_layout pages = list(self.chunk(lines, 3)) for n, page in enumerate(pages): if len(pages) <= 1: - self.write_row(0, [title]) + self.write_row(0, [title], layout) else: - self.write_row(0, [title, f"({n + 1}/{len(pages)})"]) + self.write_row(0, [title, f"({n + 1}/{len(pages)})"], layout) for i in range(3): if i >= len(page): self.buba.text(page=0, row=i + 1, col_start=0, col_end=119, text="") @@ -105,7 +127,7 @@ class BubaAnimation: p = page[i] if isinstance(p, str): p = [p, ] - self.write_row(i + 1, p) + self.write_row(i + 1, p, layout) sleep(10) From 024345a17d8e68902de03ead297e40c51b42aca1 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Jun 2025 18:03:33 +0200 Subject: [PATCH 14/30] Show info from Clubassistant Since we now have so many animations, show them in a random order --- buba/__main__.py | 3 ++ buba/animations/clubassistant.py | 62 ++++++++++++++++++++++++++++++++ buba/appconfig.py | 1 + buba/bubaanimator.py | 13 +++++-- 4 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 buba/animations/clubassistant.py diff --git a/buba/__main__.py b/buba/__main__.py index 29cf283..1984073 100644 --- a/buba/__main__.py +++ b/buba/__main__.py @@ -5,6 +5,7 @@ from bottle_log import LoggingPlugin from bottle_websocket import websocket, GeventWebSocketServer from geventwebsocket.websocket import WebSocket +from buba.animations.clubassistant import Clubassistant from buba.animations.dbf import DBFAnimation from buba.animations.icalevents import IcalEvents from buba.animations.snake import SnakeAnimation @@ -36,8 +37,10 @@ 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(Spaceapi, "https://spaceapi.hamburg.ccc.de", "CCCHH") +animator.add(Clubassistant, "https://club-assistant.ccchh.net", config.clubassistant_token) animator.add(SnakeAnimation) + @app.route("/static/") def server_static(filepath): return static_file(filepath, root=config.staticpath) diff --git a/buba/animations/clubassistant.py b/buba/animations/clubassistant.py new file mode 100644 index 0000000..06984ee --- /dev/null +++ b/buba/animations/clubassistant.py @@ -0,0 +1,62 @@ +""" +Data from club-assistant.hamburg.ccc.de +""" +import json +from threading import Thread +from time import sleep + +import requests + +from buba.bubaanimator 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 Clubassistant(BubaAnimation): + def __init__(self, buba, url, token, endpoints=None): + if endpoints is None: + endpoints = default_endpoints + super().__init__(buba) + self.url = url + self.token = token + self.endpoints = endpoints + self.data = [] + self.load() + Thread(target=self.update, daemon=True).start() + + def load(self): + data = [] + 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) + data.append([e["name"], f"{js['state']}{js['attributes']['unit_of_measurement']}"]) + self.data = data + + def update(self): + while True: + self.load() + sleep(60) + + def run(self): + self.pages("Club Assistant", self.data) \ No newline at end of file diff --git a/buba/appconfig.py b/buba/appconfig.py index 9124a9d..8f96d3f 100644 --- a/buba/appconfig.py +++ b/buba/appconfig.py @@ -16,6 +16,7 @@ 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 diff --git a/buba/bubaanimator.py b/buba/bubaanimator.py index 27b9fd9..6ea2272 100644 --- a/buba/bubaanimator.py +++ b/buba/bubaanimator.py @@ -1,4 +1,5 @@ import logging +import random from dataclasses import dataclass from datetime import datetime, timedelta from itertools import islice @@ -139,14 +140,20 @@ class BubaAnimator: Thread(target=self.run, daemon=True).start() def run(self): + last = -1 while True: if len(self.animations) == 0: self.log.debug("No animations, sleeping...") sleep(2) else: - for a in self.animations: - self.log.debug(f"Starting animation: {a}") - a.run() + while True: + next = random.randint(0, len(self.animations) - 1) + if next != last: + break + last = next + a = self.animations[next] + self.log.debug(f"Starting animation: {a}") + a.run() def add(self, animation, *args, **kwargs): self.animations.append(animation(self.buba, *args, **kwargs)) From f407348d630033576e3f606e0985ef26b2cbd8d4 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Jun 2025 18:25:30 +0200 Subject: [PATCH 15/30] Randomize animations --- buba/bubaanimator.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/buba/bubaanimator.py b/buba/bubaanimator.py index 6ea2272..6978d57 100644 --- a/buba/bubaanimator.py +++ b/buba/bubaanimator.py @@ -146,14 +146,9 @@ class BubaAnimator: self.log.debug("No animations, sleeping...") sleep(2) else: - while True: - next = random.randint(0, len(self.animations) - 1) - if next != last: - break - last = next - a = self.animations[next] - self.log.debug(f"Starting animation: {a}") - a.run() + for a in random.sample(self.animations, len(self.animations)): + self.log.debug(f"Starting animation: {a}") + a.run() def add(self, animation, *args, **kwargs): self.animations.append(animation(self.buba, *args, **kwargs)) From c37ce6ffd12bbab007709b16c189b23a2f534128 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Jun 2025 18:49:31 +0200 Subject: [PATCH 16/30] Better error handling --- buba/__main__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/buba/__main__.py b/buba/__main__.py index 1984073..4a1f79b 100644 --- a/buba/__main__.py +++ b/buba/__main__.py @@ -37,7 +37,10 @@ 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(Spaceapi, "https://spaceapi.hamburg.ccc.de", "CCCHH") -animator.add(Clubassistant, "https://club-assistant.ccchh.net", config.clubassistant_token) +if config.clubassistant_token is not None: + animator.add(Clubassistant, "https://club-assistant.ccchh.net", config.clubassistant_token) +else + log.warning("Club Assistant token not set, not activating animation") animator.add(SnakeAnimation) From d748c9ae0ec0d99a08def69ccba0f875618a3220 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Jun 2025 19:00:59 +0200 Subject: [PATCH 17/30] Better error handling --- buba/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buba/__main__.py b/buba/__main__.py index 4a1f79b..9bf4225 100644 --- a/buba/__main__.py +++ b/buba/__main__.py @@ -39,7 +39,7 @@ animator.add(IcalEvents, url="https://cloud.hamburg.ccc.de/remote.php/dav/public animator.add(Spaceapi, "https://spaceapi.hamburg.ccc.de", "CCCHH") if config.clubassistant_token is not None: animator.add(Clubassistant, "https://club-assistant.ccchh.net", config.clubassistant_token) -else +else: log.warning("Club Assistant token not set, not activating animation") animator.add(SnakeAnimation) From ca540aff9959d282b0935fd8cecf21a6e40a75e2 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sun, 15 Jun 2025 19:14:34 +0200 Subject: [PATCH 18/30] Better error handling --- buba/bubaanimator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/buba/bubaanimator.py b/buba/bubaanimator.py index 6978d57..bc1e0a6 100644 --- a/buba/bubaanimator.py +++ b/buba/bubaanimator.py @@ -151,4 +151,7 @@ class BubaAnimator: a.run() def add(self, animation, *args, **kwargs): - self.animations.append(animation(self.buba, *args, **kwargs)) + try: + self.animations.append(animation(self.buba, *args, **kwargs)) + except Exception as e: + self.log.error(f"Failed to add animation: {e}") From 64a3c729be641f039547105b910b749a0a3658ec Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Mon, 16 Jun 2025 09:33:22 +0200 Subject: [PATCH 19/30] Refactor Animation to simplify Also add some documentation on how to write animations. --- README.md | 20 +- buba/__main__.py | 20 +- buba/animationconfig.py | 43 +++++ buba/animations/charset.py | 4 +- buba/animations/dbf.py | 27 +-- .../{clubassistant.py => homeassistant.py} | 26 +-- buba/animations/icalevents.py | 19 +- buba/animations/snake.py | 4 +- buba/animations/spaceapi.py | 53 ++---- buba/animations/time.py | 4 +- buba/bubaanimation.py | 180 ++++++++++++++++++ buba/bubaanimator.py | 125 ++---------- 12 files changed, 302 insertions(+), 223 deletions(-) create mode 100644 buba/animationconfig.py rename buba/animations/{clubassistant.py => homeassistant.py} (70%) create mode 100644 buba/bubaanimation.py 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 9bf4225..a6982ea 100644 --- a/buba/__main__.py +++ b/buba/__main__.py @@ -5,12 +5,7 @@ from bottle_log import LoggingPlugin from bottle_websocket import websocket, GeventWebSocketServer from geventwebsocket.websocket import WebSocket -from buba.animations.clubassistant import Clubassistant -from buba.animations.dbf import DBFAnimation -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.animationconfig import setup_animations from buba.appconfig import AppConfig from buba.bubaanimator import BubaAnimator from buba.bubacmd import BubaCmd @@ -30,18 +25,7 @@ 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(Spaceapi, "https://spaceapi.hamburg.ccc.de", "CCCHH") -if config.clubassistant_token is not None: - animator.add(Clubassistant, "https://club-assistant.ccchh.net", config.clubassistant_token) -else: - log.warning("Club Assistant token not set, not activating animation") -animator.add(SnakeAnimation) +setup_animations(config, animator) @app.route("/static/") diff --git a/buba/animationconfig.py b/buba/animationconfig.py new file mode 100644 index 0000000..960f74d --- /dev/null +++ b/buba/animationconfig.py @@ -0,0 +1,43 @@ +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.bubaanimator import BubaAnimator + +LOG = logging.getLogger(__name__) + + +def setup_animations(config, animator: BubaAnimator): + cs = BubaCharset(animator.buba) + # animator.add(cs) + + bt = BubaTime(animator.buba) + animator.add(bt) + + snake = SnakeAnimation(animator.buba) + animator.add(snake) + + dbf_ahst = DBF(animator.buba, ds100="AHST", station="Holstenstraße") + dbf_ahs = DBF(animator.buba, ds100="AHS", station="Altona", count=9) + animator.add(dbf_ahst) + animator.add(dbf_ahs) + + ccchh_events = IcalEvents(animator.buba, + url="https://cloud.hamburg.ccc.de/remote.php/dav/public-calendars/QJAdExziSnNJEz5g?export", + title="CCCHH Events") + animator.add(ccchh_events) + + ccchh_spaceapi = Spaceapi(animator.buba, "https://spaceapi.hamburg.ccc.de", "CCCHH") + animator.add(ccchh_spaceapi) + + if config.clubassistant_token is not None: + ca = HomeAssistant(animator.buba, "https://club-assistant.ccchh.net", "Club Assistant", + config.clubassistant_token) + animator.add(ca) + else: + LOG.warning("Club Assistant token not set, not activating animation") 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 0bca3f1..d58b7f3 100644 --- a/buba/animations/dbf.py +++ b/buba/animations/dbf.py @@ -10,12 +10,13 @@ from time import sleep from deutschebahn.db_infoscreen import DBInfoscreen -from buba.bubaanimator import BubaAnimation, LineLayoutColumn +from buba.bubaanimator import LineLayoutColumn +from buba.bubaanimation import BubaAnimation from buba.bubacmd import BubaCmd LOG = logging.getLogger(__name__) -class DBFAnimation(BubaAnimation): +class DBF(BubaAnimation): dbf_layout = [ LineLayoutColumn(12, BubaCmd.ALIGN_LEFT), LineLayoutColumn(81, BubaCmd.ALIGN_LEFT), @@ -26,30 +27,21 @@ class DBFAnimation(BubaAnimation): 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): - while True: - 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, cancelled:bool): if cancelled: @@ -105,9 +97,10 @@ class DBFAnimation(BubaAnimation): train = train.replace(" ", "") return train - def run(self): - self.pages(self.station, [[ + 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]], layout=self.dbf_layout) + ] for train in self.trains[:self.count]] + self.show_pages() diff --git a/buba/animations/clubassistant.py b/buba/animations/homeassistant.py similarity index 70% rename from buba/animations/clubassistant.py rename to buba/animations/homeassistant.py index 06984ee..f1a1acb 100644 --- a/buba/animations/clubassistant.py +++ b/buba/animations/homeassistant.py @@ -7,7 +7,7 @@ from time import sleep import requests -from buba.bubaanimator import BubaAnimation +from buba.bubaanimation import BubaAnimation default_endpoints = [ { @@ -32,31 +32,21 @@ default_endpoints = [ } ] -class Clubassistant(BubaAnimation): - def __init__(self, buba, url, token, endpoints=None): +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 - self.data = [] - self.load() - Thread(target=self.update, daemon=True).start() - def load(self): - data = [] + 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) - data.append([e["name"], f"{js['state']}{js['attributes']['unit_of_measurement']}"]) - self.data = data - - def update(self): - while True: - self.load() - sleep(60) - - def run(self): - self.pages("Club Assistant", self.data) \ No newline at end of file + 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 b476cab..e905276 100644 --- a/buba/animations/icalevents.py +++ b/buba/animations/icalevents.py @@ -6,7 +6,7 @@ from time import sleep import icalevents.icalevents from pytz import timezone -from buba.bubaanimator import BubaAnimation +from buba.bubaanimation import BubaAnimation from buba.bubacmd import BubaCmd @@ -15,20 +15,13 @@ class IcalEvents(BubaAnimation): super().__init__(buba) self.url = url self.title = title - self.events = [] - Thread(target=self.update, daemon=True).start() def __repr__(self): return f"<{type(self).__name__}, {self.url}>" def update(self): - while True: - tz = timezone(os.getenv("TZ", "Europe/Berlin")) - events = icalevents.icalevents.events(self.url, tzinfo=tz, sort=True, end=datetime.now(tz) + timedelta(days=14)) - for event in events: - event.start = event.start.astimezone(tz) - self.events = events - sleep(600) - - def run(self): - self.pages(self.title, [[e.summary, self.countdown(e.start)] for e in self.events]) + tz = timezone(os.getenv("TZ", "Europe/Berlin")) + events = icalevents.icalevents.events(self.url, tzinfo=tz, sort=True, end=datetime.now(tz) + timedelta(days=14)) + for event in events: + event.start = event.start.astimezone(tz) + 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..55c13a8 100644 --- a/buba/animations/snake.py +++ b/buba/animations/snake.py @@ -1,7 +1,7 @@ import random from time import sleep -from buba.bubaanimator import BubaAnimation +from buba.bubaanimation import BubaAnimation class SnakeAnimation(BubaAnimation): @@ -34,7 +34,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) diff --git a/buba/animations/spaceapi.py b/buba/animations/spaceapi.py index bd033f8..e98a59a 100644 --- a/buba/animations/spaceapi.py +++ b/buba/animations/spaceapi.py @@ -6,7 +6,7 @@ from time import sleep import requests -from buba.bubaanimator import BubaAnimation +from buba.bubaanimation import BubaAnimation from buba.bubacmd import BubaCmd @@ -15,41 +15,21 @@ class Spaceapi(BubaAnimation): super().__init__(buba) self.url = url self.title = title - self.data = {} - self.load() - Thread(target=self.update, daemon=True).start() - - def load(self): - res = requests.get(self.url) - self.data = json.loads(res.text) def update(self): - while True: - self.load() - sleep(60) + res = requests.get(self.url) + data = json.loads(res.text) - def humanize(self, dt): - td = dt - datetime.now().astimezone() - self.log.debug(f"dt {dt}, td {td}, {td.total_seconds()}") - 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") - - def run(self): - open = "open" if self.data["state"]["open"] else "closed" - since = datetime.fromtimestamp(self.data["state"]["lastchange"]).astimezone() - temp = int(self.data["sensors"]["temperature"][0]["value"]) - hum = int(self.data["sensors"]["humidity"][0]["value"]) + 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 self.data["sensors"]["ext_3d_printer_busy_state"]: + for p in data["sensors"]["ext_3d_printer_busy_state"]: printers[p["name"]] = { "busy": p["value"] != 0, } - for p in self.data["sensors"]["ext_3d_printer_minutes_remaining"]: + for p in data["sensors"]["ext_3d_printer_minutes_remaining"]: printers[p["name"]]["remaining"] = p["value"] printstatus = [] for n, p in sorted(printers.items()): @@ -57,9 +37,18 @@ class Spaceapi(BubaAnimation): printstatus.append(f"{n} {p['remaining']}m left") else: printstatus.append(f"{n} idle") - - self.pages("CCCHH Space API", [ + 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 cfe44f0..8d0cb7a 100644 --- a/buba/animations/time.py +++ b/buba/animations/time.py @@ -1,7 +1,7 @@ from datetime import datetime from time import sleep -from buba.bubaanimator import BubaAnimation +from buba.bubaanimation import BubaAnimation from buba.bubacmd import BubaCmd @@ -9,7 +9,7 @@ class BubaTime(BubaAnimation): def __init__(self, buba: BubaCmd): super().__init__(buba) - def run(self): + 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="", align=BubaCmd.ALIGN_CENTER) 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 bc1e0a6..91b2e2b 100644 --- a/buba/bubaanimator.py +++ b/buba/bubaanimator.py @@ -1,8 +1,6 @@ import logging import random from dataclasses import dataclass -from datetime import datetime, timedelta -from itertools import islice from threading import Thread from time import sleep @@ -28,110 +26,6 @@ class LineLayoutColumn: align: int -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 - pass - - 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 humanize_delta(dt, now_delta, day_delta): - 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) - LOG.debug(f"countdown({dt}) {now_delta} {day_delta} {h}") - 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, row: int, line: list[str], layout: list[LineLayoutColumn]) -> None: - # pass a layout with n columns, or make it an instance variable - if len(line) == 1: - self.buba.text(page=0, row=row, col_start=0, col_end=119, - text=self.ellipsis(line[0], 35), align=BubaCmd.ALIGN_LEFT) - elif len(line) < len(layout) and len(line) == 2: - self.write_row(row, line, self.default_layout) - else: - col = 0 - for i, ll in enumerate(layout): - t = line[i] if len(line) > i else "" - if ll.width > 24: - t = self.ellipsis(t, int(ll.width / 3.7)) - self.buba.text(page=0, row=row, col_start=col, col_end=col+ll.width-1, text=t, align=ll.align) - col += ll.width - - def pages(self, title: str, lines: list[list[str]], layout=None) -> None: - # pass the layout through to write_row - if layout is None: - layout = self.default_layout - pages = list(self.chunk(lines, 3)) - for n, page in enumerate(pages): - if len(pages) <= 1: - self.write_row(0, [title], layout) - else: - self.write_row(0, [title, f"({n + 1}/{len(pages)})"], layout) - 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, layout) - sleep(10) - - class BubaAnimator: def __init__(self, buba: BubaCmd): self.log = logging.getLogger(__name__) @@ -140,7 +34,11 @@ class BubaAnimator: Thread(target=self.run, daemon=True).start() def run(self): - last = -1 + 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...") @@ -148,10 +46,11 @@ class BubaAnimator: else: 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): - try: - self.animations.append(animation(self.buba, *args, **kwargs)) - except Exception as e: - self.log.error(f"Failed to add animation: {e}") + def add(self, animation): + self.animations.append(animation) From 3ad796ffb2a2f6522747393d3e1b81cf337138d5 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Mon, 16 Jun 2025 09:40:08 +0200 Subject: [PATCH 20/30] Add CCC events --- buba/animationconfig.py | 5 +++++ buba/animations/icalevents.py | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/buba/animationconfig.py b/buba/animationconfig.py index 960f74d..a964515 100644 --- a/buba/animationconfig.py +++ b/buba/animationconfig.py @@ -1,3 +1,4 @@ +import datetime import logging from buba.animations.charset import BubaCharset @@ -30,7 +31,11 @@ def setup_animations(config, animator: BubaAnimator): 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)) animator.add(ccchh_events) + animator.add(ccc_events) ccchh_spaceapi = Spaceapi(animator.buba, "https://spaceapi.hamburg.ccc.de", "CCCHH") animator.add(ccchh_spaceapi) diff --git a/buba/animations/icalevents.py b/buba/animations/icalevents.py index e905276..3464bd9 100644 --- a/buba/animations/icalevents.py +++ b/buba/animations/icalevents.py @@ -11,17 +11,18 @@ from buba.bubacmd import BubaCmd 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.range = range def __repr__(self): return f"<{type(self).__name__}, {self.url}>" def update(self): tz = timezone(os.getenv("TZ", "Europe/Berlin")) - events = icalevents.icalevents.events(self.url, tzinfo=tz, sort=True, end=datetime.now(tz) + timedelta(days=14)) + events = icalevents.icalevents.events(self.url, tzinfo=tz, sort=True, end=datetime.now(tz) + self.range) for event in events: event.start = event.start.astimezone(tz) self.rows = [[e.summary, self.countdown(e.start)] for e in events] From 2321d7d552a4b95a2461f257e891bcb797aac0bc Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Mon, 16 Jun 2025 20:39:29 +0200 Subject: [PATCH 21/30] Add user submnission --- buba/__main__.py | 53 +++++++++++++++++++++++++++++++++-- buba/animationconfig.py | 25 ++++++++++------- buba/animations/icalevents.py | 3 -- buba/animations/webqueue.py | 48 +++++++++++++++++++++++++++++++ buba/bubacmd.py | 1 + buba/static/main.css | 12 ++++++++ buba/static/main.js | 41 ++++++++++++++++++++++++++- buba/templates/base.html.j2 | 1 - buba/templates/home.html.j2 | 28 ++++++++++++++++++ 9 files changed, 194 insertions(+), 18 deletions(-) create mode 100644 buba/animations/webqueue.py diff --git a/buba/__main__.py b/buba/__main__.py index a6982ea..57b8432 100644 --- a/buba/__main__.py +++ b/buba/__main__.py @@ -1,11 +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.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 @@ -25,7 +26,8 @@ TEMPLATE_PATH.insert(0, config.templatepath) websocket_clients = WebSocketClients() buba = BubaCmd(config.serial, websocket_clients.send) animator = BubaAnimator(buba) -setup_animations(config, animator) +webqueue = WebQueue(buba) +setup_animations(config, animator, webqueue) @app.route("/static/") @@ -36,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]) @@ -53,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 index a964515..109e6bc 100644 --- a/buba/animationconfig.py +++ b/buba/animationconfig.py @@ -8,25 +8,21 @@ 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): +def setup_animations(config, animator: BubaAnimator, webqueue: WebQueue): cs = BubaCharset(animator.buba) - # animator.add(cs) bt = BubaTime(animator.buba) - animator.add(bt) snake = SnakeAnimation(animator.buba) - animator.add(snake) dbf_ahst = DBF(animator.buba, ds100="AHST", station="Holstenstraße") dbf_ahs = DBF(animator.buba, ds100="AHS", station="Altona", count=9) - animator.add(dbf_ahst) - animator.add(dbf_ahs) ccchh_events = IcalEvents(animator.buba, url="https://cloud.hamburg.ccc.de/remote.php/dav/public-calendars/QJAdExziSnNJEz5g?export", @@ -34,15 +30,24 @@ def setup_animations(config, animator: BubaAnimator): ccc_events = IcalEvents(animator.buba, url="https://events.ccc.de/calendar/events.ics", title="CCC Events", range=datetime.timedelta(weeks=8)) - animator.add(ccchh_events) - animator.add(ccc_events) ccchh_spaceapi = Spaceapi(animator.buba, "https://spaceapi.hamburg.ccc.de", "CCCHH") - animator.add(ccchh_spaceapi) + ca = None if config.clubassistant_token is not None: ca = HomeAssistant(animator.buba, "https://club-assistant.ccchh.net", "Club Assistant", config.clubassistant_token) - animator.add(ca) 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(ccchh_spaceapi) + if ca is not None: + animator.add(ca) + animator.add(webqueue) diff --git a/buba/animations/icalevents.py b/buba/animations/icalevents.py index 3464bd9..148951c 100644 --- a/buba/animations/icalevents.py +++ b/buba/animations/icalevents.py @@ -1,13 +1,10 @@ import os from datetime import timedelta, datetime -from threading import Thread -from time import sleep import icalevents.icalevents from pytz import timezone from buba.bubaanimation import BubaAnimation -from buba.bubacmd import BubaCmd class IcalEvents(BubaAnimation): 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/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..05bdb37 100644 --- a/buba/static/main.css +++ b/buba/static/main.css @@ -29,4 +29,16 @@ svg.geavision__row { .gvson { fill: #4f0; +} + +.info-form__row { + display: flex; +} + +.info-form__field { + display: flex; + flex-direction: column; +} +.info-form__field * { + display: block; } \ 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..1623adb 100644 --- a/buba/templates/home.html.j2 +++ b/buba/templates/home.html.j2 @@ -2,4 +2,32 @@ {% block page_title %}CCCHH Buba{% endblock %} {% block page_body %}
...
+

Submit Your Own Info

+
+
+
+
+ + +
+
+ {% for r in user_forms %} +
+
+
+
+
+
+
+ +
+
+ {% endfor %} +
+
+ +
+
+
+
{% endblock %} From ea943e89b0ec566cb2aa31b3572f2a7b86dd4908 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Tue, 17 Jun 2025 08:51:49 +0200 Subject: [PATCH 22/30] Beautify --- buba/static/main.css | 11 +++++++++++ buba/templates/home.html.j2 | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/buba/static/main.css b/buba/static/main.css index 05bdb37..458f158 100644 --- a/buba/static/main.css +++ b/buba/static/main.css @@ -33,12 +33,23 @@ svg.geavision__row { .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/templates/home.html.j2 b/buba/templates/home.html.j2 index 1623adb..b729ab6 100644 --- a/buba/templates/home.html.j2 +++ b/buba/templates/home.html.j2 @@ -29,5 +29,16 @@ +

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 %} From e583bb5691539db40f91cef8b7440a2327ad46ae Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Tue, 17 Jun 2025 19:01:50 +0200 Subject: [PATCH 23/30] Filter out events that have concluded Sometimes, icalevents will return entries that have already ended. Filter them out. --- buba/animations/icalevents.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/buba/animations/icalevents.py b/buba/animations/icalevents.py index 148951c..4a74e56 100644 --- a/buba/animations/icalevents.py +++ b/buba/animations/icalevents.py @@ -18,8 +18,17 @@ class IcalEvents(BubaAnimation): 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) + self.range) + 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) + events = filter(lambda e: e.end > now, events) self.rows = [[e.summary, self.countdown(e.start)] for e in events] From 8aeeac62efe32cf41ba2d32f3020e16645b10768 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Tue, 17 Jun 2025 19:11:40 +0200 Subject: [PATCH 24/30] Speed up snake We cannot speed up updating the display, but we can limit the updates to once a second, and take two steps per second. --- buba/animations/snake.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/buba/animations/snake.py b/buba/animations/snake.py index 55c13a8..6eb52e2 100644 --- a/buba/animations/snake.py +++ b/buba/animations/snake.py @@ -1,4 +1,5 @@ import random +from datetime import datetime, timedelta from time import sleep from buba.bubaanimation import BubaAnimation @@ -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 From 63571e6c5366df239d4fd22e94c0d811a229f949 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Thu, 10 Jul 2025 16:14:16 +0200 Subject: [PATCH 25/30] Shorten train names for NME --- buba/animations/dbf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/buba/animations/dbf.py b/buba/animations/dbf.py index d58b7f3..e55b33b 100644 --- a/buba/animations/dbf.py +++ b/buba/animations/dbf.py @@ -88,6 +88,8 @@ class DBF(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"): From b969b7b3c3fa56a4d80ef31f6965e8ab698ad9a8 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 6 Sep 2025 13:59:55 +0200 Subject: [PATCH 26/30] Add Fux-Lichtspiele calendar --- buba/animationconfig.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/buba/animationconfig.py b/buba/animationconfig.py index 109e6bc..bfdbfd7 100644 --- a/buba/animationconfig.py +++ b/buba/animationconfig.py @@ -31,6 +31,10 @@ def setup_animations(config, animator: BubaAnimator, webqueue: WebQueue): 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 @@ -40,13 +44,14 @@ def setup_animations(config, animator: BubaAnimator, webqueue: WebQueue): else: LOG.warning("Club Assistant token not set, not activating animation") - # animator.add(cs) + 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) From e6098b44f882d75c3367f7111f33ec490b67f794 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 6 Sep 2025 14:46:55 +0200 Subject: [PATCH 27/30] Disable snake --- buba/animationconfig.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buba/animationconfig.py b/buba/animationconfig.py index bfdbfd7..cdfb9f4 100644 --- a/buba/animationconfig.py +++ b/buba/animationconfig.py @@ -46,7 +46,7 @@ def setup_animations(config, animator: BubaAnimator, webqueue: WebQueue): animator.add(cs) animator.add(bt) - animator.add(snake) + # animator.add(snake) animator.add(dbf_ahst) animator.add(dbf_ahs) animator.add(ccchh_events) From 162e8edc6e0b68abbd09ccad5d929817e372f78d Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 6 Sep 2025 14:55:40 +0200 Subject: [PATCH 28/30] Disable charset --- buba/animationconfig.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buba/animationconfig.py b/buba/animationconfig.py index cdfb9f4..22c7c06 100644 --- a/buba/animationconfig.py +++ b/buba/animationconfig.py @@ -44,7 +44,7 @@ def setup_animations(config, animator: BubaAnimator, webqueue: WebQueue): else: LOG.warning("Club Assistant token not set, not activating animation") - animator.add(cs) + # animator.add(cs) animator.add(bt) # animator.add(snake) animator.add(dbf_ahst) From 9a1b401b4bab823810f24b49c7359f5bbc44fda2 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 6 Sep 2025 15:01:02 +0200 Subject: [PATCH 29/30] Spell fux Lichtspiele correctly --- buba/animationconfig.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buba/animationconfig.py b/buba/animationconfig.py index 22c7c06..fcb85dc 100644 --- a/buba/animationconfig.py +++ b/buba/animationconfig.py @@ -33,7 +33,7 @@ def setup_animations(config, animator: BubaAnimator, webqueue: WebQueue): 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)) + title="fux Lichtspiele", range=datetime.timedelta(weeks=8)) ccchh_spaceapi = Spaceapi(animator.buba, "https://spaceapi.hamburg.ccc.de", "CCCHH") From d17a7770d67ce6e9d68680ea1c4a587c80fe7aed Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Tue, 9 Sep 2025 20:44:48 +0200 Subject: [PATCH 30/30] Show IP address --- buba/animationconfig.py | 4 ++-- buba/animations/time.py | 5 +++-- buba/appconfig.py | 6 ++++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/buba/animationconfig.py b/buba/animationconfig.py index fcb85dc..c8a7584 100644 --- a/buba/animationconfig.py +++ b/buba/animationconfig.py @@ -17,7 +17,7 @@ LOG = logging.getLogger(__name__) def setup_animations(config, animator: BubaAnimator, webqueue: WebQueue): cs = BubaCharset(animator.buba) - bt = BubaTime(animator.buba) + bt = BubaTime(animator.buba, config.ip) snake = SnakeAnimation(animator.buba) @@ -46,7 +46,7 @@ def setup_animations(config, animator: BubaAnimator, webqueue: WebQueue): # animator.add(cs) animator.add(bt) - # animator.add(snake) + animator.add(snake) animator.add(dbf_ahst) animator.add(dbf_ahs) animator.add(ccchh_events) diff --git a/buba/animations/time.py b/buba/animations/time.py index 8d0cb7a..78ef2f1 100644 --- a/buba/animations/time.py +++ b/buba/animations/time.py @@ -6,13 +6,14 @@ 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 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="", 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) sleep(10) diff --git a/buba/appconfig.py b/buba/appconfig.py index 8f96d3f..89befaf 100644 --- a/buba/appconfig.py +++ b/buba/appconfig.py @@ -22,3 +22,9 @@ class AppConfig: 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()