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.1) 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, 0), (320, 240)], 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() time_text = current_time.strftime("%-I:%M") date_text = current_time.strftime("%a, %d") time_bbox = draw.textbbox((0, 0), time_text, font=Font3) date_bbox = draw.textbbox((0, 0), date_text, font=Font2) time_width = time_bbox[2] - time_bbox[0] date_width = date_bbox[2] - date_bbox[0] time_x = (320 - time_width) // 2 date_x = (320 - date_width) // 2 draw.text((time_x, 30), time_text, font=Font3, fill=(255, 255, 255)) draw.text((date_x, 78), date_text, font=Font2, fill=(255, 255, 255)) d.ShowImage(not_playing_img) 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)