diff --git a/src/blueprints/plugin.py b/src/blueprints/plugin.py index b7a80d860..ee07078f0 100644 --- a/src/blueprints/plugin.py +++ b/src/blueprints/plugin.py @@ -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: diff --git a/src/model.py b/src/model.py index df2f4b1cf..c3b8fc4da 100644 --- a/src/model.py +++ b/src/model.py @@ -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 # 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" diff --git a/src/refresh_task.py b/src/refresh_task.py index f554e2adb..1df70f785 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -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()) @@ -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() @@ -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) @@ -161,11 +170,19 @@ 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 @@ -173,19 +190,43 @@ def _determine_next_plugin(self, playlist_manager, latest_refresh_info, current_ 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 = { @@ -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.""" @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/src/templates/inky.html b/src/templates/inky.html index 97b85a697..819496252 100644 --- a/src/templates/inky.html +++ b/src/templates/inky.html @@ -51,6 +51,12 @@ refreshImage(); setInterval(refreshImage, refreshIntervalMs); }); + + window.addEventListener('pageshow', function(event) { + if (event.persisted) { + location.reload(); + } + }); @@ -75,7 +81,12 @@
+
+