Compare commits
33 commits
Author | SHA1 | Date | |
---|---|---|---|
d17a7770d6 | |||
9a1b401b4b | |||
162e8edc6e | |||
e6098b44f8 | |||
b969b7b3c3 | |||
63571e6c53 | |||
8aeeac62ef | |||
e583bb5691 | |||
ea943e89b0 | |||
2321d7d552 | |||
3ad796ffb2 | |||
64a3c729be | |||
ca540aff99 | |||
d748c9ae0e | |||
c37ce6ffd1 | |||
f407348d63 | |||
024345a17d | |||
0adabf7758 | |||
84925687ca | |||
c7f1752fad | |||
7e8ad576c4 | |||
99574b020f | |||
4310ea5b60 | |||
2e4e0d072d | |||
441b1a2890 | |||
c39bd1ce53 | |||
123803d619 | |||
cc38689bfc | |||
87dcc7b360 | |||
0de14df168 | |||
018ddfda06 | |||
8e9fa0b16e | |||
bdc7c2b1bc |
19 changed files with 668 additions and 188 deletions
20
README.md
20
README.md
|
@ -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 |
|
||||||
|------|--------------|--------------------------|
|
|------|--------------|--------------------------|
|
||||||
|
|
|
@ -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
58
buba/animationconfig.py
Normal 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)
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
52
buba/animations/homeassistant.py
Normal file
52
buba/animations/homeassistant.py
Normal 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
|
|
@ -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)
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
54
buba/animations/spaceapi.py
Normal file
54
buba/animations/spaceapi.py
Normal 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")
|
|
@ -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)
|
|
||||||
|
|
48
buba/animations/webqueue.py
Normal file
48
buba/animations/webqueue.py
Normal 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
|
|
@ -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
180
buba/bubaanimation.py
Normal 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())
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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();
|
||||||
|
})
|
||||||
|
}
|
|
@ -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>
|
|
@ -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 %}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue