diff --git a/library/lcd/lcd_comm.py b/library/lcd/lcd_comm.py index 67c04844..f0ff2ca2 100644 --- a/library/lcd/lcd_comm.py +++ b/library/lcd/lcd_comm.py @@ -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 @@ -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.") diff --git a/library/resume_recovery.py b/library/resume_recovery.py new file mode 100644 index 00000000..cd409d20 --- /dev/null +++ b/library/resume_recovery.py @@ -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 diff --git a/main.py b/main.py index 9c274658..1cd7ef16 100755 --- a/main.py +++ b/main.py @@ -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 @@ -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): @@ -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 @@ -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: diff --git a/tests/library/test_resume_scene_recovery.py b/tests/library/test_resume_scene_recovery.py new file mode 100644 index 00000000..f705954e --- /dev/null +++ b/tests/library/test_resume_scene_recovery.py @@ -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()