308 lines
10 KiB
Python
308 lines
10 KiB
Python
|
|
import os
|
||
|
|
import math
|
||
|
|
from PIL import Image, ImageDraw, ImageFont
|
||
|
|
|
||
|
|
_FONT_CACHE = {}
|
||
|
|
|
||
|
|
_FONT_SEARCH_PATHS = [
|
||
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||
|
|
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
||
|
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||
|
|
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
|
||
|
|
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
||
|
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
||
|
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def _get_font(size):
|
||
|
|
if size not in _FONT_CACHE:
|
||
|
|
for name in _FONT_SEARCH_PATHS:
|
||
|
|
if os.path.exists(name):
|
||
|
|
_FONT_CACHE[size] = ImageFont.truetype(name, size)
|
||
|
|
return _FONT_CACHE[size]
|
||
|
|
_FONT_CACHE[size] = ImageFont.load_default()
|
||
|
|
return _FONT_CACHE[size]
|
||
|
|
|
||
|
|
ICON_COLORS = {
|
||
|
|
"lightbulb": {"on": (255, 210, 50), "off": (80, 80, 80)},
|
||
|
|
"power": {"on": (50, 200, 50), "off": (80, 80, 80)},
|
||
|
|
"thermometer": {"on": (200, 60, 30), "off": (80, 80, 80)},
|
||
|
|
"thermostat": {"on": (50, 150, 220), "off": (80, 80, 80)},
|
||
|
|
"play": {"on": (50, 200, 50), "off": (80, 80, 80)},
|
||
|
|
"film": {"on": (160, 50, 200), "off": (80, 80, 80)},
|
||
|
|
"door": {"on": (50, 200, 50), "off": (200, 50, 50)},
|
||
|
|
"lock": {"on": (50, 200, 50), "off": (200, 50, 50)},
|
||
|
|
"speaker": {"on": (50, 150, 220), "off": (80, 80, 80)},
|
||
|
|
"fan": {"on": (50, 200, 220), "off": (80, 80, 80)},
|
||
|
|
"default": {"on": (50, 150, 220), "off": (80, 80, 80)},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _is_on(state_str):
|
||
|
|
s = state_str.lower() if state_str else "off"
|
||
|
|
return s in ("on", "playing", "open", "unlocked", "home", "heat", "cool", "auto")
|
||
|
|
|
||
|
|
|
||
|
|
def _get_colors(icon, state_str):
|
||
|
|
is_on = _is_on(state_str)
|
||
|
|
icon_key = icon if icon in ICON_COLORS else "default"
|
||
|
|
key = "on" if is_on else "off"
|
||
|
|
return ICON_COLORS[icon_key][key], is_on
|
||
|
|
|
||
|
|
|
||
|
|
def _draw_icon(draw, icon, cx, cy, r, color):
|
||
|
|
if icon == "lightbulb":
|
||
|
|
_draw_lightbulb(draw, cx, cy, r, color)
|
||
|
|
elif icon == "power":
|
||
|
|
_draw_power(draw, cx, cy, r, color)
|
||
|
|
elif icon == "thermometer":
|
||
|
|
_draw_thermometer(draw, cx, cy, r, color)
|
||
|
|
elif icon == "play":
|
||
|
|
_draw_play(draw, cx, cy, r, color)
|
||
|
|
elif icon == "film":
|
||
|
|
_draw_film(draw, cx, cy, r, color)
|
||
|
|
elif icon == "door":
|
||
|
|
_draw_door(draw, cx, cy, r, color)
|
||
|
|
elif icon == "lock":
|
||
|
|
_draw_lock(draw, cx, cy, r, color)
|
||
|
|
elif icon == "fan":
|
||
|
|
_draw_fan(draw, cx, cy, r, color)
|
||
|
|
elif icon == "thermostat":
|
||
|
|
_draw_thermostat(draw, cx, cy, r, color)
|
||
|
|
elif icon == "speaker":
|
||
|
|
_draw_speaker(draw, cx, cy, r, color)
|
||
|
|
else:
|
||
|
|
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color)
|
||
|
|
|
||
|
|
|
||
|
|
def _draw_lightbulb(draw, cx, cy, r, color):
|
||
|
|
bulb_r = int(r * 0.6)
|
||
|
|
bulb_cy = cy - int(r * 0.15)
|
||
|
|
draw.ellipse(
|
||
|
|
[cx - bulb_r, bulb_cy - bulb_r, cx + bulb_r, bulb_cy + bulb_r], fill=color
|
||
|
|
)
|
||
|
|
base_w = int(r * 0.35)
|
||
|
|
base_h = int(r * 0.3)
|
||
|
|
base_top = bulb_cy + bulb_r - 2
|
||
|
|
draw.rectangle(
|
||
|
|
[cx - base_w, base_top, cx + base_w, base_top + base_h], fill=color
|
||
|
|
)
|
||
|
|
for i in range(3):
|
||
|
|
yy = base_top + int(base_h * 0.2) + i * int(base_h * 0.3)
|
||
|
|
draw.line([cx - base_w, yy, cx + base_w, yy], fill=(30, 30, 30), width=1)
|
||
|
|
|
||
|
|
|
||
|
|
def _draw_power(draw, cx, cy, r, color):
|
||
|
|
gap = int(r * 0.2)
|
||
|
|
arc_r = int(r * 0.7)
|
||
|
|
draw.line([cx, cy - r, cx, cy - gap], fill=color, width=max(2, int(r * 0.15)))
|
||
|
|
bbox = [cx - arc_r, cy - arc_r, cx + arc_r, cy + arc_r]
|
||
|
|
draw.arc(bbox, start=225, end=315, fill=color, width=max(2, int(r * 0.12)))
|
||
|
|
draw.arc(bbox, start=45, end=135, fill=color, width=max(2, int(r * 0.12)))
|
||
|
|
|
||
|
|
|
||
|
|
def _draw_thermometer(draw, cx, cy, r, color):
|
||
|
|
bulb_r = int(r * 0.35)
|
||
|
|
bulb_cy = cy + int(r * 0.35)
|
||
|
|
draw.ellipse(
|
||
|
|
[cx - bulb_r, bulb_cy - bulb_r, cx + bulb_r, bulb_cy + bulb_r], fill=color
|
||
|
|
)
|
||
|
|
tube_w = int(r * 0.18)
|
||
|
|
tube_top = cy - int(r * 0.85)
|
||
|
|
draw.rectangle(
|
||
|
|
[cx - tube_w, tube_top, cx + tube_w, bulb_cy - bulb_r + 2], fill=color
|
||
|
|
)
|
||
|
|
fill_h = int(r * 0.5)
|
||
|
|
fill_top = bulb_cy - fill_h
|
||
|
|
draw.rectangle(
|
||
|
|
[cx - tube_w + 1, fill_top, cx + tube_w - 1, bulb_cy], fill=(220, 60, 30)
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _draw_play(draw, cx, cy, r, color):
|
||
|
|
pts = [
|
||
|
|
(cx - int(r * 0.4), cy - int(r * 0.6)),
|
||
|
|
(cx - int(r * 0.4), cy + int(r * 0.6)),
|
||
|
|
(cx + int(r * 0.6), cy),
|
||
|
|
]
|
||
|
|
draw.polygon(pts, fill=color)
|
||
|
|
|
||
|
|
|
||
|
|
def _draw_film(draw, cx, cy, r, color):
|
||
|
|
w, h = int(r * 1.2), int(r * 0.8)
|
||
|
|
draw.rectangle([cx - w, cy - h, cx + w, cy + h], outline=color, width=2)
|
||
|
|
for yy in [cy - h, cy + h]:
|
||
|
|
for xx_off in [-w, w]:
|
||
|
|
draw.rectangle(
|
||
|
|
[cx + xx_off - 3, yy - 3, cx + xx_off + 3, yy + 3], fill=color
|
||
|
|
)
|
||
|
|
draw.rectangle(
|
||
|
|
[cx - int(w * 0.6), cy - int(h * 0.5), cx + int(w * 0.6), cy + int(h * 0.5)],
|
||
|
|
fill=color,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _draw_door(draw, cx, cy, r, color):
|
||
|
|
w, h = int(r * 0.6), int(r * 0.9)
|
||
|
|
draw.rectangle(
|
||
|
|
[cx - w, cy - h, cx + w, cy + h], outline=color, width=max(2, int(r * 0.08))
|
||
|
|
)
|
||
|
|
knob_r = max(2, int(r * 0.08))
|
||
|
|
draw.ellipse([cx + w - knob_r * 4, cy - knob_r, cx + w - knob_r * 2, cy + knob_r], fill=color)
|
||
|
|
|
||
|
|
|
||
|
|
def _draw_lock(draw, cx, cy, r, color):
|
||
|
|
body_w, body_h = int(r * 0.7), int(r * 0.5)
|
||
|
|
body_top = cy - int(r * 0.05)
|
||
|
|
draw.rectangle(
|
||
|
|
[cx - body_w, body_top, cx + body_w, body_top + body_h], fill=color
|
||
|
|
)
|
||
|
|
arc_r = int(r * 0.45)
|
||
|
|
arc_cy = body_top - 2
|
||
|
|
draw.arc(
|
||
|
|
[cx - arc_r, arc_cy - arc_r, cx + arc_r, arc_cy + arc_r],
|
||
|
|
start=180,
|
||
|
|
end=360,
|
||
|
|
fill=color,
|
||
|
|
width=max(2, int(r * 0.1)),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _draw_fan(draw, cx, cy, r, color):
|
||
|
|
blade_r = int(r * 0.75)
|
||
|
|
for angle_offset in [0, 90, 180, 270]:
|
||
|
|
a = math.radians(angle_offset)
|
||
|
|
x2 = cx + int(blade_r * math.cos(a))
|
||
|
|
y2 = cy + int(blade_r * math.sin(a))
|
||
|
|
perp_x = int(blade_r * 0.3 * math.cos(a + math.pi / 2))
|
||
|
|
perp_y = int(blade_r * 0.3 * math.sin(a + math.pi / 2))
|
||
|
|
pts = [(cx, cy), (x2 + perp_x, y2 + perp_y), (x2 - perp_x, y2 - perp_y)]
|
||
|
|
draw.polygon(pts, fill=color)
|
||
|
|
draw.ellipse([cx - int(r * 0.15), cy - int(r * 0.15), cx + int(r * 0.15), cy + int(r * 0.15)], fill=(40, 40, 40))
|
||
|
|
|
||
|
|
|
||
|
|
def _draw_thermostat(draw, cx, cy, r, color):
|
||
|
|
draw.ellipse(
|
||
|
|
[cx - int(r * 0.7), cy - int(r * 0.7), cx + int(r * 0.7), cy + int(r * 0.7)],
|
||
|
|
outline=color,
|
||
|
|
width=max(2, int(r * 0.08)),
|
||
|
|
)
|
||
|
|
hand_len = int(r * 0.5)
|
||
|
|
angle = math.radians(-45)
|
||
|
|
hx = cx + int(hand_len * math.cos(angle))
|
||
|
|
hy = cy + int(hand_len * math.sin(angle))
|
||
|
|
draw.line([cx, cy, hx, hy], fill=color, width=max(2, int(r * 0.1)))
|
||
|
|
draw.ellipse([cx - 3, cy - 3, cx + 3, cy + 3], fill=color)
|
||
|
|
|
||
|
|
|
||
|
|
def _draw_speaker(draw, cx, cy, r, color):
|
||
|
|
draw.polygon(
|
||
|
|
[
|
||
|
|
(cx - int(r * 0.5), cy - int(r * 0.25)),
|
||
|
|
(cx - int(r * 0.15), cy - int(r * 0.25)),
|
||
|
|
(cx - int(r * 0.15), cy - int(r * 0.6)),
|
||
|
|
(cx + int(r * 0.2), cy - int(r * 0.6)),
|
||
|
|
],
|
||
|
|
fill=color,
|
||
|
|
)
|
||
|
|
draw.polygon(
|
||
|
|
[
|
||
|
|
(cx - int(r * 0.5), cy + int(r * 0.25)),
|
||
|
|
(cx - int(r * 0.15), cy + int(r * 0.25)),
|
||
|
|
(cx - int(r * 0.15), cy + int(r * 0.6)),
|
||
|
|
(cx + int(r * 0.2), cy + int(r * 0.6)),
|
||
|
|
],
|
||
|
|
fill=color,
|
||
|
|
)
|
||
|
|
draw.rectangle(
|
||
|
|
[cx - int(r * 0.5), cy - int(r * 0.25), cx - int(r * 0.15), cy + int(r * 0.25)],
|
||
|
|
fill=color,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def render_key_image(key_config, state_str, size=64):
|
||
|
|
icon = key_config.get("icon", "default")
|
||
|
|
label = key_config.get("label", "")
|
||
|
|
|
||
|
|
bg_color, is_on = _get_colors(icon, state_str)
|
||
|
|
|
||
|
|
img = Image.new("RGB", (size, size), (20, 20, 20))
|
||
|
|
draw = ImageDraw.Draw(img)
|
||
|
|
|
||
|
|
if is_on:
|
||
|
|
glow = Image.new("RGB", (size, size), (0, 0, 0))
|
||
|
|
gd = ImageDraw.Draw(glow)
|
||
|
|
glow_r = int(size * 0.45)
|
||
|
|
gx, gy = size // 2, size // 2 - int(size * 0.05)
|
||
|
|
for i in range(glow_r, 0, -1):
|
||
|
|
alpha = int(40 * (i / glow_r))
|
||
|
|
c = tuple(min(255, v * alpha // 100) for v in bg_color)
|
||
|
|
gd.ellipse([gx - i, gy - i, gx + i, gy + i], fill=c)
|
||
|
|
img = Image.blend(img, glow, 0.6)
|
||
|
|
draw = ImageDraw.Draw(img)
|
||
|
|
|
||
|
|
icon_r = int(size * 0.28)
|
||
|
|
icon_cy = size // 2 - int(size * 0.1)
|
||
|
|
_draw_icon(draw, icon, size // 2, icon_cy, icon_r, bg_color)
|
||
|
|
|
||
|
|
if label:
|
||
|
|
font_size = max(8, int(size * 0.19))
|
||
|
|
font = _get_font(font_size)
|
||
|
|
text = label[:8]
|
||
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||
|
|
tw = bbox[2] - bbox[0]
|
||
|
|
tx = (size - tw) // 2
|
||
|
|
ty = size - int(size * 0.28)
|
||
|
|
draw.text((tx, ty), text, fill=(220, 220, 220), font=font)
|
||
|
|
|
||
|
|
state_display = _format_state(state_str, key_config)
|
||
|
|
if state_display:
|
||
|
|
font_size = max(7, int(size * 0.16))
|
||
|
|
font = _get_font(font_size)
|
||
|
|
bbox = draw.textbbox((0, 0), state_display, font=font)
|
||
|
|
tw = bbox[2] - bbox[0]
|
||
|
|
tx = (size - tw) // 2
|
||
|
|
ty = int(size * 0.06)
|
||
|
|
draw.text((tx, ty), state_display, fill=(180, 180, 180), font=font)
|
||
|
|
|
||
|
|
return img
|
||
|
|
|
||
|
|
|
||
|
|
def _format_state(state_str, key_config):
|
||
|
|
if not state_str:
|
||
|
|
return ""
|
||
|
|
s = state_str.lower()
|
||
|
|
if s in ("on", "off", "unavailable", "unknown"):
|
||
|
|
return s.capitalize()
|
||
|
|
if s in ("playing", "paused", "idle", "standby"):
|
||
|
|
return s.capitalize()
|
||
|
|
if s in ("open", "closed"):
|
||
|
|
return s.capitalize()
|
||
|
|
if s in ("home", "away", "not_home"):
|
||
|
|
return s.capitalize()
|
||
|
|
if any(unit in state_str for unit in ["°", "%", "V", "A", "W", "kW", "mph", "km/h", "Hz"]):
|
||
|
|
return state_str
|
||
|
|
return state_str[:7]
|
||
|
|
|
||
|
|
|
||
|
|
def render_background(pages, active_page, page_num=1, total_pages=1, size=(320, 240)):
|
||
|
|
img = Image.new("RGB", size, (25, 25, 30))
|
||
|
|
draw = ImageDraw.Draw(img)
|
||
|
|
font = _get_font(max(10, int(size[1] * 0.07)))
|
||
|
|
small_font = _get_font(max(8, int(size[1] * 0.05)))
|
||
|
|
draw.text((10, 10), active_page, fill=(255, 255, 255), font=font)
|
||
|
|
if total_pages > 1:
|
||
|
|
dot_r = 3
|
||
|
|
dot_spacing = 14
|
||
|
|
total_width = total_pages * dot_spacing
|
||
|
|
start_x = (size[0] - total_width) // 2
|
||
|
|
page_label_y = size[1] - 20
|
||
|
|
for i in range(total_pages):
|
||
|
|
cx = start_x + i * dot_spacing + dot_spacing // 2
|
||
|
|
if i == page_num - 1:
|
||
|
|
draw.ellipse([cx - dot_r, page_label_y - dot_r, cx + dot_r, page_label_y + dot_r], fill=(255, 255, 255))
|
||
|
|
else:
|
||
|
|
draw.ellipse([cx - dot_r, page_label_y - dot_r, cx + dot_r, page_label_y + dot_r], fill=(80, 80, 80))
|
||
|
|
return img
|