Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions library/lcd/lcd_comm.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ class LcdComm(ABC):
def __init__(self, com_port: str = "AUTO", display_width: int = 320, display_height: int = 480,
update_queue: Optional[queue.Queue] = None):
self.lcd_serial = None
# Count swallowed serial write errors: post-resume scene repair may
# need to resend orientation/background after individual lost writes.
self.serial_write_failures = 0

# String containing absolute path to serial port e.g. "COM3", "/dev/ttyACM1" or "AUTO" for auto-discovery
# Ignored for USB HID screens
Expand Down Expand Up @@ -184,8 +187,10 @@ def WriteLine(self, line: bytes):
self.lcd_serial.flush()
except serial.SerialTimeoutException:
# We timed-out trying to write to our device, slow things down.
self.serial_write_failures += 1
logger.warning("(Write line) Too fast! Slow down!")
except serial.SerialException:
self.serial_write_failures += 1
# Error writing data to device: close and reopen serial port, try to write again
logger.error(
"SerialException: Failed to send serial data to device. Closing and reopening COM port before retrying once.")
Expand Down
83 changes: 83 additions & 0 deletions library/resume_recovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Limited post-resume scene repair after serial write failures.

The Windows resume handler queues the normal wake/orientation/static redraw.
If the serial writer reports failures after these commands were queued,
retry the whole scene when the physical port is open, the queue is drained,
and writes have stayed quiet. No device reset, COM selection or extra threads.
"""
import time


class ResumeRedrawWatch:
def __init__(self, clock=None, quiet_seconds=2.0, window_seconds=90.0,
max_redraws=3):
self.clock = clock or time.monotonic
self.quiet_seconds = quiet_seconds
self.window_seconds = window_seconds
self.max_redraws = max_redraws
self.active = False

def arm(self, serial_write_failures):
"""Start observing immediately before queuing the initial resume redraw."""
self.active = True
self.started = self.clock()
self.last_fault_time = None
self.last_fault_count = serial_write_failures
self.repair_pending = False
self.repairs = 0

def poll(self, serial_write_failures, ready, redraw, logger):
"""Run from the existing Windows message loop, without sleeping.

ready means serial is open and queued output has drained. This cannot
verify LCD firmware acknowledgement: log the repair as queued only.
"""
if not self.active:
return False
now = self.clock()
if serial_write_failures > self.last_fault_count:
self.last_fault_count = serial_write_failures
self.last_fault_time = now
if not self.repair_pending:
logger.warning(
"Resume serial writes failed; waiting for stable COM and "
"empty output queue before restoring orientation/background")
self.repair_pending = True

if now - self.started >= self.window_seconds:
if self.repair_pending:
logger.warning(
"Resume scene repair expired; no stable serial window "
"within %.0fs (attempts=%d)", self.window_seconds, self.repairs)
self.active = False
return False

if not self.repair_pending:
return False
if self.repairs >= self.max_redraws:
logger.warning(
"Resume scene repair stopped after %d retries; check COM/USB log",
self.repairs)
self.active = False
return False
if not ready or now - self.last_fault_time < self.quiet_seconds:
return False

self.repairs += 1
# Queue the full scene again, in the same order as normal resume:
# ScreenOn, brightness, orientation, background, static text.
try:
redraw()
except Exception:
logger.exception(
"Resume scene repair could not queue redraw (attempt %d)",
self.repairs)
self.last_fault_time = now
return False

self.repair_pending = False
logger.info(
"Resume scene repair queued after serial errors (attempt %d/%d)",
self.repairs, self.max_redraws)
return True
31 changes: 27 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@

from library.log import logger
import library.scheduler as scheduler
from library import config
from library.display import display
from library.resume_recovery import ResumeRedrawWatch

except Exception as e:
print("""Import error: %s
Expand All @@ -77,6 +79,12 @@
locale.setlocale(locale.LC_ALL, '')

logger.debug("Using Python %s" % sys.version)
resume_redraw_watch = ResumeRedrawWatch()

def restore_display_scene():
display.turn_on()
display.display_static_images()
display.display_static_text()


def wait_for_empty_queue(timeout: int = 5):
Expand Down Expand Up @@ -164,13 +172,16 @@ def on_win32_wm_event(hWnd, msg, wParam, lParam):
# Windows may resume the process before the USB/serial endpoint is fully
# ready. Also allow queued pre-suspend writes to finish before restoring
# display state and redrawing the theme.
# Arm before the initial redraw; a partial serial write may
# fail while USB is still re-enumerating after hibernation.
if (config.CONFIG_DATA["display"]["REVISION"] == "A" and
hasattr(display.lcd, "serial_write_failures")):
resume_redraw_watch.arm(display.lcd.serial_write_failures)
logger.info("Waiting 1.0s for USB display to settle after resume")
time.sleep(1.0)
wait_for_empty_queue(2)
display.turn_on()
# Some models have troubles displaying back the previous bitmap after being turned off/on
display.display_static_images()
display.display_static_text()
# Some models have troubles displaying back the previous bitmap after being turned off/on.
restore_display_scene()
logger.info("Resume redraw queued")
else:
# For any other events, the program will stop
Expand Down Expand Up @@ -326,6 +337,18 @@ def on_win32_wm_event(hWnd, msg, wParam, lParam):
while True:
# Receive and dispatch window messages
win32gui.PumpWaitingMessages()
# If a post-resume serial write was lost, retry the entire
# orientation/static scene once USB and the output queue settle.
# Runs in the existing message loop; no extra USB resets.
if resume_redraw_watch.active:
lcd = display.lcd
serial_port = getattr(lcd, "lcd_serial", None)
ready = (serial_port is not None and
bool(getattr(serial_port, "is_open", False)) and
scheduler.is_queue_empty())
resume_redraw_watch.poll(
getattr(lcd, "serial_write_failures", 0), ready,
restore_display_scene, logger)
time.sleep(0.5)

except Exception as e:
Expand Down
130 changes: 130 additions & 0 deletions tests/library/test_resume_scene_recovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""A bad post-resume serial write must lead to a bounded full scene replay."""
import unittest
from unittest.mock import Mock, patch

import serial

from library.lcd.lcd_comm import LcdComm
from library.resume_recovery import ResumeRedrawWatch


class ResumeSceneRecoveryTests(unittest.TestCase):
def setUp(self):
self.now = [10.0]
self.log = Mock()
self.redraw = Mock()
self.watch = ResumeRedrawWatch(clock=lambda: self.now[0],
quiet_seconds=2, window_seconds=40,
max_redraws=3)
self.watch.arm(0)

def tick(self, failures=0, ready=True):
return self.watch.poll(failures, ready, self.redraw, self.log)

def test_clean_resume_does_not_redraw_twice(self):
self.now[0] = 35
self.assertFalse(self.tick())
self.redraw.assert_not_called()

def test_timeout_requires_quiet_and_drained_queue(self):
self.now[0] = 11
self.assertFalse(self.tick(17, False))
self.now[0] = 12.9
self.assertFalse(self.tick(17, True))
self.now[0] = 13
self.assertFalse(self.tick(17, False))
self.assertTrue(self.tick(17, True))
self.redraw.assert_called_once_with()
self.assertFalse(self.tick(17, True))
self.assertEqual(self.log.warning.call_count, 1)

def test_more_timeouts_delay_replay(self):
self.now[0] = 11
self.tick(2)
self.now[0] = 12.5
self.tick(257)
self.now[0] = 14
self.assertFalse(self.tick(257))
self.now[0] = 14.5
self.assertTrue(self.tick(257))
self.assertEqual(self.redraw.call_count, 1)

def test_new_failures_after_replay_retry_but_cap(self):
count = 0
for i in range(3):
self.now[0] += 1
count += 1
self.tick(count)
self.now[0] += 2
self.assertTrue(self.tick(count))
self.assertEqual(self.redraw.call_count, 3)
self.now[0] += 1
self.assertFalse(self.tick(count + 1))
self.assertFalse(self.watch.active)

def test_repair_attempt_expires_if_usb_never_ready(self):
self.now[0] = 11
self.tick(1, False)
self.now[0] = 51
self.assertFalse(self.tick(1, False))
self.assertFalse(self.watch.active)
self.redraw.assert_not_called()
self.log.warning.assert_any_call(
"Resume scene repair expired; no stable serial window "
"within %.0fs (attempts=%d)", 40, 0)

def test_callback_exception_is_logged_and_retried(self):
self.redraw.side_effect = [RuntimeError("render error"), None]
self.now[0] = 11
self.tick(1)
self.now[0] = 13
self.assertFalse(self.tick(1))
self.log.exception.assert_called_once()
self.now[0] = 15
self.assertTrue(self.tick(1))
self.assertEqual(self.redraw.call_count, 2)

def test_rearm_after_second_resume(self):
self.now[0] = 11
self.tick(1)
self.now[0] = 13
self.tick(1)
self.watch.arm(1)
self.now[0] = 16
self.assertFalse(self.tick(1))
self.redraw.assert_called_once_with()


class WriteFaultDetectionTests(unittest.TestCase):
@staticmethod
def probe(write):
class FakeDisplay:
serial_write_failures = 0
WriteLine = LcdComm.WriteLine
serial_write = write
closeSerial = Mock()
openSerial = Mock()
return FakeDisplay()

def test_serial_timeout_is_counted_even_if_swallowed(self):
probe = self.probe(Mock(side_effect=serial.SerialTimeoutException("slow")))
with patch("library.lcd.lcd_comm.logger") as logger:
probe.WriteLine(b"abcdef")
self.assertEqual(probe.serial_write_failures, 1)
logger.warning.assert_called_once()

def test_serial_disconnect_is_counted_and_retry_is_preserved(self):
writer = Mock(side_effect=[serial.SerialException("USB disappeared"), None])
probe = self.probe(writer)
with patch("library.lcd.lcd_comm.time.sleep"), \
patch("library.lcd.lcd_comm.logger"):
probe.WriteLine(b"abcdef")
self.assertEqual(probe.serial_write_failures, 1)
probe.closeSerial.assert_called_once()
probe.openSerial.assert_called_once()
self.assertEqual(writer.call_count, 2)


if __name__ == "__main__":
unittest.main()