Compare commits

..

33 commits

Author SHA1 Message Date
d17a7770d6 Show IP address
All checks were successful
docker-image / docker (push) Successful in 8m19s
2025-09-09 20:44:48 +02:00
9a1b401b4b Spell fux Lichtspiele correctly
All checks were successful
docker-image / docker (push) Successful in 8m23s
2025-09-06 15:01:02 +02:00
162e8edc6e Disable charset
Some checks failed
docker-image / docker (push) Has been cancelled
2025-09-06 14:55:40 +02:00
e6098b44f8 Disable snake
All checks were successful
docker-image / docker (push) Successful in 8m14s
2025-09-06 14:46:55 +02:00
b969b7b3c3 Add Fux-Lichtspiele calendar
All checks were successful
docker-image / docker (push) Successful in 8m35s
2025-09-06 13:59:55 +02:00
63571e6c53 Shorten train names for NME
All checks were successful
docker-image / docker (push) Successful in 8m24s
2025-07-10 16:14:16 +02:00
8aeeac62ef Speed up snake
All checks were successful
docker-image / docker (push) Successful in 10m9s
We cannot speed up updating the display, but we can limit the updates to once a second, and take two steps per second.
2025-06-17 19:11:40 +02:00
e583bb5691 Filter out events that have concluded
Some checks failed
docker-image / docker (push) Has been cancelled
Sometimes, icalevents will return entries that have already ended. Filter them out.
2025-06-17 19:01:50 +02:00
ea943e89b0 Beautify
All checks were successful
docker-image / docker (push) Successful in 9m55s
2025-06-17 08:51:49 +02:00
2321d7d552 Add user submnission
All checks were successful
docker-image / docker (push) Successful in 10m24s
2025-06-16 20:39:29 +02:00
3ad796ffb2 Add CCC events
All checks were successful
docker-image / docker (push) Successful in 10m3s
2025-06-16 09:40:08 +02:00
64a3c729be Refactor Animation to simplify
Also add some documentation on how to write animations.
2025-06-16 09:33:22 +02:00
ca540aff99 Better error handling
All checks were successful
docker-image / docker (push) Successful in 10m14s
2025-06-15 19:14:34 +02:00
d748c9ae0e Better error handling
All checks were successful
docker-image / docker (push) Successful in 10m11s
2025-06-15 19:00:59 +02:00
c37ce6ffd1 Better error handling
All checks were successful
docker-image / docker (push) Successful in 9m53s
2025-06-15 18:49:31 +02:00
f407348d63 Randomize animations
All checks were successful
docker-image / docker (push) Successful in 9m53s
2025-06-15 18:25:30 +02:00
024345a17d Show info from Clubassistant
All checks were successful
docker-image / docker (push) Successful in 9m52s
Since we now have so many animations, show them in a random order
2025-06-15 18:03:33 +02:00
0adabf7758 Further refactor
All checks were successful
docker-image / docker (push) Successful in 10m11s
By specifying a layout, you can format the page directly, instead of having to overrride write_row.
2025-06-15 17:41:34 +02:00
84925687ca Refactor paged display
All checks were successful
docker-image / docker (push) Successful in 9m44s
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.
2025-06-14 13:36:35 +02:00
c7f1752fad Add SpaceAPI info
All checks were successful
docker-image / docker (push) Successful in 9m58s
2025-06-13 20:15:01 +02:00
7e8ad576c4 Also update in a loop
All checks were successful
docker-image / docker (push) Successful in 9m58s
2025-06-13 19:38:24 +02:00
99574b020f Down to debug level
All checks were successful
docker-image / docker (push) Successful in 9m53s
2025-06-13 19:08:28 +02:00
4310ea5b60 Actually update trains continuously
Some checks failed
docker-image / docker (push) Has been cancelled
2025-06-13 19:05:35 +02:00
2e4e0d072d Add debugging output to figure out why the times are wrong
All checks were successful
docker-image / docker (push) Successful in 10m0s
2025-06-13 18:45:40 +02:00
441b1a2890 Clear screen
All checks were successful
docker-image / docker (push) Successful in 10m1s
2025-06-12 22:19:14 +02:00
c39bd1ce53 Weekdays and "now" in German
Some checks failed
docker-image / docker (push) Has been cancelled
2025-06-12 22:15:13 +02:00
123803d619 Properly use date and timezones
All checks were successful
docker-image / docker (push) Successful in 10m12s
2025-06-12 21:15:10 +02:00
cc38689bfc Try to work with timezones
All checks were successful
docker-image / docker (push) Successful in 9m54s
2025-06-10 22:23:44 +02:00
stb
87dcc7b360 Merge pull request 'make the time animation look nice :3' (#7) from strdst/buba:fix_time_page into main
Some checks failed
docker-image / docker (push) Has been cancelled
Reviewed-on: #7
Reviewed-by: stb <stb@noreply.git.hamburg.ccc.de>
2025-06-10 22:22:48 +02:00
0de14df168 merge main 2025-06-09 19:55:53 +02:00
018ddfda06 merge main 2025-06-03 22:56:30 +02:00
8e9fa0b16e fix col_end 2025-06-03 22:35:29 +02:00
bdc7c2b1bc make the time animation look nice :3 2025-06-03 22:14:28 +02:00
19 changed files with 668 additions and 188 deletions

View file

@ -48,21 +48,29 @@ Buba is configured completely through environment variables:
## Creating Animation Plugins ## Creating Animation Plugins
Buba instantiates objects of type `BubaAnimation` and runs through them in a loop. Each animation must implement the 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 should sleep an appropriate time to let users take in the information. See the existing animations
in [buba/animations](./buba/animations) for inspiration. 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 To implement your own animation, subclass [BubaAnimation](buba/bubaanimation.py).
your animation.
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 ## Character Set
The display uses [Code Page 437](https://en.wikipedia.org/wiki/Code_page_437), with a few exceptions. Due to the limited 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. 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 | | Code | CP437 | Geavision Spaltenschrift |
|------|--------------|--------------------------| |------|--------------|--------------------------|

View file

@ -1,14 +1,12 @@
import logging 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_log import LoggingPlugin
from bottle_websocket import websocket, GeventWebSocketServer from bottle_websocket import websocket, GeventWebSocketServer
from geventwebsocket.websocket import WebSocket from geventwebsocket.websocket import WebSocket
from buba.animations.dbf import DBFAnimation from buba.animationconfig import setup_animations
from buba.animations.icalevents import IcalEvents from buba.animations.webqueue import WebQueue
from buba.animations.snake import SnakeAnimation
from buba.animations.time import BubaTime
from buba.appconfig import AppConfig from buba.appconfig import AppConfig
from buba.bubaanimator import BubaAnimator from buba.bubaanimator import BubaAnimator
from buba.bubacmd import BubaCmd from buba.bubacmd import BubaCmd
@ -28,13 +26,9 @@ TEMPLATE_PATH.insert(0, config.templatepath)
websocket_clients = WebSocketClients() websocket_clients = WebSocketClients()
buba = BubaCmd(config.serial, websocket_clients.send) buba = BubaCmd(config.serial, websocket_clients.send)
animator = BubaAnimator(buba) animator = BubaAnimator(buba)
# animator.add(BubaCharset) #, single=12) webqueue = WebQueue(buba)
animator.add(BubaTime) setup_animations(config, animator, webqueue)
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)
@app.route("/static/<filepath>") @app.route("/static/<filepath>")
def server_static(filepath): def server_static(filepath):
@ -44,7 +38,9 @@ def server_static(filepath):
@app.get("/") @app.get("/")
@jinja2_view("home.html.j2") @jinja2_view("home.html.j2")
def root(): def root():
return {} return {
"user_forms": range(webqueue.maxrows),
}
@app.get('/ws', apply=[websocket]) @app.get('/ws', apply=[websocket])
@ -61,6 +57,49 @@ def websocket_endpoint(ws: WebSocket):
websocket_clients.remove(ws) 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__': if __name__ == '__main__':
app.run(host=config.listen_host, port=config.listen_port, server=GeventWebSocketServer, debug=config.debug, app.run(host=config.listen_host, port=config.listen_port, server=GeventWebSocketServer, debug=config.debug,
quiet=not config.debug) quiet=not config.debug)

58
buba/animationconfig.py Normal file
View file

@ -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)

View file

@ -1,6 +1,6 @@
from time import sleep from time import sleep
from buba.bubaanimator import BubaAnimation from buba.bubaanimation import BubaAnimation
from buba.bubacmd import BubaCmd from buba.bubacmd import BubaCmd
@ -10,7 +10,7 @@ class BubaCharset(BubaAnimation):
self.charset = bytes(range(256)).decode("CP437") self.charset = bytes(range(256)).decode("CP437")
self.single = single self.single = single
def run(self): def show(self):
if self.single is not None: if self.single is not None:
while True: while True:
self.render(self.single) self.render(self.single)

View file

@ -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 See also https://github.com/derf/db-fakedisplay/blob/main/README.md
""" """
import datetime import datetime
import logging
from threading import Thread from threading import Thread
from time import sleep from time import sleep
from deutschebahn.db_infoscreen import DBInfoscreen 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 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): def __init__(self, buba: BubaCmd, ds100="AHST", station="Holstenstraße", count=3):
super().__init__(buba) super().__init__(buba)
self.dbi = DBInfoscreen("trains.xatlabs.com") self.dbi = DBInfoscreen("trains.xatlabs.com")
self.ds100 = ds100 self.ds100 = ds100
self.station = station self.title = station
self.layout = self.dbf_layout
self.trains = [] self.trains = []
self.count = count self.count = count
Thread(target=self.update, daemon=True).start()
def __repr__(self): def __repr__(self):
return f"<{type(self).__name__}, {self.ds100}>" 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 = 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 = [t for t in trains] # if t['platform'] == "1"] # platform gleis
trains.sort(key=self.dbi.time_sort) trains.sort(key=self.dbi.time_sort)
self.trains = trains self.trains = trains
self.log.info(f"Fetched {len(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 @staticmethod
def countdown(dt: datetime): def countdown(dt: datetime, cancelled:bool):
now = datetime.datetime.now() if cancelled:
return "--"
now = datetime.datetime.now().astimezone()
try: try:
dep_time = datetime.datetime.strptime(dt, "%H:%M").time() day = now.strftime("%y-%m-%d")
except TypeError as e: departure = datetime.datetime.strptime(f"{day} {dt}", "%y-%m-%d %H:%M").astimezone()
except ValueError as e:
return "--" return "--"
# First, assume all departure times are on the current day dep_td = departure - now
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)
if dep_td.total_seconds() <= -3600: if dep_td.total_seconds() <= -3600:
dep_date += datetime.timedelta(days=1) # dep_date += datetime.timedelta(days=1)
departure += 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)
if dep_td.total_seconds() >= 3600 * 23: 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(departure)
return BubaAnimation.countdown(datetime.datetime.combine(dep_date, dep_time))
@staticmethod @staticmethod
def short_station(station: str) -> str: def short_station(station: str) -> str:
@ -95,6 +88,8 @@ class DBFAnimation(BubaAnimation):
train = "EN" train = "EN"
if train.startswith("ME"): if train.startswith("ME"):
train = "ME" train = "ME"
if train.startswith("NB"):
train = "NB"
if train.startswith("NJ"): if train.startswith("NJ"):
train = "NJ" train = "NJ"
if train.startswith("RB"): if train.startswith("RB"):
@ -104,31 +99,10 @@ class DBFAnimation(BubaAnimation):
train = train.replace(" ", "") train = train.replace(" ", "")
return train return train
def run(self): def show(self):
all = self.trains[:self.count] self.rows = [[
all_len = int((len(all) + 1) / 3) self.short_train(train['train']),
self.short_station(train['destination']),
if len(self.trains) == 0: self.countdown(train['actualDeparture'], train['isCancelled']),
sleep(5) ] for train in self.trains[:self.count]]
self.show_pages()
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)

View file

@ -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

View file

@ -1,48 +1,34 @@
import os import os
from datetime import timedelta, datetime from datetime import timedelta, datetime
from threading import Thread
from time import sleep
import icalevents.icalevents import icalevents.icalevents
from pytz import timezone from pytz import timezone
from buba.bubaanimator import BubaAnimation from buba.bubaanimation import BubaAnimation
from buba.bubacmd import BubaCmd
class IcalEvents(BubaAnimation): class IcalEvents(BubaAnimation):
def __init__(self, buba, url, title): def __init__(self, buba, url, title, range=timedelta(weeks=2)):
super().__init__(buba) super().__init__(buba)
self.url = url self.url = url
self.title = title self.title = title
self.events = [] self.range = range
Thread(target=self.update, daemon=True).start()
def __repr__(self): def __repr__(self):
return f"<{type(self).__name__}, {self.url}>" return f"<{type(self).__name__}, {self.url}>"
def update(self): 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")) 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: for event in events:
event.start = event.start.astimezone(tz) event.start = event.start.astimezone(tz)
self.events = events events = filter(lambda e: e.end > now, events)
sleep(600) self.rows = [[e.summary, self.countdown(e.start)] for e in events]
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)

View file

@ -1,7 +1,8 @@
import random import random
from datetime import datetime, timedelta
from time import sleep from time import sleep
from buba.bubaanimator import BubaAnimation from buba.bubaanimation import BubaAnimation
class SnakeAnimation(BubaAnimation): class SnakeAnimation(BubaAnimation):
@ -34,7 +35,7 @@ class SnakeAnimation(BubaAnimation):
random.seed() random.seed()
def run(self): def show(self):
self.grid = [list([0] * self.width) for i in range(self.height)] 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)] self.prev_grid = [list([0] * self.width) for i in range(self.height)]
x = random.randrange(self.width) x = random.randrange(self.width)
@ -46,6 +47,7 @@ class SnakeAnimation(BubaAnimation):
self.body = [(x, y)] self.body = [(x, y)]
self.render() self.render()
iterations = 0 iterations = 0
last_update = datetime.now()
while True: while True:
if self.is_blocked(x, y, d): if self.is_blocked(x, y, d):
end = True end = True
@ -71,8 +73,11 @@ class SnakeAnimation(BubaAnimation):
self.grid[ty][tx] = 0 self.grid[ty][tx] = 0
(tx, ty) = self.body[0] (tx, ty) = self.body[0]
self.grid[ty][tx] = 11 self.grid[ty][tx] = 11
self.render() if datetime.now() - last_update > timedelta(seconds=1):
sleep(1) last_update = datetime.now()
self.render()
else:
sleep(.5)
sleep(5) sleep(5)
@staticmethod @staticmethod

View file

@ -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")

View file

@ -1,22 +1,19 @@
from datetime import datetime from datetime import datetime
from time import sleep from time import sleep
from buba.bubaanimator import BubaAnimation from buba.bubaanimation import BubaAnimation
from buba.bubacmd import BubaCmd from buba.bubacmd import BubaCmd
class BubaTime(BubaAnimation): class BubaTime(BubaAnimation):
def __init__(self, buba: BubaCmd): def __init__(self, buba: BubaCmd, ip:str):
super().__init__(buba) super().__init__(buba)
self.ip = ip
def run(self): def show(self):
self.buba.text(page=0, row=0, col_start=0, col_end=92, text="Bus-Bahn-Anzeige") self.buba.text(page=0, row=0, col_start=0, col_end=119, text="Chaos Computer Club", align=BubaCmd.ALIGN_CENTER)
self.buba.simple_text(page=0, row=1, col=0, text="Chaos Computer Club") self.buba.text(page=0, row=1, col_start=0, col_end=119, text="Hansestadt Hamburg", align=BubaCmd.ALIGN_CENTER)
self.buba.simple_text(page=0, row=2, col=0, text="Hansestadt Hamburg") self.buba.text(page=0, row=2, col_start=0, col_end=119, text=f"{self.ip}", align=BubaCmd.ALIGN_CENTER)
self.buba.simple_text(page=0, row=3, col=0, text="Hello, world!") 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)
self.buba.set_page(0)
for i in range(3): sleep(10)
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)

View file

@ -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

View file

@ -16,8 +16,15 @@ class AppConfig:
self.url = getenv('BUBA_URL', 'http://localhost:3000') self.url = getenv('BUBA_URL', 'http://localhost:3000')
(self.listen_host, self.listen_port) = getenv('BUBA_LISTEN', '127.0.0.1:3000').split(':') (self.listen_host, self.listen_port) = getenv('BUBA_LISTEN', '127.0.0.1:3000').split(':')
self.serial = getenv('BUBA_SERIAL', '/dev/ttyUSB0') 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'): if self.debug is not None and self.debug.lower not in ('0', 'f', 'false'):
self.debug = True self.debug = True
else: else:
self.debug = False 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()

180
buba/bubaanimation.py Normal file
View file

@ -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())

View file

@ -1,67 +1,29 @@
import logging import logging
from datetime import datetime, timedelta import random
from itertools import islice from dataclasses import dataclass
from threading import Thread from threading import Thread
from time import sleep from time import sleep
from buba.bubacmd import BubaCmd from buba.bubacmd import BubaCmd
LOG = logging.getLogger(__name__)
class BubaAnimation: WEEKDAYS_DE = [
def __init__(self, buba: BubaCmd): "Mo",
self.log = logging.getLogger(type(self).__name__) "Di",
self.buba = buba "Mi",
pass "Do",
"Fr",
"Sa",
"So"
]
def __repr__(self):
return f"<{type(self).__name__}>"
def run(self): # class layout with width, alignment; list of layouts
raise Exception("Your class must implement a run() method") @dataclass
class LineLayoutColumn:
@staticmethod width: int
def chunk(it, size): align: int
"""
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 BubaAnimator: class BubaAnimator:
@ -72,14 +34,23 @@ class BubaAnimator:
Thread(target=self.run, daemon=True).start() Thread(target=self.run, daemon=True).start()
def run(self): 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: while True:
if len(self.animations) == 0: if len(self.animations) == 0:
self.log.debug("No animations, sleeping...") self.log.debug("No animations, sleeping...")
sleep(2) sleep(2)
else: else:
for a in self.animations: for a in random.sample(self.animations, len(self.animations)):
self.log.debug(f"Starting animation: {a}") 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): def add(self, animation):
self.animations.append(animation(self.buba, *args, **kwargs)) self.animations.append(animation)

View file

@ -64,6 +64,7 @@ class BubaCmd:
:param align: alignment options, see MIS1TextDisplay.ALIGN_* :param align: alignment options, see MIS1TextDisplay.ALIGN_*
:return: :return:
""" """
text = text.replace("\u00ad", "") # remove soft hyphen
text = text.encode("CP437", "replace").decode("CP437") # force text to be CP437 compliant text = text.encode("CP437", "replace").decode("CP437") # force text to be CP437 compliant
if self.display is not None: if self.display is not None:
self.display.text(page, row, col_start, col_end, text, align) self.display.text(page, row, col_start, col_end, text, align)

View file

@ -30,3 +30,26 @@ svg.geavision__row {
.gvson { .gvson {
fill: #4f0; 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;
}

View file

@ -11,7 +11,7 @@ if (container) {
if (m.cmd === undefined) { if (m.cmd === undefined) {
console.log("undefined command", m) console.log("undefined command", m)
} }
switch(m.cmd) { switch (m.cmd) {
case "set_pages": case "set_pages":
display.set_pages(m.pages); display.set_pages(m.pages);
break; break;
@ -32,5 +32,44 @@ if (container) {
ws.close(); ws.close();
}); });
} }
connect(); 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();
})
}

View file

@ -8,7 +8,6 @@
<script src="static/main.js" type="module"></script> <script src="static/main.js" type="module"></script>
</head> </head>
<body> <body>
<h1>{{ self.page_title() }}</h1>
{% block page_body %}{% endblock %} {% block page_body %}{% endblock %}
</body> </body>
</html> </html>

View file

@ -2,4 +2,43 @@
{% block page_title %}CCCHH Buba{% endblock %} {% block page_title %}CCCHH Buba{% endblock %}
{% block page_body %} {% block page_body %}
<div id="geavision-display">...</div> <div id="geavision-display">...</div>
<h3>Submit Your Own Info</h3>
<div class="info-form">
<form id="info-form__form">
<div class="info-form__row">
<div class="info-form__field">
<label for="info-form__title">Title</label>
<input id="info-form__title" type="text" name="title" size="32">
</div>
</div>
{% for r in user_forms %}
<div class="info-form__row info-form__row-data">
<div class="info-form__field">
<label for="info-form__row-{{ r }}-text">Description</label><br>
<input id="info-form__row-{{ r }}-text" class="info-row__field-text" type="text" name="row-{{ r }}-text" size="27"><br>
</div>
<div class="info-form__field">
<label for="info-form__row-{{ r }}-value">Value</label><br>
<input id="info-form__row-{{ r }}-value" class="info-row__field-value" type="text" name="row-{{ r }}-value" size="5">
</div>
</div>
{% endfor %}
<div class="info-form__row">
<div class="info-form__field">
<button id="info-send">Send Info</button>
</div>
</div>
</form>
<h4>Submitting info via REST</h4>
<p>You can also submit your info via a PUT to https://buba.ccchh.net/user-entry, with a JSON body like this:</p>
<pre>
{
"title": "My great info",
"rows": [
["line 1 info", "123"],
["line 2 info", "23:42"]
]
}
</pre>
</div>
{% endblock %} {% endblock %}