2025-01-18 22:22:22 +01:00

314 lines
12 KiB
Python

from PIL import Image, ImageDraw, ImageFont
import textwrap
from collections import deque
import time
import requests
from io import BytesIO
import warnings
from datetime import datetime
import yaml
from typing import Dict
import subprocess
import numpy as np
import psutil
from spotiplayer_pi.lib import LCD_2inch
from spotiplayer_pi.api import Api
from spotiplayer_pi.cmap import cmap, cmap2
def get_cpu_temp():
try:
return float(
subprocess.run(
["vcgencmd", "measure_temp"], capture_output=True, text=True, check=True
)
.stdout.split("=")[1]
.replace("'C", "")
)
except (subprocess.CalledProcessError, IndexError, ValueError) as e:
warnings.warn(f"Error getting CPU temp: {e}")
return None
def get_cpu_util():
try:
return psutil.cpu_percent(interval=0)
except Exception as e:
warnings.warn(f"Error getting CPU util: {e}")
return None
def get_mem_util():
try:
return int(
subprocess.run(
["bash", "-c", "free -m | awk '/^Mem:/ {print $3}'"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
)
except Exception as e:
warnings.warn(f"Error getting Memory utilization: {e}")
def parse_config(path="spotiplayer_pi/config.yaml"):
with open(path, "r") as file:
data = yaml.safe_load(file)
for key, value in data["color_theme"].items():
data["color_theme"][key] = tuple(value)
return data
def display_loop(api: Api, cfg: Dict):
try:
d = LCD_2inch()
d.Init()
d.clear()
bg = Image.new("RGB", (d.height, d.width), (0, 0, 0))
d.ShowImage(bg)
Font00 = ImageFont.truetype("Font/GothamBold.ttf", 10)
Font0 = ImageFont.truetype("Font/GothamBold.ttf", 14)
Font1 = ImageFont.truetype("Font/GothamMedium.ttf", 18)
Font1b = ImageFont.truetype("Font/GothamBold.ttf", 19)
Font2 = ImageFont.truetype("Font/GothamBold.ttf", 23)
Font3 = ImageFont.truetype("Font/GothamMedium.ttf", 52)
unavailable_img = Image.open("imgs/unavailable.jpg")
error_img = Image.new("RGB", (240, 320), color=(255, 0, 0))
draw = ImageDraw.Draw(error_img)
draw.text((150, 150), "Error :(", font=Font1, fill=cfg["color_theme"]["text"])
del draw
not_playing_img = Image.new("RGB", (320, 240), (0, 0, 0))
last_api_call = 0
last_auth_refresh = 0
current_mode = None # 0: not-playing, 1: playing
last_track = None
data = None
auth_interval = 0
buffer_temp = deque([None] * 320, maxlen=320)
buffer_mem = deque([None] * 320, maxlen=320)
buffer_cpu = deque([None] * 320, maxlen=320)
alpha = 0.3
while True:
if time.time() - last_auth_refresh >= auth_interval:
auth_interval = api.refreshAuth() - 120
last_auth_refresh = time.time()
print(f"Refreshed auth at {datetime.now().strftime('%d-%m %H:%M:%S')}")
if time.time() - last_api_call >= cfg["api_interval"]:
data = api.getPlaying()
last_api_call = time.time()
if data == None:
warnings.warn("No data found")
d.ShowImage(error_img)
elif data == "not-playing":
pixels = not_playing_img.load()
draw = ImageDraw.Draw(not_playing_img)
draw.rectangle([(0, 100), (320, 240)], fill=(0, 0, 0)) # refresh bg
draw.rectangle([(95, 25), (250, 98)], fill=(0, 0, 0)) # refresh bg
draw.rectangle([(240, 0), (320, 150)], fill=(0, 0, 0)) # refresh bg
# Draw CPU usage
buffer_cpu.append(get_cpu_util())
cpu_values = np.array([x for x in buffer_cpu if x is not None])
cpu_values_idxs = [i for i, x in enumerate(buffer_cpu) if x is not None]
if len(cpu_values) >= 2:
smoothed = np.empty_like(cpu_values, dtype=float)
smoothed[0] = cpu_values[0]
for t in range(1, len(cpu_values)):
smoothed[t] = (
alpha * cpu_values[t] + (1 - alpha) * cpu_values[t - 1]
)
for i in range(1, len(cpu_values_idxs)):
idx = cpu_values_idxs[i]
val, prev_val = cpu_values[i], cpu_values[i - 1]
draw.rectangle(
[(idx, 200 - val), (idx - 1, 200 - prev_val)],
fill=(255, 253, 195),
)
draw.text(
(290, 180 - val),
f"{int(val)}%",
fill=(255, 255, 255),
font=Font0,
)
# Draw Temp
buffer_temp.append(get_cpu_temp())
temp_values = [x for x in buffer_temp if x is not None]
min_temp, max_temp = min(temp_values), max(temp_values)
if len(temp_values) >= 2 and max_temp != min_temp:
temp_data_scaled = [
(idx, ((x - min_temp) / (max_temp - min_temp)) * 40)
for idx, x in enumerate(buffer_temp)
if x is not None
]
for idx, val in temp_data_scaled:
color_idx = max(
0, min(int(buffer_temp[idx]) - 40, len(cmap) - 1)
)
pixels[idx, 150 - val] = tuple(cmap[color_idx])
draw.rectangle(
[(0, 149), (3, 151)],
fill=tuple(
cmap[max(0, min(len(cmap) - 1, int(min_temp) - 40))]
),
) # min
draw.rectangle(
[(0, 109), (3, 111)],
fill=tuple(
cmap[max(0, min(len(cmap) - 1, int(max_temp) - 40))]
),
) # max
draw.text((5, 150), f"{int(min_temp)}°", font=Font0)
draw.text((5, 100), f"{int(max_temp)}°", font=Font0)
# Draw Mem usage
buffer_mem.append(get_mem_util())
mem_data = [x for x in buffer_mem if x is not None]
group_size = 16
n_bins = len(mem_data) // group_size
bins = np.arange(len(mem_data)) // group_size
if n_bins > 0:
binned_data = [
np.mean(np.array(mem_data)[bins == i]) for i in range(n_bins)
]
min_bin, max_bin = np.argmin(binned_data), np.argmax(binned_data)
for idx_, bin_i in enumerate(binned_data):
idx = len(binned_data) - idx_ - 1
draw.rectangle(
[
(321 - ((idx + 1) * 16), 240),
(319 - (idx * 16), 240 - bin_i // 65),
],
fill=tuple(cmap2[idx]),
)
if idx_ in [min_bin, max_bin]:
col = 0 if idx_ == max_bin else n_bins - 1
draw.text(
(320 - ((idx + 1) * 16), 230 - bin_i // 65),
f"{bin_i / 1024:.1f}Gi",
font=Font00,
fill=tuple(cmap2[col]),
)
# Draw Time
current_time = datetime.now()
offset = 15 if current_time.hour < 10 and current_time.hour != 0 else 0
draw.text(
(100 + offset, 30),
current_time.strftime("%-I:%M"),
font=Font3,
fill=(255, 255, 255),
)
draw.text(
(120, 78),
current_time.strftime("%a, %d"),
font=Font2,
fill=(255, 255, 255),
)
d.ShowImage(not_playing_img)
time.sleep(0.1)
if current_mode != 0:
current_mode = 0
print("Standby mode")
elif type(data) == dict:
current_mode = 1
current_track = data["track"] + data["artists"][0] + data["album"]
if current_track != last_track:
print(f"Updating track to : {data['track']} - {data['artists'][0]}")
last_track = current_track
img = None
try:
img = requests.get(
data["img_url"], timeout=cfg["refresh_interval"] / 2
)
img = Image.open(BytesIO(img.content))
except Exception as e:
warnings.warn(
f"Failed to fetch album cover at: {data['img_url']}\n{e}"
)
if img == None:
img = unavailable_img
img = img.resize((128, 128))
bg = Image.new("RGB", (320, 240), cfg["color_theme"]["background"])
bg.paste(img, (10, 30))
draw = ImageDraw.Draw(bg)
draw.text(
(145, 34),
"\n".join(textwrap.wrap(", ".join(data["artists"]), width=16)),
font=Font1,
fill=cfg["color_theme"]["text"],
)
draw.text(
(11, 164),
"\n".join(textwrap.wrap(data["track"], width=28)),
font=Font1b,
fill=cfg["color_theme"]["text"],
)
w, h = bg.size
w -= 90
h += 4
progress_time = min(
data["duration_ms"],
data["progress_ms"] + int((time.time() - last_api_call) * 1000),
)
progress = min(1, progress_time / data["duration_ms"])
bar_width = int((w - 10) * progress)
draw.rectangle(
[(9, h - 23), (w + 3, h - 7)],
outline=cfg["color_theme"]["text"],
)
draw.rectangle(
[(10, h - 22), (w + 2, h - 8)],
outline=cfg["color_theme"]["text"],
)
draw.rectangle(
[(12, h - 20), (w, h - 10)], fill=cfg["color_theme"]["background"]
)
draw.rectangle(
[(12, h - 20), (12 + bar_width, h - 10)],
fill=cfg["color_theme"]["bar_inside"],
)
f_current_time = "{}:{:02.0f}".format(
*divmod(progress_time // 1000, 60)
)
f_total_time = "{}:{:02d}".format(
*divmod(data["duration_ms"] // 1000, 60)
)
draw.rectangle(
[(237, 218), (320, 238)], fill=cfg["color_theme"]["background"]
)
draw.text(
(238, 223),
f"{f_current_time}/{f_total_time}",
font=Font0,
fill=cfg["color_theme"]["text"],
)
d.ShowImage(bg)
time.sleep(cfg["refresh_interval"])
except IOError as e:
raise e
except KeyboardInterrupt:
d.module_exit()
if __name__ == "__main__":
cfg = parse_config()
api = Api(cfg["refresh_token"], cfg["base_64"])
display_loop(api, cfg)