Skip to content

Commit f15a480

Browse files
committed
Timer
1 parent 08e65da commit f15a480

10 files changed

Lines changed: 561 additions & 4 deletions

File tree

custom_components/ipixel_color/api.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .device.info import build_device_info_command, parse_device_response
2020
from .display.text_renderer import render_text_to_png
2121
from .exceptions import iPIXELConnectionError
22+
from .giftimer import build_timer_gif
2223

2324
_LOGGER = logging.getLogger(__name__)
2425

@@ -336,6 +337,107 @@ async def display_text_pypixelcolor(
336337
_LOGGER.error("Error displaying pypixelcolor text: %s", err)
337338
return False
338339

340+
async def display_timer_gif(
341+
self,
342+
duration_seconds: int,
343+
text_color: tuple[int, int, int] = (0, 255, 0),
344+
bg_color: tuple[int, int, int] = (0, 0, 0),
345+
font_path: str | None = None,
346+
) -> bool:
347+
"""Generate and display a countdown timer GIF on the device.
348+
349+
Args:
350+
duration_seconds: Total countdown duration in seconds
351+
text_color: RGB tuple for text color (default: green)
352+
bg_color: RGB tuple for background color (default: black)
353+
font_path: Path to TTF font file, or None for default
354+
355+
Returns:
356+
True if timer GIF was sent successfully
357+
"""
358+
try:
359+
import tempfile
360+
import os
361+
from pathlib import Path
362+
363+
# Get device dimensions
364+
device_info = await self.get_device_info()
365+
width = device_info["width"]
366+
height = device_info["height"]
367+
368+
# Determine font size based on display height
369+
# Use a font size that fits well in the display
370+
font_size = max(8, height - 4)
371+
372+
# Use default font if none specified
373+
if font_path is None:
374+
fonts_dir = Path(__file__).parent / "fonts"
375+
# Try to find a good monospace font for timer display
376+
for font_name in ["7x5.ttf", "5x5.ttf", "OpenSans-Light.ttf"]:
377+
potential_font = fonts_dir / font_name
378+
if potential_font.exists():
379+
font_path = str(potential_font)
380+
break
381+
382+
# Generate timer GIF to temporary file
383+
with tempfile.NamedTemporaryFile(suffix=".gif", delete=False) as tmp:
384+
tmp_path = tmp.name
385+
386+
try:
387+
frame_count = build_timer_gif(
388+
duration_seconds=duration_seconds,
389+
output_path=tmp_path,
390+
font_size=font_size,
391+
bg=bg_color,
392+
fg=text_color,
393+
font_path=font_path,
394+
width=width,
395+
height=height,
396+
)
397+
398+
_LOGGER.debug(
399+
"Generated timer GIF: %d seconds, %d frames, %dx%d",
400+
duration_seconds, frame_count, width, height
401+
)
402+
403+
# Read the generated GIF
404+
with open(tmp_path, "rb") as f:
405+
gif_data = f.read()
406+
407+
finally:
408+
# Clean up temp file
409+
if os.path.exists(tmp_path):
410+
os.unlink(tmp_path)
411+
412+
# Generate image commands using pypixelcolor
413+
commands = make_image_command(
414+
image_bytes=gif_data,
415+
file_extension=".gif",
416+
resize_method="crop",
417+
device_info_dict=device_info
418+
)
419+
420+
# Send all command frames
421+
for i, command in enumerate(commands):
422+
_LOGGER.debug(
423+
"Sending timer GIF frame %d/%d: %d bytes",
424+
i + 1, len(commands), len(command)
425+
)
426+
success = await self._bluetooth.send_command(command)
427+
if not success:
428+
_LOGGER.error("Failed to send timer GIF frame %d/%d", i + 1, len(commands))
429+
return False
430+
431+
_LOGGER.info(
432+
"Timer GIF sent: %d seconds countdown (%dx%d, %d bytes, %d frames)",
433+
duration_seconds, width, height, len(gif_data), len(commands)
434+
)
435+
return True
436+
437+
except Exception as err:
438+
_LOGGER.error("Error displaying timer GIF: %s", err)
439+
return False
440+
339441
def _notification_handler(self, sender: Any, data: bytearray) -> None:
340442
"""Handle notifications from the device."""
341443
_LOGGER.debug("Notification from %s: %s", sender, data.hex())

custom_components/ipixel_color/common.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from homeassistant.core import HomeAssistant
66
from homeassistant.helpers.template import Template
77
from homeassistant.helpers import entity_registry as er
8-
from .const import MODE_TEXT_IMAGE, MODE_TEXT, MODE_CLOCK, DOMAIN
8+
from .const import MODE_TEXT_IMAGE, MODE_TEXT, MODE_CLOCK, MODE_TIMER, DOMAIN
99

1010
_LOGGER = logging.getLogger(__name__)
1111

@@ -127,6 +127,10 @@ async def update_ipixel_display(hass: HomeAssistant, device_name: str, api, text
127127
return await _update_text_mode(hass, device_name, api, text)
128128
elif mode == MODE_CLOCK:
129129
return await _update_clock_mode(hass, device_name, api)
130+
elif mode == MODE_TIMER:
131+
# Timer mode is triggered by timer entity state changes, not manual updates
132+
_LOGGER.debug("Timer mode active - waiting for timer entity to start")
133+
return True
130134
else:
131135
_LOGGER.warning("Unknown mode: %s, falling back to textimage", mode)
132136
return await _update_textimage_mode(hass, device_name, api, text)
@@ -294,7 +298,7 @@ async def _update_text_mode(hass: HomeAssistant, device_name: str, api, text: st
294298
# We weight the channels since not each color appears as bright as the others.
295299
# In this way we choose the channel which should be less obvious.
296300
bg = bg_color or "000000"
297-
r, g, b = int(bg[0:2], 16)*333, int(bg[2:4], 16)*169, int(bg[4:6], 16)*909
301+
r, g, b = int(bg[0:2], 16)*333, int(bg[2:4], 16)*169, int(bg[4:6], 16)*909
298302
if g >= r and g >= b:
299303
color = "000100"
300304
elif b >= r:

custom_components/ipixel_color/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@
2727
MODE_TEXT_IMAGE = "textimage"
2828
MODE_TEXT = "text"
2929
MODE_CLOCK = "clock"
30+
MODE_TIMER = "timer"
3031

3132
AVAILABLE_MODES = [
3233
MODE_TEXT_IMAGE,
3334
MODE_TEXT,
3435
MODE_CLOCK,
36+
MODE_TIMER,
3537
]
3638

3739
DEFAULT_MODE = MODE_TEXT_IMAGE
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Timer GIF generation utilities for iPIXEL Color displays."""
2+
3+
from .formatter import format_time, parse_duration
4+
from .renderer import get_font, measure_text, create_frame, update_frame, create_palette
5+
from .gif_builder import build_timer_gif, DEFAULT_BG, DEFAULT_FG
6+
7+
__all__ = [
8+
"format_time",
9+
"parse_duration",
10+
"get_font",
11+
"measure_text",
12+
"create_frame",
13+
"update_frame",
14+
"create_palette",
15+
"build_timer_gif",
16+
"DEFAULT_BG",
17+
"DEFAULT_FG",
18+
]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Time formatting utilities."""
2+
3+
4+
def format_time(seconds):
5+
"""Format seconds into display string.
6+
7+
- Up to 9:59: shows M:SS (e.g., "5:32")
8+
- Above 9:59: shows Xm (e.g., "14m")
9+
"""
10+
if seconds < 0:
11+
seconds = 0
12+
13+
minutes = seconds // 60
14+
secs = seconds % 60
15+
16+
if minutes <= 9:
17+
return f"{minutes}:{secs:02d}"
18+
else:
19+
return f"{minutes}m"
20+
21+
22+
def parse_duration(duration_str):
23+
"""Parse duration string into seconds.
24+
25+
Accepts: 30s, 5m, 2:30, or plain number (seconds)
26+
"""
27+
duration_str = duration_str.lower().strip()
28+
29+
if duration_str.endswith("s"):
30+
return int(duration_str[:-1])
31+
elif duration_str.endswith("m"):
32+
return int(duration_str[:-1]) * 60
33+
elif ":" in duration_str:
34+
parts = duration_str.split(":")
35+
return int(parts[0]) * 60 + int(parts[1])
36+
else:
37+
return int(duration_str)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""GIF generation from timer frames."""
2+
3+
from .formatter import format_time
4+
from .renderer import get_font, measure_text, create_frame, update_frame, create_palette
5+
6+
# Default colors: green on black
7+
DEFAULT_BG = (0, 0, 0)
8+
DEFAULT_FG = (0, 255, 0)
9+
10+
11+
def build_timer_gif(duration_seconds, output_path, font_size=72, bg=DEFAULT_BG, fg=DEFAULT_FG,
12+
font_path=None, width=None, height=None):
13+
"""Build a countdown timer GIF."""
14+
font = get_font(font_size, font_path)
15+
palette = create_palette(bg, fg)
16+
padding = 20
17+
18+
# Calculate image size from longest possible text, or use provided size
19+
max_text = format_time(duration_seconds)
20+
if duration_seconds <= 599: # Could show 9:59
21+
max_text = "9:59"
22+
text_w, text_h, _ = measure_text(max_text, font)
23+
24+
if width is None:
25+
width = text_w + 2 * padding
26+
if height is None:
27+
height = text_h + 2 * padding
28+
29+
frames = []
30+
durations = []
31+
prev_frame = None
32+
prev_text = None
33+
text_x, text_y = 0, 0
34+
35+
for remaining in range(duration_seconds, -1, -1):
36+
current_text = format_time(remaining)
37+
38+
if remaining == 0:
39+
# Inverted colors for final frame
40+
inverted_palette = create_palette(fg, bg)
41+
frame, text_x, text_y = create_frame(current_text, width, height, font, inverted_palette)
42+
durations.append(65535) # Max GIF duration (65535ms)
43+
elif prev_frame is None or len(current_text) != len(prev_text):
44+
frame, text_x, text_y = create_frame(current_text, width, height, font, palette)
45+
durations.append(1000)
46+
else:
47+
frame = update_frame(prev_frame, prev_text, current_text, text_x, text_y, font)
48+
durations.append(1000)
49+
50+
frames.append(frame)
51+
prev_frame = frame
52+
prev_text = current_text
53+
54+
# Save GIF (loop=1 means play once, no repeat)
55+
frames[0].save(
56+
output_path,
57+
save_all=True,
58+
append_images=frames[1:],
59+
duration=durations,
60+
loop=1,
61+
optimize=True
62+
)
63+
64+
return len(frames)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Frame rendering utilities."""
2+
3+
from PIL import Image, ImageDraw, ImageFont
4+
5+
6+
DEFAULT_FONT_PATHS = [
7+
"/System/Library/Fonts/Menlo.ttc",
8+
"/System/Library/Fonts/Monaco.ttf",
9+
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
10+
]
11+
12+
13+
def get_font(size, font_path=None):
14+
"""Get a font. Uses font_path if provided, otherwise tries defaults."""
15+
if font_path:
16+
return ImageFont.truetype(font_path, size)
17+
18+
for path in DEFAULT_FONT_PATHS:
19+
try:
20+
return ImageFont.truetype(path, size)
21+
except OSError:
22+
continue
23+
return ImageFont.load_default()
24+
25+
26+
def measure_text(text, font):
27+
"""Measure text dimensions."""
28+
img = Image.new("RGB", (1, 1))
29+
draw = ImageDraw.Draw(img)
30+
bbox = draw.textbbox((0, 0), text, font=font)
31+
return bbox[2] - bbox[0], bbox[3] - bbox[1], bbox[1]
32+
33+
34+
def create_palette(bg_color, fg_color):
35+
"""Create a 2-color palette."""
36+
palette = list(bg_color) + list(fg_color) + [0] * (256 * 3 - 6)
37+
return palette
38+
39+
40+
def create_frame(text, width, height, font, palette):
41+
"""Create a new frame with centered text (2-color palette)."""
42+
frame = Image.new("P", (width, height), 0)
43+
frame.putpalette(palette)
44+
draw = ImageDraw.Draw(frame)
45+
46+
text_w, text_h, y_offset = measure_text(text, font)
47+
x = (width - text_w) // 2
48+
y = (height - text_h) // 2 - y_offset
49+
50+
draw.text((x, y), text, font=font, fill=1)
51+
return frame, x, y
52+
53+
54+
def update_frame(prev_frame, prev_text, new_text, x, y, font):
55+
"""Update only changed characters in a frame (2-color palette)."""
56+
frame = prev_frame.copy()
57+
draw = ImageDraw.Draw(frame)
58+
59+
x_offset = x
60+
for old_char, new_char in zip(prev_text, new_text):
61+
bbox = draw.textbbox((x_offset, y), old_char, font=font)
62+
char_width = bbox[2] - bbox[0]
63+
64+
if old_char != new_char:
65+
# Clear and redraw only this character
66+
draw.rectangle([bbox[0], bbox[1], bbox[2], bbox[3]], fill=0)
67+
draw.text((x_offset, y), new_char, font=font, fill=1)
68+
69+
x_offset += char_width
70+
71+
return frame
206 KB
Loading
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env python3
2+
"""Generate a countdown timer GIF."""
3+
4+
import argparse
5+
import sys
6+
7+
from formatter import parse_duration
8+
from gif_builder import build_timer_gif
9+
10+
11+
def main():
12+
parser = argparse.ArgumentParser(description="Generate a countdown timer GIF")
13+
parser.add_argument("duration", help="Duration: 30s, 5m, 2:30, or seconds")
14+
parser.add_argument("-o", "--output", default="timer.gif", help="Output file")
15+
parser.add_argument("-s", "--font-size", type=int, default=72, help="Font size")
16+
parser.add_argument("-f", "--font", help="Path to font file")
17+
parser.add_argument("-W", "--width", type=int, help="Image width")
18+
parser.add_argument("-H", "--height", type=int, help="Image height")
19+
20+
args = parser.parse_args()
21+
22+
try:
23+
seconds = parse_duration(args.duration)
24+
except ValueError:
25+
print(f"Error: Invalid duration '{args.duration}'", file=sys.stderr)
26+
sys.exit(1)
27+
28+
if seconds <= 0:
29+
print("Error: Duration must be positive", file=sys.stderr)
30+
sys.exit(1)
31+
32+
frame_count = build_timer_gif(
33+
seconds,
34+
args.output,
35+
font_size=args.font_size,
36+
font_path=args.font,
37+
width=args.width,
38+
height=args.height
39+
)
40+
41+
print(f"Created {args.output} ({frame_count} frames, {seconds}s)")
42+
43+
44+
if __name__ == "__main__":
45+
main()

0 commit comments

Comments
 (0)