From 2d84f582e287dd5e2764dbb347c96d2a1b185e04 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 19 Mar 2026 14:16:15 -0300 Subject: [PATCH 01/18] fix: scheduled refresh triggering prematurely and stale image reuse - Remove duplicate scheduled refresh check in PluginInstance.should_refresh() that incorrectly triggered refresh before the scheduled time by comparing latest_refresh hour string against scheduled time string - Change PlaylistRefresh.execute() to return None instead of loading a stale cached image when plugin does not need refresh, preventing the display from reverting to outdated clock images - Handle None return from execute() in RefreshTask._run() to skip plugins that are not due for refresh --- src/model.py | 8 -------- src/refresh_task.py | 13 +++++++++---- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/model.py b/src/model.py index df2f4b1cf..0e8b96757 100644 --- a/src/model.py +++ b/src/model.py @@ -306,14 +306,6 @@ def should_refresh(self, current_time): 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() diff --git a/src/refresh_task.py b/src/refresh_task.py index f554e2adb..35d8c5c4c 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -112,6 +112,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() @@ -280,9 +286,8 @@ def execute(self, plugin, device_config, current_dt: datetime): 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() + logger.info(f"Not time to refresh plugin instance, skipping. | plugin_instance: {self.plugin_instance.name}.") + # Return None to signal that this plugin should be skipped + return None return image \ No newline at end of file From ce3d6f12bd6d2d0b9c205dae0d8c1491f7715141 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 19 Mar 2026 14:18:26 -0300 Subject: [PATCH 02/18] fix: change clock plugin default timezone fallback from US/Eastern to UTC Align default timezone with refresh_task which already defaults to UTC. Previously, if timezone was not configured, the clock would display US/Eastern time instead of the expected system/configured timezone. fix: scheduled plugin should not refresh before scheduled time on first run When a scheduled plugin is created with no previous refresh history, it now waits until the scheduled time instead of refreshing immediately. --- src/model.py | 6 ++++++ src/plugins/clock/clock.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/model.py b/src/model.py index 0e8b96757..f998e8ff5 100644 --- a/src/model.py +++ b/src/model.py @@ -296,7 +296,13 @@ 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() + return current_time.time() >= scheduled_time return True # Check for interval-based refresh diff --git a/src/plugins/clock/clock.py b/src/plugins/clock/clock.py index c065f3094..500246e3b 100644 --- a/src/plugins/clock/clock.py +++ b/src/plugins/clock/clock.py @@ -38,7 +38,7 @@ } ] -DEFAULT_TIMEZONE = "US/Eastern" +DEFAULT_TIMEZONE = "UTC" DEFAULT_CLOCK_FACE = "Gradient Clock" class Clock(BasePlugin): From 140f897c7b63e75a06eab17967c59d7bbbdd4d1e Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 19 Mar 2026 14:38:52 -0300 Subject: [PATCH 03/18] fix: show placeholder instead of broken image when no current image exists Display a 'No image displayed yet' message on the dashboard when the system starts fresh and no plugin has generated an image yet. --- src/templates/inky.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/templates/inky.html b/src/templates/inky.html index 97b85a697..b47b4da5c 100644 --- a/src/templates/inky.html +++ b/src/templates/inky.html @@ -75,7 +75,11 @@

{{ config.name }}

- Current Image + Current Image +
+ No image displayed yet +
From 1a0850792a81d456c20617651f77baf9073d92b2 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 19 Mar 2026 14:49:11 -0300 Subject: [PATCH 04/18] fix: revert clock default timezone to US/Eastern and fix playlist index on skip - Revert DEFAULT_TIMEZONE back to US/Eastern since UTC is not available in the settings UI timezone list - When a plugin instance is skipped (not time to refresh), revert the playlist index so the same plugin is retried on the next cycle instead of advancing to the next one and causing a 1-minute delay --- src/plugins/clock/clock.py | 2 +- src/refresh_task.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/plugins/clock/clock.py b/src/plugins/clock/clock.py index 500246e3b..c065f3094 100644 --- a/src/plugins/clock/clock.py +++ b/src/plugins/clock/clock.py @@ -38,7 +38,7 @@ } ] -DEFAULT_TIMEZONE = "UTC" +DEFAULT_TIMEZONE = "US/Eastern" DEFAULT_CLOCK_FACE = "Gradient Clock" class Clock(BasePlugin): diff --git a/src/refresh_task.py b/src/refresh_task.py index 35d8c5c4c..227b109c0 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -115,6 +115,11 @@ def _run(self): # If execute returns None, the plugin was skipped (not time to refresh) if image is None: + # Revert playlist index so this plugin is retried next cycle + if isinstance(refresh_action, PlaylistRefresh): + playlist = refresh_action.playlist + if playlist.current_plugin_index is not None: + playlist.current_plugin_index = (playlist.current_plugin_index - 1) % len(playlist.plugins) self.device_config.write_config() continue From 493f42433ea70625dbbccc077b0d5da260394cd9 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 19 Mar 2026 15:44:53 -0300 Subject: [PATCH 05/18] fix: advance playlist on skipped plugins and handle null images - Stop reverting playlist index when a plugin is skipped, preventing the playlist from being stuck on the same plugin indefinitely - Add null check after generate_image() to gracefully skip plugins that fail to produce an image instead of crashing --- src/refresh_task.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/refresh_task.py b/src/refresh_task.py index 227b109c0..4faf5aca6 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -115,11 +115,6 @@ def _run(self): # If execute returns None, the plugin was skipped (not time to refresh) if image is None: - # Revert playlist index so this plugin is retried next cycle - if isinstance(refresh_action, PlaylistRefresh): - playlist = refresh_action.playlist - if playlist.current_plugin_index is not None: - playlist.current_plugin_index = (playlist.current_plugin_index - 1) % len(playlist.plugins) self.device_config.write_config() continue @@ -288,6 +283,9 @@ 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: From 16a3eef476bf2f0efbbd678d8e7ae8d55450efe3 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 19 Mar 2026 16:04:10 -0300 Subject: [PATCH 06/18] fix: check all playlist plugins each cycle instead of one at a time - Loop through all plugins in the playlist to find one that needs refreshing, instead of only checking one plugin per cycle - Prevents delayed refresh when playlist has many plugins (e.g. 10 plugins with 1-min cycle no longer takes 10 min to reach a plugin) --- src/refresh_task.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/refresh_task.py b/src/refresh_task.py index 4faf5aca6..945b1a9ca 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -103,7 +103,7 @@ 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) + refresh_action = PlaylistRefresh(playlist, plugin_instance, force=True) if refresh_action: plugin_config = self.device_config.get_plugin(refresh_action.get_plugin_id()) @@ -188,10 +188,18 @@ def _determine_next_plugin(self, playlist_manager, latest_refresh_info, current_ logger.info(f"Not time to update display. | latest_update: {latest_refresh_str} | plugin_cycle_interval: {plugin_cycle_interval}") 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 + # Loop through all plugins in the playlist to find one that needs refreshing + num_plugins = len(playlist.plugins) + for _ in range(num_plugins): + plugin = playlist.get_next_plugin() + if plugin.should_refresh(current_dt): + logger.info(f"Determined next plugin. | active_playlist: {playlist.name} | plugin_instance: {plugin.name}") + return playlist, plugin + else: + logger.info(f"Plugin '{plugin.name}' not due for refresh, skipping.") + + logger.info(f"No plugins in playlist '{playlist.name}' need refreshing.") + return None, None def log_system_stats(self): metrics = { From 33000277bfb7ed39b000b8e714335ac183f8fc5b Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 19 Mar 2026 18:11:16 -0300 Subject: [PATCH 07/18] fix: return clear error when plugin fails to generate image - ManualRefresh raises RuntimeError with descriptive message - update_now returns error response when image is null in dev mode --- src/blueprints/plugin.py | 2 ++ src/refresh_task.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) 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/refresh_task.py b/src/refresh_task.py index 945b1a9ca..ea29a6939 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -245,7 +245,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.""" From 84eda2294629027981db89b98310a1359bbf9a05 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 19 Mar 2026 18:18:54 -0300 Subject: [PATCH 08/18] fix: auto-reload main page on browser back navigation - Detect bfcache restore via pageshow event and force reload - Ensures last refresh time and image are up to date after update --- src/templates/inky.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/templates/inky.html b/src/templates/inky.html index b47b4da5c..b5b5470bf 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(); + } + }); From 6e0021ce74bd04dc36c65a9876111a1a90f56cb4 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 25 Mar 2026 22:47:01 -0300 Subject: [PATCH 09/18] fix: add tests for scheduled refresh and interval handling in PluginInstance --- tests/test_plugin_instance_refresh.py | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/test_plugin_instance_refresh.py diff --git a/tests/test_plugin_instance_refresh.py b/tests/test_plugin_instance_refresh.py new file mode 100644 index 000000000..d6b4fce6d --- /dev/null +++ b/tests/test_plugin_instance_refresh.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone + +from src.model import PluginInstance + + +def make_plugin(refresh, latest_refresh_time=None): + return PluginInstance(plugin_id="p1", name="inst", settings={}, refresh=refresh, latest_refresh_time=latest_refresh_time) + + +def test_never_refreshed_with_and_without_scheduled(): + # no scheduled -> should refresh immediately + p = make_plugin(refresh={}) + now = datetime(2022, 1, 2, 14, 0, 0) + assert p.should_refresh(now) is True + + # scheduled in future -> should not refresh + p = make_plugin(refresh={"scheduled": "15:00"}) + now = datetime(2022, 1, 2, 14, 0, 0) + assert p.should_refresh(now) is False + + # scheduled at or before current time -> should refresh + p = make_plugin(refresh={"scheduled": "14:00"}) + now = datetime(2022, 1, 2, 14, 0, 0) + assert p.should_refresh(now) is True + + +def test_interval_refresh_naive_datetimes(): + # latest refresh 2 minutes ago, interval 60s -> should refresh + latest = datetime(2022, 1, 2, 14, 0, 0) + p = make_plugin(refresh={"interval": 60}, latest_refresh_time=latest.isoformat()) + now = datetime(2022, 1, 2, 14, 2, 0) + assert p.should_refresh(now) is True + + # latest refresh 30s ago, interval 120s -> should not refresh + latest = datetime(2022, 1, 2, 14, 1, 30) + p = make_plugin(refresh={"interval": 120}, latest_refresh_time=latest.isoformat()) + now = datetime(2022, 1, 2, 14, 2, 0) + assert p.should_refresh(now) is False + + +def test_interval_refresh_timezone_aware_datetimes(): + # use timezone-aware datetimes for both latest and current times + latest = datetime(2022, 1, 2, 14, 0, 0, tzinfo=timezone.utc) + p = make_plugin(refresh={"interval": 60}, latest_refresh_time=latest.isoformat()) + now = datetime(2022, 1, 2, 14, 2, 0, tzinfo=timezone.utc) + assert p.should_refresh(now) is True + + # not yet reached interval + latest = datetime(2022, 1, 2, 14, 1, 30, tzinfo=timezone.utc) + p = make_plugin(refresh={"interval": 120}, latest_refresh_time=latest.isoformat()) + now = datetime(2022, 1, 2, 14, 2, 0, tzinfo=timezone.utc) + assert p.should_refresh(now) is False + + +def test_scheduled_refresh_when_latest_refresh_varies(): + # latest refresh earlier same day before scheduled -> should refresh when current is at scheduled + latest = datetime(2022, 1, 2, 14, 0, 0) + p = make_plugin(refresh={"scheduled": "15:00"}, latest_refresh_time=latest.isoformat()) + now = datetime(2022, 1, 2, 15, 0, 0) + assert p.should_refresh(now) is True + + # latest refresh on same day after scheduled -> should not refresh + latest = datetime(2022, 1, 2, 15, 30, 0) + p = make_plugin(refresh={"scheduled": "15:00"}, latest_refresh_time=latest.isoformat()) + now = datetime(2022, 1, 2, 16, 0, 0) + assert p.should_refresh(now) is False + + # latest refresh previous day -> should refresh at scheduled time today + latest = datetime(2022, 1, 1, 23, 59, 0) + p = make_plugin(refresh={"scheduled": "08:00"}, latest_refresh_time=latest.isoformat()) + now = datetime(2022, 1, 2, 8, 0, 0) + assert p.should_refresh(now) is True From 33f34ac091f11b6262fda38e21d0c12790e39bff Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 25 Mar 2026 22:51:17 -0300 Subject: [PATCH 10/18] fix: enhance scheduled refresh logic to handle timezone-aware datetimes --- src/model.py | 45 ++++++++++++++++++++++----- tests/test_plugin_instance_refresh.py | 18 +++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/model.py b/src/model.py index f998e8ff5..e8a49fb5b 100644 --- a/src/model.py +++ b/src/model.py @@ -296,32 +296,61 @@ 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() - return current_time.time() >= scheduled_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) + + return current_time >= scheduled_dt 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) + + if (current_time - ldt) >= timedelta(seconds=interval): + return True # Check for scheduled refresh (HH:MM format) 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): return True return False diff --git a/tests/test_plugin_instance_refresh.py b/tests/test_plugin_instance_refresh.py index d6b4fce6d..c8270019a 100644 --- a/tests/test_plugin_instance_refresh.py +++ b/tests/test_plugin_instance_refresh.py @@ -70,3 +70,21 @@ def test_scheduled_refresh_when_latest_refresh_varies(): p = make_plugin(refresh={"scheduled": "08:00"}, latest_refresh_time=latest.isoformat()) now = datetime(2022, 1, 2, 8, 0, 0) assert p.should_refresh(now) is True + + +def test_never_refreshed_scheduled_with_tz_aware_current_time(): + # when current_time is timezone-aware we should not raise and should compare correctly + p = make_plugin(refresh={"scheduled": "14:00"}) + now = datetime(2022, 1, 2, 14, 0, 0, tzinfo=timezone.utc) + assert p.should_refresh(now) is True + + now = datetime(2022, 1, 2, 13, 59, 0, tzinfo=timezone.utc) + assert p.should_refresh(now) is False + + +def test_scheduled_refresh_timezone_aware_latest_and_current(): + # both latest and current are timezone-aware + latest = datetime(2022, 1, 1, 23, 59, 0, tzinfo=timezone.utc) + p = make_plugin(refresh={"scheduled": "08:00"}, latest_refresh_time=latest.isoformat()) + now = datetime(2022, 1, 2, 8, 0, 0, tzinfo=timezone.utc) + assert p.should_refresh(now) is True From a7279172d0f3ed5e34c7932ccefab1b7a0fda375 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 25 Mar 2026 23:03:08 -0300 Subject: [PATCH 11/18] fix: refine image refresh logic to allow cached images and improve plugin selection --- src/refresh_task.py | 50 ++++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/refresh_task.py b/src/refresh_task.py index ea29a6939..c6d1ee907 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, force=True) + # 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()) @@ -188,18 +191,16 @@ def _determine_next_plugin(self, playlist_manager, latest_refresh_info, current_ logger.info(f"Not time to update display. | latest_update: {latest_refresh_str} | plugin_cycle_interval: {plugin_cycle_interval}") return None, None - # Loop through all plugins in the playlist to find one that needs refreshing - num_plugins = len(playlist.plugins) - for _ in range(num_plugins): - plugin = playlist.get_next_plugin() - if plugin.should_refresh(current_dt): - logger.info(f"Determined next plugin. | active_playlist: {playlist.name} | plugin_instance: {plugin.name}") - return playlist, plugin - else: - logger.info(f"Plugin '{plugin.name}' not due for refresh, skipping.") - - logger.info(f"No plugins in playlist '{playlist.name}' need refreshing.") - return None, None + # Select the next plugin in the playlist for display rotation. Whether its image + # needs to be regenerated vs using a cached image should be decided during + # the refresh execution phase, not here. + plugin = playlist.get_next_plugin() + if not plugin: + logger.info(f"Failed to obtain next plugin from playlist '{playlist.name}'.") + return None, None + + logger.info(f"Determined next plugin. | active_playlist: {playlist.name} | plugin_instance: {plugin.name}") + return playlist, plugin def log_system_stats(self): metrics = { @@ -299,9 +300,24 @@ def execute(self, plugin, device_config, current_dt: datetime): 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, skipping. | plugin_instance: {self.plugin_instance.name}.") - # Return None to signal that this plugin should be skipped - return None + return image + # Not time to regenerate — attempt to load a cached image for display rotation. + logger.info(f"Using cached image for plugin instance if available. | plugin_instance: {self.plugin_instance.name}.") + if os.path.exists(plugin_image_path): + try: + image = Image.open(plugin_image_path) + # Do not update plugin_instance.latest_refresh_time — we are using cached content. + return image + except Exception: + logger.exception(f"Failed to load cached image for '{self.plugin_instance.name}', will attempt regeneration.") + + # If cached image not present or failed to load, fall back to regenerating the image. + logger.info(f"Cached image missing or unreadable; regenerating. | plugin_instance: {self.plugin_instance.name}") + 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 on regeneration. Skipping.") + return None + image.save(plugin_image_path) + self.plugin_instance.latest_refresh_time = current_dt.isoformat() return image \ No newline at end of file From bf6c6d5b5e61d5924187c112b268ed7252492f1f Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 25 Mar 2026 23:08:50 -0300 Subject: [PATCH 12/18] fix: change log level to debug for image refresh and caching messages --- src/refresh_task.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/refresh_task.py b/src/refresh_task.py index c6d1ee907..5ffc7c4f0 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -130,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) @@ -303,7 +303,7 @@ def execute(self, plugin, device_config, current_dt: datetime): return image # Not time to regenerate — attempt to load a cached image for display rotation. - logger.info(f"Using cached image for plugin instance if available. | plugin_instance: {self.plugin_instance.name}.") + logger.debug(f"Using cached image for plugin instance if available. | plugin_instance: {self.plugin_instance.name}.") if os.path.exists(plugin_image_path): try: image = Image.open(plugin_image_path) @@ -313,7 +313,7 @@ def execute(self, plugin, device_config, current_dt: datetime): logger.exception(f"Failed to load cached image for '{self.plugin_instance.name}', will attempt regeneration.") # If cached image not present or failed to load, fall back to regenerating the image. - logger.info(f"Cached image missing or unreadable; regenerating. | plugin_instance: {self.plugin_instance.name}") + logger.debug(f"Cached image missing or unreadable; regenerating. | plugin_instance: {self.plugin_instance.name}") 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 on regeneration. Skipping.") From 90c0e9bc186e9e883e2e11f0154b88b17e0e84c6 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 25 Mar 2026 23:12:50 -0300 Subject: [PATCH 13/18] fix: improve cached image handling in PlaylistRefresh to enhance display rotation --- src/refresh_task.py | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/refresh_task.py b/src/refresh_task.py index 5ffc7c4f0..176d11309 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -302,22 +302,26 @@ def execute(self, plugin, device_config, current_dt: datetime): self.plugin_instance.latest_refresh_time = current_dt.isoformat() return image - # Not time to regenerate — attempt to load a cached image for display rotation. - logger.debug(f"Using cached image for plugin instance if available. | plugin_instance: {self.plugin_instance.name}.") - if os.path.exists(plugin_image_path): - try: - image = Image.open(plugin_image_path) - # Do not update plugin_instance.latest_refresh_time — we are using cached content. - return image - except Exception: - logger.exception(f"Failed to load cached image for '{self.plugin_instance.name}', will attempt regeneration.") - - # If cached image not present or failed to load, fall back to regenerating the image. - logger.debug(f"Cached image missing or unreadable; regenerating. | plugin_instance: {self.plugin_instance.name}") - 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 on regeneration. Skipping.") + # Not time to regenerate — load and return the cached image from disk to keep + # playlist rotation working. Return None only for error/skip conditions. + logger.info( + f"Not time to refresh plugin instance; using cached image. | plugin_instance: {self.plugin_instance.name}." + ) + + if not os.path.exists(plugin_image_path): + logger.error( + f"Cached image for plugin instance is missing; cannot display. | plugin_instance: {self.plugin_instance.name} | path: {plugin_image_path}" + ) return None - image.save(plugin_image_path) - self.plugin_instance.latest_refresh_time = current_dt.isoformat() - return image \ No newline at end of file + + try: + image = Image.open(plugin_image_path) + # Ensure the image data is actually loaded before returning + image.load() + # Do not update plugin_instance.latest_refresh_time — we are using cached content. + return image + except Exception as exc: + logger.exception( + f"Failed to load cached image for plugin instance; skipping. | plugin_instance: {self.plugin_instance.name}, path: {plugin_image_path}" + ) + return None \ No newline at end of file From 300c81e2d726ee991ecf1d08ec03e629db07c78a Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Wed, 25 Mar 2026 23:18:27 -0300 Subject: [PATCH 14/18] fix: enhance image loading behavior to show/hide elements based on load state --- src/templates/inky.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/templates/inky.html b/src/templates/inky.html index b5b5470bf..819496252 100644 --- a/src/templates/inky.html +++ b/src/templates/inky.html @@ -82,7 +82,8 @@

{{ config.name }}

Current Image + onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';" + onload="this.style.display='block'; this.nextElementSibling.style.display='none';">
No image displayed yet
From 0ea631cc5a4ad84fed7d0b0b504caaa10e722939 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 26 Mar 2026 10:09:09 -0300 Subject: [PATCH 15/18] fix: improve scheduled refresh logic and cached image handling --- src/model.py | 24 ++++++++++++++++++++++-- src/refresh_task.py | 30 +++++++++--------------------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/model.py b/src/model.py index e8a49fb5b..23e8e116a 100644 --- a/src/model.py +++ b/src/model.py @@ -307,7 +307,13 @@ def should_refresh(self, current_time): if current_time.tzinfo is not None: scheduled_dt = scheduled_dt.replace(tzinfo=current_time.tzinfo) - return current_time >= scheduled_dt + 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 @@ -323,7 +329,12 @@ def should_refresh(self, current_time): elif ldt.tzinfo is not None and current_time.tzinfo is not None: ldt = ldt.astimezone(current_time.tzinfo) - if (current_time - ldt) >= timedelta(seconds=interval): + 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) @@ -351,8 +362,17 @@ def should_refresh(self, current_time): # Determine if a refresh is needed based on scheduled time and last refresh 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_image_path(self): diff --git a/src/refresh_task.py b/src/refresh_task.py index 176d11309..78790685c 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -302,26 +302,14 @@ def execute(self, plugin, device_config, current_dt: datetime): self.plugin_instance.latest_refresh_time = current_dt.isoformat() return image - # Not time to regenerate — load and return the cached image from disk to keep - # playlist rotation working. Return None only for error/skip conditions. + # 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; using cached image. | plugin_instance: {self.plugin_instance.name}." + 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}" ) - - if not os.path.exists(plugin_image_path): - logger.error( - f"Cached image for plugin instance is missing; cannot display. | plugin_instance: {self.plugin_instance.name} | path: {plugin_image_path}" - ) - return None - - try: - image = Image.open(plugin_image_path) - # Ensure the image data is actually loaded before returning - image.load() - # Do not update plugin_instance.latest_refresh_time — we are using cached content. - return image - except Exception as exc: - logger.exception( - f"Failed to load cached image for plugin instance; skipping. | plugin_instance: {self.plugin_instance.name}, path: {plugin_image_path}" - ) - return None \ No newline at end of file + return None \ No newline at end of file From 0b3efe5210d3e6c6638092c1354e9c85ddba84e1 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 26 Mar 2026 10:19:02 -0300 Subject: [PATCH 16/18] fix: improve next-plugin selection logic for scheduled refresh --- src/refresh_task.py | 48 ++++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src/refresh_task.py b/src/refresh_task.py index 78790685c..8a566a1a3 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -170,11 +170,17 @@ 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. + + Iterates every plugin starting from the one after the last refreshed index + (round-robin fairness) and returns the first plugin whose should_refresh() + returns True. 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 @@ -182,25 +188,27 @@ 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}") - return None, None - - # Select the next plugin in the playlist for display rotation. Whether its image - # needs to be regenerated vs using a cached image should be decided during - # the refresh execution phase, not here. - plugin = playlist.get_next_plugin() - if not plugin: - logger.info(f"Failed to obtain next plugin from playlist '{playlist.name}'.") - return None, None + # Scan every plugin in the playlist, starting after the last-refreshed + # index so that no single plugin starves the others. + num_plugins = len(playlist.plugins) + start_index = ((playlist.current_plugin_index or -1) + 1) % num_plugins + + for i in range(num_plugins): + idx = (start_index + i) % num_plugins + plugin = playlist.plugins[idx] + if plugin.should_refresh(current_dt): + playlist.current_plugin_index = idx + logger.info( + f"Plugin due for refresh. | active_playlist: {playlist.name} " + f"| plugin_instance: {plugin.name} | index: {idx}" + ) + return playlist, plugin - logger.info(f"Determined next plugin. | active_playlist: {playlist.name} | plugin_instance: {plugin.name}") - return playlist, plugin + logger.info( + f"No plugins due for refresh in playlist '{playlist.name}'. " + f"| checked: {num_plugins} plugin(s)" + ) + return None, None def log_system_stats(self): metrics = { From 86833cfd74da976407baa6a6ba0902c688d48a9b Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 26 Mar 2026 17:19:23 -0300 Subject: [PATCH 17/18] fix: improve plugin selection logic for scheduled and interval refreshes --- src/model.py | 54 +++++++++++++++++++++++++++++++++++++++++++++ src/refresh_task.py | 46 ++++++++++++++++++++++++++------------ 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/model.py b/src/model.py index 23e8e116a..700ce1dcb 100644 --- a/src/model.py +++ b/src/model.py @@ -375,6 +375,60 @@ def should_refresh(self, current_time): ) 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 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 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 8a566a1a3..1df70f785 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -172,10 +172,12 @@ def _get_current_datetime(self): def _determine_next_plugin(self, playlist_manager, latest_refresh_info, current_dt): """Determines the next plugin to refresh by scanning all plugins in the active playlist. - Iterates every plugin starting from the one after the last refreshed index - (round-robin fairness) and returns the first plugin whose should_refresh() - returns True. If no plugin is due, returns (None, None) so the display - is not updated. + 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: @@ -188,27 +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 - # Scan every plugin in the playlist, starting after the last-refreshed - # index so that no single plugin starves the others. + # 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): - playlist.current_plugin_index = idx - logger.info( - f"Plugin due for refresh. | active_playlist: {playlist.name} " - f"| plugin_instance: {plugin.name} | index: {idx}" + 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}" ) - return playlist, plugin + 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 + + # 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"No plugins due for refresh in playlist '{playlist.name}'. " - f"| checked: {num_plugins} plugin(s)" + 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 None, None + return playlist, selected_plugin def log_system_stats(self): metrics = { From cd6f3e391efe318eb7f09d777c849d5c09cce307 Mon Sep 17 00:00:00 2001 From: Saulo Benigno Date: Thu, 26 Mar 2026 17:23:50 -0300 Subject: [PATCH 18/18] fix: correct timezone handling in plugin instances --- src/model.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/model.py b/src/model.py index 700ce1dcb..c3b8fc4da 100644 --- a/src/model.py +++ b/src/model.py @@ -403,6 +403,8 @@ def get_due_datetime(self, current_time): 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) @@ -420,6 +422,8 @@ def get_due_datetime(self, current_time): 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)