Skip to content

Commit 6f6519c

Browse files
committed
timer updating and font size
1 parent f15a480 commit 6f6519c

4 files changed

Lines changed: 183 additions & 96 deletions

File tree

custom_components/ipixel_color/api.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def __init__(self, hass: HomeAssistant, address: str) -> None:
3939
self._power_state = False
4040
self._device_info: dict[str, Any] | None = None
4141
self._device_response: bytes | None = None
42+
self._active_mode: str | None = None # Last mode sent to display
4243

4344
async def connect(self) -> bool:
4445
"""Connect to the iPIXEL device."""
@@ -343,6 +344,8 @@ async def display_timer_gif(
343344
text_color: tuple[int, int, int] = (0, 255, 0),
344345
bg_color: tuple[int, int, int] = (0, 0, 0),
345346
font_path: str | None = None,
347+
static: bool = False,
348+
font_size: float | None = None,
346349
) -> bool:
347350
"""Generate and display a countdown timer GIF on the device.
348351
@@ -351,6 +354,8 @@ async def display_timer_gif(
351354
text_color: RGB tuple for text color (default: green)
352355
bg_color: RGB tuple for background color (default: black)
353356
font_path: Path to TTF font file, or None for default
357+
static: If True, display static image instead of animated GIF
358+
font_size: Font size in pixels, or None for auto-sizing
354359
355360
Returns:
356361
True if timer GIF was sent successfully
@@ -365,9 +370,9 @@ async def display_timer_gif(
365370
width = device_info["width"]
366371
height = device_info["height"]
367372

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)
373+
# Determine font size based on display height if not specified
374+
if font_size is None or font_size == 0:
375+
font_size = height - 4
371376

372377
# Use default font if none specified
373378
if font_path is None:
@@ -393,6 +398,7 @@ async def display_timer_gif(
393398
font_path=font_path,
394399
width=width,
395400
height=height,
401+
static=static,
396402
)
397403

398404
_LOGGER.debug(

custom_components/ipixel_color/common.py

Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,18 +122,23 @@ async def update_ipixel_display(hass: HomeAssistant, device_name: str, api, text
122122

123123
# Route to appropriate mode handler
124124
if mode == MODE_TEXT_IMAGE:
125-
return await _update_textimage_mode(hass, device_name, api, text)
125+
success = await _update_textimage_mode(hass, device_name, api, text)
126126
elif mode == MODE_TEXT:
127-
return await _update_text_mode(hass, device_name, api, text)
127+
success = await _update_text_mode(hass, device_name, api, text)
128128
elif mode == MODE_CLOCK:
129-
return await _update_clock_mode(hass, device_name, api)
129+
success = await _update_clock_mode(hass, device_name, api)
130130
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
131+
success = await _update_timer_mode(hass, device_name, api)
134132
else:
135133
_LOGGER.warning("Unknown mode: %s, falling back to textimage", mode)
136-
return await _update_textimage_mode(hass, device_name, api, text)
134+
success = await _update_textimage_mode(hass, device_name, api, text)
135+
mode = MODE_TEXT_IMAGE
136+
137+
# Store the active mode on successful update
138+
if success:
139+
api._active_mode = mode
140+
141+
return success
137142

138143
except Exception as err:
139144
_LOGGER.error("Error during display update: %s", err)
@@ -355,6 +360,127 @@ async def _update_text_mode(hass: HomeAssistant, device_name: str, api, text: st
355360
return False
356361

357362

363+
async def _update_timer_mode(hass: HomeAssistant, device_name: str, api) -> bool:
364+
"""Update display in timer mode.
365+
366+
Args:
367+
hass: Home Assistant instance
368+
device_name: Device name for entity ID lookups
369+
api: iPIXEL API instance
370+
371+
Returns:
372+
True if update was successful
373+
"""
374+
from datetime import datetime, timezone
375+
376+
try:
377+
# Get selected timer entity
378+
timer_entity_id = get_entity_id_by_unique_id(hass, api._address, "timer_entity_select", "select")
379+
timer_select_state = hass.states.get(timer_entity_id) if timer_entity_id else None
380+
381+
if not timer_select_state or timer_select_state.state == "none":
382+
_LOGGER.warning("No timer entity selected - skipping update")
383+
return False
384+
385+
selected_timer = timer_select_state.state
386+
timer_state = hass.states.get(selected_timer)
387+
388+
if not timer_state:
389+
_LOGGER.warning("Timer entity %s not found", selected_timer)
390+
return False
391+
392+
# Determine seconds and static flag based on timer state
393+
state = timer_state.state
394+
attrs = timer_state.attributes
395+
seconds = 0
396+
static = True # Default to static
397+
398+
if state == "active":
399+
# Calculate remaining time from finishes_at
400+
finishes_at = attrs.get("finishes_at")
401+
if finishes_at:
402+
try:
403+
finish_time = datetime.fromisoformat(finishes_at.replace("Z", "+00:00"))
404+
now = datetime.now(timezone.utc)
405+
seconds = max(0, int((finish_time - now).total_seconds()))
406+
static = False # Animated countdown
407+
except (ValueError, TypeError) as err:
408+
_LOGGER.error("Failed to parse finishes_at '%s': %s", finishes_at, err)
409+
elif state == "paused":
410+
# Use remaining attribute
411+
remaining = attrs.get("remaining")
412+
if remaining:
413+
seconds = _parse_timer_duration(remaining)
414+
static = True # Static for paused
415+
else: # idle
416+
# Use duration attribute (time it will run when started)
417+
duration = attrs.get("duration")
418+
if duration:
419+
seconds = _parse_timer_duration(duration)
420+
static = True # Static for idle
421+
422+
if seconds <= 0:
423+
_LOGGER.warning("Timer has no valid duration")
424+
return False
425+
426+
# Get font size setting
427+
font_size = await _get_entity_setting(hass, device_name, "number", "font_size", float, api._address)
428+
429+
# Get colors from light entities
430+
text_color_hex = get_color_from_light_entity(hass, api._address, "text_color", default="00ff00")
431+
bg_color_hex = get_color_from_light_entity(hass, api._address, "background_color", default="000000")
432+
433+
text_color = (
434+
int(text_color_hex[0:2], 16),
435+
int(text_color_hex[2:4], 16),
436+
int(text_color_hex[4:6], 16),
437+
)
438+
bg_color = (
439+
int(bg_color_hex[0:2], 16),
440+
int(bg_color_hex[2:4], 16),
441+
int(bg_color_hex[4:6], 16),
442+
)
443+
444+
# Connect if needed
445+
if not api.is_connected:
446+
_LOGGER.debug("Reconnecting to device for timer mode update")
447+
await api.connect()
448+
449+
# Display timer
450+
success = await api.display_timer_gif(
451+
duration_seconds=seconds,
452+
text_color=text_color,
453+
bg_color=bg_color,
454+
static=static,
455+
font_size=font_size,
456+
)
457+
458+
if success:
459+
_LOGGER.info("Timer mode update: %d sec, state=%s, static=%s", seconds, state, static)
460+
else:
461+
_LOGGER.error("Timer mode update failed")
462+
463+
return success
464+
465+
except Exception as err:
466+
_LOGGER.error("Error in timer mode update: %s", err)
467+
return False
468+
469+
470+
def _parse_timer_duration(duration_str: str) -> int:
471+
"""Parse timer duration string to seconds."""
472+
try:
473+
parts = duration_str.split(":")
474+
if len(parts) == 3:
475+
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
476+
elif len(parts) == 2:
477+
return int(parts[0]) * 60 + int(parts[1])
478+
else:
479+
return int(float(duration_str))
480+
except (ValueError, IndexError):
481+
return 0
482+
483+
358484
async def _get_entity_setting(hass: HomeAssistant, device_name: str, platform: str, setting: str, value_type=str, address: str = None):
359485
"""Get setting from Home Assistant entity.
360486

custom_components/ipixel_color/giftimer/gif_builder.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,23 @@
99

1010

1111
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."""
12+
font_path=None, width=None, height=None, static=False):
13+
"""Build a countdown timer GIF or static image.
14+
15+
Args:
16+
duration_seconds: Time to display/countdown from in seconds
17+
output_path: Path to save the output file
18+
font_size: Font size in pixels
19+
bg: Background color as RGB tuple
20+
fg: Foreground/text color as RGB tuple
21+
font_path: Path to TTF font file, or None for default
22+
width: Image width, or None for auto
23+
height: Image height, or None for auto
24+
static: If True, generate a single-frame static image instead of animated GIF
25+
26+
Returns:
27+
Number of frames (1 for static, duration_seconds+1 for animated)
28+
"""
1429
font = get_font(font_size, font_path)
1530
palette = create_palette(bg, fg)
1631
padding = 20
@@ -26,6 +41,20 @@ def build_timer_gif(duration_seconds, output_path, font_size=72, bg=DEFAULT_BG,
2641
if height is None:
2742
height = text_h + 2 * padding
2843

44+
if static:
45+
# Generate single static frame showing the duration
46+
time_text = format_time(duration_seconds)
47+
frame, _, _ = create_frame(time_text, width, height, font, palette)
48+
49+
# Save as single-frame GIF (or PNG based on extension)
50+
if output_path.lower().endswith('.png'):
51+
frame.convert("RGB").save(output_path, format="PNG")
52+
else:
53+
frame.save(output_path, format="GIF")
54+
55+
return 1
56+
57+
# Generate animated countdown GIF
2958
frames = []
3059
durations = []
3160
prev_frame = None

custom_components/ipixel_color/select.py

Lines changed: 10 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -399,99 +399,25 @@ def async_timer_state_changed(event: Event[EventStateChangedData]) -> None:
399399
new_state.state
400400
)
401401

402-
# Only trigger when timer becomes active (started)
403-
if new_state.state == "active":
404-
# Check if we're in timer mode
405-
mode_entity_id = get_entity_id_by_unique_id(
406-
self.hass, self._address, "mode_select", "select"
402+
# Check if timer mode is active on the display
403+
if self._api._active_mode == MODE_TIMER:
404+
_LOGGER.debug("Timer state changed, updating timer display")
405+
self.hass.async_create_task(
406+
self._update_timer_on_callback()
407407
)
408-
mode_state = self.hass.states.get(mode_entity_id) if mode_entity_id else None
409-
410-
if mode_state and mode_state.state == MODE_TIMER:
411-
# Get the remaining duration from the timer
412-
duration = new_state.attributes.get("duration", "0:00:00")
413-
# Parse duration string (H:MM:SS or M:SS format)
414-
duration_seconds = self._parse_duration(duration)
415-
416-
if duration_seconds > 0:
417-
_LOGGER.info(
418-
"Timer started! Displaying %d second countdown",
419-
duration_seconds
420-
)
421-
# Schedule the timer GIF display
422-
self.hass.async_create_task(
423-
self._display_timer(duration_seconds)
424-
)
425408

426409
self._unsub_state_change = async_track_state_change_event(
427410
self.hass, timer_entity_id, async_timer_state_changed
428411
)
429412
_LOGGER.debug("Set up state listener for timer: %s", timer_entity_id)
430413

431-
def _parse_duration(self, duration_str: str) -> int:
432-
"""Parse duration string to seconds.
433-
434-
Accepts formats: H:MM:SS, MM:SS, or seconds as string
435-
"""
436-
try:
437-
parts = duration_str.split(":")
438-
if len(parts) == 3:
439-
# H:MM:SS
440-
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
441-
elif len(parts) == 2:
442-
# MM:SS or M:SS
443-
return int(parts[0]) * 60 + int(parts[1])
444-
else:
445-
# Just seconds
446-
return int(float(duration_str))
447-
except (ValueError, IndexError) as err:
448-
_LOGGER.error("Failed to parse duration '%s': %s", duration_str, err)
449-
return 0
450-
451-
async def _display_timer(self, duration_seconds: int) -> None:
452-
"""Display the timer countdown GIF on the device."""
414+
async def _update_timer_on_callback(self) -> None:
415+
"""Update timer display on callback without changing active mode."""
416+
from .common import _update_timer_mode
453417
try:
454-
# Get colors from light entities
455-
from .common import get_color_from_light_entity
456-
457-
text_color_hex = get_color_from_light_entity(
458-
self.hass, self._address, "text_color", default="00ff00"
459-
)
460-
bg_color_hex = get_color_from_light_entity(
461-
self.hass, self._address, "background_color", default="000000"
462-
)
463-
464-
# Convert hex to RGB tuples
465-
text_color = (
466-
int(text_color_hex[0:2], 16),
467-
int(text_color_hex[2:4], 16),
468-
int(text_color_hex[4:6], 16),
469-
)
470-
bg_color = (
471-
int(bg_color_hex[0:2], 16),
472-
int(bg_color_hex[2:4], 16),
473-
int(bg_color_hex[4:6], 16),
474-
)
475-
476-
# Connect if needed
477-
if not self._api.is_connected:
478-
_LOGGER.debug("Reconnecting to device for timer display")
479-
await self._api.connect()
480-
481-
# Display the timer GIF
482-
success = await self._api.display_timer_gif(
483-
duration_seconds=duration_seconds,
484-
text_color=text_color,
485-
bg_color=bg_color,
486-
)
487-
488-
if success:
489-
_LOGGER.info("Timer countdown displayed successfully")
490-
else:
491-
_LOGGER.error("Failed to display timer countdown")
492-
418+
await _update_timer_mode(self.hass, self._name, self._api)
493419
except Exception as err:
494-
_LOGGER.error("Error displaying timer: %s", err)
420+
_LOGGER.error("Error updating timer on callback: %s", err)
495421

496422
@property
497423
def available(self) -> bool:

0 commit comments

Comments
 (0)