Compare commits
11 Commits
92097f8f37
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
c3b05c6bcb
|
|||
|
1bf8e41e0c
|
|||
|
1a530bd27d
|
|||
|
bad309b2f7
|
|||
|
83467531b0
|
|||
|
49ee2d15be
|
|||
|
805f461dd2
|
|||
|
c6c1f7dd39
|
|||
|
8f4b0b6a54
|
|||
|
9c18d5c060
|
|||
|
e6380b7c22
|
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
env
|
||||||
|
out
|
||||||
|
res
|
||||||
|
*.toml
|
||||||
|
*.md
|
||||||
|
.git
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
out
|
out
|
||||||
|
env
|
||||||
|
|||||||
10
Dockerfile
Normal file
10
Dockerfile
Normal 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
7
compose-dev.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
nowblinkie:
|
||||||
|
build: .
|
||||||
|
volumes:
|
||||||
|
- "./res:/res"
|
||||||
|
- "./out:/out"
|
||||||
|
- "./config-docker.toml:/config.toml"
|
||||||
8
compose.yaml
Normal file
8
compose.yaml
Normal 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
32
config-docker.toml
Normal 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
|
||||||
14
config.toml
14
config.toml
@@ -20,3 +20,17 @@ command = ["bash", "-c", "/usr/bin/uptime -p | cut -d, -f-2"]
|
|||||||
font = "./res/curie.bdf"
|
font = "./res/curie.bdf"
|
||||||
text_filters = [ "lowercase" ]
|
text_filters = [ "lowercase" ]
|
||||||
text_offset = [21, 4]
|
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" ]
|
||||||
|
|||||||
59
readme.md
59
readme.md
@@ -3,11 +3,70 @@
|
|||||||
generate dynamic blinkies to use on your website,
|
generate dynamic blinkies to use on your website,
|
||||||
using the output of arbitrary commands.
|
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
|
## usage
|
||||||
|
|
||||||
1. install requirements: `pip install -r requirements.txt`
|
1. install requirements: `pip install -r requirements.txt`
|
||||||
2. run: `python main/src.py [-c config-file] [-L]`.
|
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
|
## config
|
||||||
|
|
||||||
the provided [example config file](./config.toml) lists all the options that can be used.
|
the provided [example config file](./config.toml) lists all the options that can be used.
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
|
certifi==2026.4.22
|
||||||
|
charset-normalizer==3.4.7
|
||||||
|
idna==3.15
|
||||||
pillow==12.2.0
|
pillow==12.2.0
|
||||||
|
requests==2.34.2
|
||||||
|
urllib3==2.7.0
|
||||||
|
|||||||
BIN
res/autumn.gif
Normal file
BIN
res/autumn.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
42
res/iloveseason.py
Executable file
42
res/iloveseason.py
Executable 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
61
res/pdweather.py
Executable 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
BIN
res/spring.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
res/summer.gif
Normal file
BIN
res/summer.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
BIN
res/templates/forest.gif
Normal file
BIN
res/templates/forest.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
res/winter.gif
Normal file
BIN
res/winter.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
53
src/main.py
53
src/main.py
@@ -3,7 +3,7 @@
|
|||||||
# Akbar Rahman <hi@alv.cx>
|
# Akbar Rahman <hi@alv.cx>
|
||||||
#
|
#
|
||||||
|
|
||||||
import shlex
|
import json
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
@@ -21,32 +21,49 @@ def get_args():
|
|||||||
parser.add_argument("-L", "--no-loop", action="store_true")
|
parser.add_argument("-L", "--no-loop", action="store_true")
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def generate(config):
|
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)
|
process = subprocess.run(config['command'], stdout=subprocess.PIPE, shell=False)
|
||||||
if process.returncode != config.get('return_code', 0):
|
if process.returncode != config.get('return_code', 0):
|
||||||
# process did not run succesfully.
|
# process did not run succesfully.
|
||||||
# do not risk displaying that to user
|
# do not risk displaying that to user
|
||||||
return
|
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]
|
print(f"{dynamic_config=}")
|
||||||
for filter in config.get('text_filters', []):
|
for key, val in dynamic_config.items():
|
||||||
if filter == "lowercase":
|
config[key] = val
|
||||||
text = text.lower()
|
|
||||||
elif filter == "uppercase":
|
|
||||||
text = text.upper()
|
|
||||||
|
|
||||||
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):
|
def main(args):
|
||||||
|
|||||||
Reference in New Issue
Block a user