Compare commits

...

11 Commits

17 changed files with 280 additions and 18 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
env
out
res
*.toml
*.md
.git

1
.gitignore vendored
View File

@@ -1 +1,2 @@
out
env

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.14
WORKDIR /usr/src/app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD [ "python", "-u", "./src/main.py", "-c", "/config.toml" ]

7
compose-dev.yaml Normal file
View File

@@ -0,0 +1,7 @@
services:
nowblinkie:
build: .
volumes:
- "./res:/res"
- "./out:/out"
- "./config-docker.toml:/config.toml"

8
compose.yaml Normal file
View File

@@ -0,0 +1,8 @@
services:
nowblinkie:
build: "https://git.alv.cx/alvierahman90/nowblinkie.git"
volumes:
- "./res:/res"
- "./out:/out"
- "./config-docker.toml:/config.toml"
restart: unless-stopped

32
config-docker.toml Normal file
View File

@@ -0,0 +1,32 @@
[loop]
enable = true # run process in a loop, default: false
sleep = 60 # (seconds) time to sleep between generating images, default: 60
[[image]]
output = "/out/itistoday.gif" # required
template = "/res/templates/pastel rainbow border.gif"
# size=[120, 20] # specify size if template is not specified
command = ["/usr/bin/date", "+It is %a %d %b"] # required, command to generate text
font = "/res/curie.bdf" # required, only BDF fonts are supported atm
text_filters = [ "lowercase" ] # text_filters available: lowercase, uppercase
text_offset = [24, 4] # offset the text to move it around the image, default: [0, 0]
[[image]]
output = "/out/uptime.gif"
template = "/res/templates/pastel rainbow border.gif"
command = ["bash", "-c", "/usr/bin/uptime -p | cut -d, -f-2"]
font = "/res/curie.bdf"
text_filters = [ "lowercase" ]
text_offset = [21, 4]
[[image]]
output = "/out/pdweather.gif"
template = "/res/templates/forest.gif"
command = [ "python", "/res/pdweather.py" ]
font = "/res/curie.bdf"
text_filters = [ "lowercase" ]
text_offset = [45, 4]
text_fill = [255, 255, 255] # your template image must have alpha channel to use alpha

View File

@@ -20,3 +20,17 @@ command = ["bash", "-c", "/usr/bin/uptime -p | cut -d, -f-2"]
font = "./res/curie.bdf"
text_filters = [ "lowercase" ]
text_offset = [21, 4]
[[image]]
output = "out/pdweather.gif"
template = "./res/templates/forest.gif"
command = [ "python", "./res/pdweather.py" ]
font = "./res/curie.bdf"
text_filters = [ "lowercase" ]
text_offset = [45, 4]
text_fill = [255, 255, 255] # your template image must have alpha channel to use alpha
[[image]]
output = "out/iloveseason.gif"
command = [ "python", "./res/iloveseason.py" ]

View File

@@ -3,11 +3,70 @@
generate dynamic blinkies to use on your website,
using the output of arbitrary commands.
nowblinkie does not host a web server,
you must use a separate webserver to host your blinkies,
such as nginx or caddy.
## usage
1. install requirements: `pip install -r requirements.txt`
2. run: `python main/src.py [-c config-file] [-L]`.
### docker
a [dockerfile](./Dockerfile) and [compose file](./compose.yaml) have been provided.
keep in mind the commands will run in the docker container, too.
you do not need to clone this repository to use the compose file provided.
the docker image installs the python
[`requests` library](https://docs.python-requests.org/en/latest/index.html),
so you can use it for calling external APIs inside the container.
the `pdweather` blinkie example does this.
### custom images
to use utilities not installed in the standard docker image,
you can build a new one based off of it:
```dockerfile
FROM https://git.alv.cx/alvierahman90/nowblinkie.git
RUN apt-get update && apt-get install blahblahblah
CMD [ "python", "-u", "./src/main.py", "-c", "/config.toml" ]
```
## writing commands
### basic
basic commands return just text.
the text can end in 0 or 1 newlines but not multiple,
else it will be interpreted as a dynamic config command.
### dynamic config commands
any config can be dynamically set by preceeding the text to be printed
with TOML config:
```
template = "pat/to/templat.gif"
font = "fonts/custom_font.bdf"
text_offset = [20, 4]
hello world
```
if you do not want to print any text,
simply leave an extra blank/empty line
(the text ends in two newlines)
or a space character:
```
template = "pat/to/templat.gif"
```
## config
the provided [example config file](./config.toml) lists all the options that can be used.

View File

@@ -1 +1,6 @@
certifi==2026.4.22
charset-normalizer==3.4.7
idna==3.15
pillow==12.2.0
requests==2.34.2
urllib3==2.7.0

BIN
res/autumn.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

42
res/iloveseason.py Executable file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python3
#
# Akbar Rahman <hi@alv.cx>
#
import sys
from datetime import datetime as dt
def get_args():
""" Get command line arguments """
import argparse
parser = argparse.ArgumentParser()
return parser.parse_args()
def main(args):
""" Entry point for script """
now = dt.now()
template = ""
if now.month in [3, 4, 5]:
template = './res/spring.gif'
if now.month in [6, 7, 8]:
template = './res/summer.gif'
if now.month in [9, 10, 11]:
template = './res/autumn.gif'
if now.month in [12, 1, 2]:
template = './res/winter.gif'
print(f'template = "{template}"')
print()
return 0
if __name__ == '__main__':
try:
sys.exit(main(get_args()))
except KeyboardInterrupt:
sys.exit(0)

61
res/pdweather.py Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
#
# Akbar Rahman <hi@alv.cx>
#
import sys
from datetime import datetime as dt
import requests
def get_args():
""" Get command line arguments """
import argparse
parser = argparse.ArgumentParser()
return parser.parse_args()
def main(args):
""" Entry point for script """
resp = requests.get("https://api.open-meteo.com/v1/forecast?latitude=53.35&longitude=-1.8333&hourly=temperature_2m,rain,cloud_cover&timezone=GMT&forecast_days=1").json()
now = dt.now()
closest_past_date = None
for idx, datetime_string in enumerate(resp['hourly']['time']):
date = dt.fromisoformat(datetime_string)
if closest_past_date is None:
closest_past_date = (idx, date)
continue
if date > now: # date > now means date is in future, ignore
continue
if (now - date) < (now - closest_past_date[1]):
closest_past_date = (idx, date)
continue
cur_weather = {}
for key in resp['hourly'].keys():
cur_weather[key] = resp['hourly'][key][closest_past_date[0]]
output = "sunny in peaks"
if cur_weather['cloud_cover'] > 40:
output = "cloudy in peaks"
if cur_weather['rain'] > 0.5:
output = "raining in peaks"
print(output, end='')
return 0
if __name__ == '__main__':
try:
sys.exit(main(get_args()))
except KeyboardInterrupt:
sys.exit(0)

BIN
res/spring.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
res/summer.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
res/templates/forest.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
res/winter.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -3,7 +3,7 @@
# Akbar Rahman <hi@alv.cx>
#
import shlex
import json
import sys
import subprocess
import time
@@ -21,32 +21,49 @@ def get_args():
parser.add_argument("-L", "--no-loop", action="store_true")
return parser.parse_args()
def generate(config):
if config.get('template'):
img = Image.open(config['template'])
else:
img = Image.new(mode="RGBA", size = config['size'])
draw = ImageDraw.Draw(img)
with open(config['font'], "rb") as fp:
font = BdfFontFile.BdfFontFile(fp).to_imagefont()
process = subprocess.run(config['command'], stdout=subprocess.PIPE, shell=False)
if process.returncode != config.get('return_code', 0):
# process did not run succesfully.
# do not risk displaying that to user
return
cmd_output = process.stdout.decode('utf-8').split('\n')
print(f"{cmd_output=}")
dynamic_config = {}
if len(cmd_output) > 2:
dynamic_config = tomllib.loads('\n'.join(cmd_output[:-2]))
text = cmd_output[-2]
else:
text = cmd_output[0]
text = process.stdout.decode('utf-8').split('\n')[0]
for filter in config.get('text_filters', []):
if filter == "lowercase":
text = text.lower()
elif filter == "uppercase":
text = text.upper()
print(f"{dynamic_config=}")
for key, val in dynamic_config.items():
config[key] = val
draw.text(config.get('text_offset', [0, 0]), text, font=font)
if config.get('template'):
img = Image.open(config['template'])
else:
img = Image.new(mode="RGBA", size = config['size'])
img.save(config['output'], save_all=True)
print(f"{text=}")
if text:
draw = ImageDraw.Draw(img)
with open(config['font'], "rb") as fp:
font = BdfFontFile.BdfFontFile(fp).to_imagefont()
for filter in config.get('text_filters', []):
if filter == "lowercase":
text = text.lower()
elif filter == "uppercase":
text = text.upper()
fill = config.get('text_fill')
fill = tuple(fill) if fill else None
draw.text(config.get('text_offset', [0, 0]), text, font=font, fill=fill)
img.save(config['output'], save_all=True, loop=0)
def main(args):