Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2d84f58
fix: scheduled refresh triggering prematurely and stale image reuse
saulob Mar 19, 2026
ce3d6f1
fix: change clock plugin default timezone fallback from US/Eastern to…
saulob Mar 19, 2026
140f897
fix: show placeholder instead of broken image when no current image e…
saulob Mar 19, 2026
1a08507
fix: revert clock default timezone to US/Eastern and fix playlist ind…
saulob Mar 19, 2026
493f424
fix: advance playlist on skipped plugins and handle null images
saulob Mar 19, 2026
16a3eef
fix: check all playlist plugins each cycle instead of one at a time
saulob Mar 19, 2026
3300027
fix: return clear error when plugin fails to generate image
saulob Mar 19, 2026
84eda22
fix: auto-reload main page on browser back navigation
saulob Mar 19, 2026
6e0021c
fix: add tests for scheduled refresh and interval handling in PluginI…
Mar 26, 2026
33f34ac
fix: enhance scheduled refresh logic to handle timezone-aware datetimes
Mar 26, 2026
a727917
fix: refine image refresh logic to allow cached images and improve pl…
Mar 26, 2026
bf6c6d5
fix: change log level to debug for image refresh and caching messages
Mar 26, 2026
90c0e9b
fix: improve cached image handling in PlaylistRefresh to enhance disp…
Mar 26, 2026
300c81e
fix: enhance image loading behavior to show/hide elements based on lo…
Mar 26, 2026
0ea631c
fix: improve scheduled refresh logic and cached image handling
saulob Mar 26, 2026
0b3efe5
fix: improve next-plugin selection logic for scheduled refresh
saulob Mar 26, 2026
86833cf
fix: improve plugin selection logic for scheduled and interval refreshes
saulob Mar 26, 2026
cd6f3e3
fix: correct timezone handling in plugin instances
saulob Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/blueprints/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ def update_now():

plugin = get_plugin_instance(plugin_config)
image = plugin.generate_image(plugin_settings, device_config)
if image is None:
return jsonify({"error": f"Plugin '{plugin_id}' failed to generate an image."}), 500
display_manager.display_image(image, image_settings=plugin_config.get("image_settings", []))

except Exception as e:
Expand Down
133 changes: 119 additions & 14 deletions src/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,38 +296,143 @@ def update(self, updated_data):
def should_refresh(self, current_time):
"""Checks whether the plugin should be refreshed based on its refresh settings and the current time."""
latest_refresh_dt = self.get_latest_refresh_dt()
# If never refreshed, check scheduled time before allowing refresh
if not latest_refresh_dt:
if "scheduled" in self.refresh:
scheduled_time_str = self.refresh.get("scheduled")
scheduled_time = datetime.strptime(scheduled_time_str, "%H:%M").time()

# Build a scheduled datetime on the current date and align tzinfo with current_time
scheduled_dt = datetime.combine(current_time.date(), scheduled_time)
if current_time.tzinfo is not None:
scheduled_dt = scheduled_dt.replace(tzinfo=current_time.tzinfo)

result = current_time >= scheduled_dt
logger.debug(
f"should_refresh({self.name}): never refreshed, scheduled={scheduled_time_str}, "
f"current_time={current_time}, result={result}"
)
return result
logger.debug(f"should_refresh({self.name}): never refreshed, no schedule, returning True")
return True
Comment thread
saulob marked this conversation as resolved.
Comment thread
saulob marked this conversation as resolved.

# Check for interval-based refresh
if "interval" in self.refresh:
interval = self.refresh.get("interval")
if interval and (current_time - latest_refresh_dt) >= timedelta(seconds=interval):
return True
if interval:
ldt = latest_refresh_dt
# Normalize latest refresh to current_time's tz-naive/aware state
if ldt.tzinfo is None and current_time.tzinfo is not None:
ldt = ldt.replace(tzinfo=current_time.tzinfo)
elif ldt.tzinfo is not None and current_time.tzinfo is None:
ldt = ldt.replace(tzinfo=None)
elif ldt.tzinfo is not None and current_time.tzinfo is not None:
ldt = ldt.astimezone(current_time.tzinfo)

elapsed = current_time - ldt
if elapsed >= timedelta(seconds=interval):
logger.debug(
f"should_refresh({self.name}): interval elapsed "
f"({elapsed.total_seconds():.0f}s >= {interval}s), returning True"
)
return True

# Check for scheduled refresh (HH:MM format)
if "scheduled" in self.refresh:
scheduled_time_str = self.refresh.get("scheduled")
latest_refresh_str = latest_refresh_dt.strftime("%H:%M")

# If the latest refresh is before the scheduled time today
if latest_refresh_str < scheduled_time_str:
return True

if "scheduled" in self.refresh:
scheduled_time_str = self.refresh.get("scheduled")
scheduled_time = datetime.strptime(scheduled_time_str, "%H:%M").time()

latest_refresh_date = latest_refresh_dt.date()

# Build a scheduled datetime for today and align tzinfo with current_time
scheduled_dt = datetime.combine(current_time.date(), scheduled_time)
if current_time.tzinfo is not None:
scheduled_dt = scheduled_dt.replace(tzinfo=current_time.tzinfo)

# Normalize latest_refresh_dt into current_time's timezone/naive state for safe comparison
ldt = latest_refresh_dt
if ldt.tzinfo is None and current_time.tzinfo is not None:
ldt = ldt.replace(tzinfo=current_time.tzinfo)
elif ldt.tzinfo is not None and current_time.tzinfo is None:
ldt = ldt.replace(tzinfo=None)
elif ldt.tzinfo is not None and current_time.tzinfo is not None:
ldt = ldt.astimezone(current_time.tzinfo)

latest_refresh_date = ldt.date()
current_date = current_time.date()

# Determine if a refresh is needed based on scheduled time and last refresh
if (latest_refresh_date < current_date and current_time.time() >= scheduled_time) or \
(latest_refresh_date == current_date and latest_refresh_dt.time() < scheduled_time <= current_time.time()):
if (latest_refresh_date < current_date and current_time >= scheduled_dt) or \
(latest_refresh_date == current_date and ldt < scheduled_dt <= current_time):
logger.debug(
f"should_refresh({self.name}): scheduled time reached "
f"(scheduled={scheduled_time_str}, latest_refresh_date={latest_refresh_date}, "
f"current_date={current_date}), returning True"
)
return True

logger.debug(
f"should_refresh({self.name}): no refresh needed "
f"| latest_refresh={latest_refresh_dt} | refresh_settings={self.refresh}"
)
return False

def get_due_datetime(self, current_time):
"""Computes the effective due datetime for this plugin.

For scheduled plugins: today's scheduled time (or yesterday's if the
plugin has never been refreshed and the scheduled time has passed).
For interval plugins: latest_refresh_time + interval.
For never-refreshed non-scheduled plugins: datetime.min (highest priority).

Returns a timezone-aware or naive datetime matching current_time.
"""
latest_refresh_dt = self.get_latest_refresh_dt()

# --- scheduled ---
if "scheduled" in self.refresh:
scheduled_time_str = self.refresh.get("scheduled")
scheduled_time = datetime.strptime(scheduled_time_str, "%H:%M").time()
scheduled_dt = datetime.combine(current_time.date(), scheduled_time)
if current_time.tzinfo is not None:
scheduled_dt = scheduled_dt.replace(tzinfo=current_time.tzinfo)

if not latest_refresh_dt:
# Never refreshed ? due since today's scheduled time
return scheduled_dt

# Normalize latest_refresh_dt timezone
ldt = latest_refresh_dt
if ldt.tzinfo is None and current_time.tzinfo is not None:
ldt = ldt.replace(tzinfo=current_time.tzinfo)
elif ldt.tzinfo is not None and current_time.tzinfo is None:
ldt = ldt.replace(tzinfo=None)
elif ldt.tzinfo is not None and current_time.tzinfo is not None:
ldt = ldt.astimezone(current_time.tzinfo)

# If last refresh was before today's scheduled time, due time is today's scheduled time
if ldt < scheduled_dt <= current_time:
return scheduled_dt
# If last refresh was on a previous day, due time is today's scheduled time
if ldt.date() < current_time.date() and current_time >= scheduled_dt:
return scheduled_dt

# --- interval ---
if "interval" in self.refresh:
interval = self.refresh.get("interval")
if interval and latest_refresh_dt:
ldt = latest_refresh_dt
if ldt.tzinfo is None and current_time.tzinfo is not None:
ldt = ldt.replace(tzinfo=current_time.tzinfo)
elif ldt.tzinfo is not None and current_time.tzinfo is None:
ldt = ldt.replace(tzinfo=None)
elif ldt.tzinfo is not None and current_time.tzinfo is not None:
ldt = ldt.astimezone(current_time.tzinfo)
return ldt + timedelta(seconds=interval)

# Never refreshed, no schedule ? due since the beginning of time
if current_time.tzinfo is not None:
return datetime.min.replace(tzinfo=current_time.tzinfo)
return datetime.min

def get_image_path(self):
"""Formats the image path for this plugin instance."""
return f"{self.plugin_id}_{self.name.replace(' ', '_')}.png"
Expand Down
99 changes: 76 additions & 23 deletions src/refresh_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@ def _run(self):
logger.info(f"Running interval refresh check. | current_time: {current_dt.strftime('%Y-%m-%d %H:%M:%S')}")
playlist, plugin_instance = self._determine_next_plugin(playlist_manager, latest_refresh, current_dt)
if plugin_instance:
refresh_action = PlaylistRefresh(playlist, plugin_instance)
# Do not force regeneration here; let the PlaylistRefresh decide whether to
# regenerate the plugin's image or load the cached image. This keeps
# display rotation independent from image regeneration.
refresh_action = PlaylistRefresh(playlist, plugin_instance, force=False)

if refresh_action:
plugin_config = self.device_config.get_plugin(refresh_action.get_plugin_id())
Expand All @@ -112,6 +115,12 @@ def _run(self):
continue
plugin = get_plugin_instance(plugin_config)
image = refresh_action.execute(plugin, self.device_config, current_dt)

# If execute returns None, the plugin was skipped (not time to refresh)
if image is None:
self.device_config.write_config()
continue

image_hash = compute_image_hash(image)

refresh_info = refresh_action.get_refresh_info()
Expand All @@ -121,7 +130,7 @@ def _run(self):
logger.info(f"Updating display. | refresh_info: {refresh_info}")
self.display_manager.display_image(image, image_settings=plugin.config.get("image_settings", []))
else:
logger.info(f"Image already displayed, skipping refresh. | refresh_info: {refresh_info}")
logger.debug(f"Image already displayed, skipping refresh. | refresh_info: {refresh_info}")

# update latest refresh data in the device config
self.device_config.refresh_info = RefreshInfo(**refresh_info)
Expand Down Expand Up @@ -161,31 +170,63 @@ def _get_current_datetime(self):
return datetime.now(pytz.timezone(tz_str))

def _determine_next_plugin(self, playlist_manager, latest_refresh_info, current_dt):
"""Determines the next plugin to refresh based on the active playlist, plugin cycle interval, and current time."""
"""Determines the next plugin to refresh by scanning all plugins in the active playlist.

Collects every plugin whose should_refresh() returns True, computes each
one's effective due datetime, and selects the plugin that has been overdue
the longest (oldest due time). Ties are broken by playlist order starting
from the round-robin index for fairness.

If no plugin is due, returns (None, None) so the display is not updated.
"""
playlist = playlist_manager.determine_active_playlist(current_dt)
if not playlist:
playlist_manager.active_playlist = None
logger.info(f"No active playlist determined.")
logger.info("No active playlist determined.")
return None, None

playlist_manager.active_playlist = playlist.name
if not playlist.plugins:
logger.info(f"Active playlist '{playlist.name}' has no plugins.")
return None, None

latest_refresh_dt = latest_refresh_info.get_refresh_datetime()
plugin_cycle_interval = self.device_config.get_config("plugin_cycle_interval_seconds", default=3600)
should_refresh = PlaylistManager.should_refresh(latest_refresh_dt, plugin_cycle_interval, current_dt)

if not should_refresh:
latest_refresh_str = latest_refresh_dt.strftime('%Y-%m-%d %H:%M:%S') if latest_refresh_dt else "None"
logger.info(f"Not time to update display. | latest_update: {latest_refresh_str} | plugin_cycle_interval: {plugin_cycle_interval}")
# Collect all due plugins with their effective due datetimes
num_plugins = len(playlist.plugins)
start_index = ((playlist.current_plugin_index or -1) + 1) % num_plugins
due_plugins = []

for i in range(num_plugins):
idx = (start_index + i) % num_plugins
plugin = playlist.plugins[idx]
if plugin.should_refresh(current_dt):
due_dt = plugin.get_due_datetime(current_dt)
due_plugins.append((due_dt, i, idx, plugin)) # i = scan order for tie-breaking
logger.debug(
f"Plugin due: {plugin.name} | due_datetime: {due_dt.strftime('%Y-%m-%d %H:%M:%S')} "
f"| overdue_seconds: {(current_dt - due_dt).total_seconds():.0f}"
)

if not due_plugins:
logger.info(
f"No plugins due for refresh in playlist '{playlist.name}'. "
f"| checked: {num_plugins} plugin(s)"
)
return None, None

plugin = playlist.get_next_plugin()
logger.info(f"Determined next plugin. | active_playlist: {playlist.name} | plugin_instance: {plugin.name}")

return playlist, plugin
# Select the plugin with the oldest due time (most overdue).
# Ties broken by scan order (round-robin fairness).
due_plugins.sort(key=lambda x: (x[0], x[1]))
selected_due_dt, _, selected_idx, selected_plugin = due_plugins[0]

playlist.current_plugin_index = selected_idx
logger.info(
f"Selected plugin for refresh. | active_playlist: {playlist.name} "
f"| plugin_instance: {selected_plugin.name} | index: {selected_idx} "
f"| due_datetime: {selected_due_dt.strftime('%Y-%m-%d %H:%M:%S')} "
f"| overdue_seconds: {(current_dt - selected_due_dt).total_seconds():.0f} "
f"| total_due_plugins: {len(due_plugins)}"
)
return playlist, selected_plugin

def log_system_stats(self):
metrics = {
Expand Down Expand Up @@ -231,7 +272,10 @@ def __init__(self, plugin_id: str, plugin_settings: dict):

def execute(self, plugin, device_config, current_dt: datetime):
"""Performs a manual refresh using the stored plugin ID and settings."""
return plugin.generate_image(self.plugin_settings, device_config)
image = plugin.generate_image(self.plugin_settings, device_config)
if image is None:
raise RuntimeError(f"Plugin '{self.plugin_id}' failed to generate an image.")
return image

def get_refresh_info(self):
"""Return refresh metadata as a dictionary."""
Expand Down Expand Up @@ -277,12 +321,21 @@ def execute(self, plugin, device_config, current_dt: datetime):
logger.info(f"Refreshing plugin instance. | plugin_instance: '{self.plugin_instance.name}'")
# Generate a new image
image = plugin.generate_image(self.plugin_instance.settings, device_config)
if image is None:
logger.error(f"Plugin '{self.plugin_instance.name}' returned no image. Skipping.")
return None
image.save(plugin_image_path)
self.plugin_instance.latest_refresh_time = current_dt.isoformat()
else:
logger.info(f"Not time to refresh plugin instance, using latest image. | plugin_instance: {self.plugin_instance.name}.")
# Load the existing image from disk
with Image.open(plugin_image_path) as img:
image = img.copy()

return image
return image

# Not time to regenerate — return None so the caller skips the display
# update. The rotation index has already been advanced by
# get_next_plugin(), so the *next* cycle will evaluate the following
# plugin in the playlist. This avoids unnecessary e-paper refreshes
# (which cause visible flashing) when no plugin has new content.
logger.info(
f"Not time to refresh plugin instance; skipping display update. "
f"| plugin_instance: {self.plugin_instance.name} "
f"| latest_refresh: {self.plugin_instance.latest_refresh_time}"
)
return None
13 changes: 12 additions & 1 deletion src/templates/inky.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@
refreshImage();
setInterval(refreshImage, refreshIntervalMs);
});

window.addEventListener('pageshow', function(event) {
if (event.persisted) {
location.reload();
}
});
</script>
<script src="{{ url_for('static', filename='scripts/image_modal.js') }}"></script>
</head>
Expand All @@ -75,7 +81,12 @@ <h1>{{ config.name }}</h1>

<!-- Display the current image -->
<div class="image-container">
<img src="{{ url_for('static', filename='images/current_image.png') }}" alt="Current Image">
<img src="{{ url_for('static', filename='images/current_image.png') }}" alt="Current Image"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
onload="this.style.display='block'; this.nextElementSibling.style.display='none';">
<div style="display:none; width:100%; aspect-ratio:5/3; background:var(--bg-secondary, #f0f0f0); border-radius:8px; align-items:center; justify-content:center; color:var(--text-secondary, #888); font-size:14px;">
No image displayed yet
</div>
</div>

<!-- Separator -->
Expand Down
Loading