Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
be6ea7d
Fix message extraction and keyboard input handling
halfmanbear Nov 25, 2025
e493565
Improve error handling in display.py
halfmanbear Nov 25, 2025
b1e7227
Refactor signal handling and logging setup
halfmanbear Nov 25, 2025
4ae362e
Fix import statement and update zip function usage
halfmanbear Nov 25, 2025
acaa7ed
Refactor current_page assignment to use direct attribute
halfmanbear Nov 25, 2025
cdda38d
Refactor dynamic imports to avoid circular imports
halfmanbear Nov 25, 2025
67b3fb6
Refactor import statements for Elegoo classes
halfmanbear Nov 25, 2025
63e2e15
Refactor import of ElegooDisplayMapper in elegoo_custom.py
halfmanbear Nov 25, 2025
ae41684
Comment out import of ElegooDisplayCommunicator
halfmanbear Nov 25, 2025
c30a101
Disable dynamic imports in ElegooNeptune3 classes
halfmanbear Nov 25, 2025
a23535b
Improve error handling in connection methods
halfmanbear Nov 25, 2025
4c0ee18
Remove unused import statement
halfmanbear Nov 25, 2025
e82cd82
Refactor DisplayCommunicator tests with fixtures
halfmanbear Nov 25, 2025
fa00084
Refactor _extract_fixed_length_packet method
halfmanbear Nov 25, 2025
2fb673e
Refactor _extract_fixed_length_packet method
halfmanbear Nov 25, 2025
70c542e
Refactor data_received_number test case
halfmanbear Nov 25, 2025
2750ca9
Improve signal handler for graceful shutdown
halfmanbear Nov 25, 2025
245bc93
Fix position_max checks for stepper_y and stepper_z
halfmanbear Nov 25, 2025
57b458a
Refactor write method and command execution
halfmanbear Nov 25, 2025
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
47 changes: 27 additions & 20 deletions display.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import sys
import logging
import pathlib
import requests
import re
import os
import os.path
Expand All @@ -14,23 +13,6 @@
import signal
import systemd.daemon

# Global flag for graceful shutdown
_shutdown_requested = False

def signal_handler(signum, frame):
global _shutdown_requested
_shutdown_requested = True
log = logging.getLogger(__name__)
log.info(f"Received signal {signum}, initiating graceful shutdown...")
try:
loop.call_soon_threadsafe(loop.stop)
except Exception:
pass

# Register signal handlers
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)

from PIL import Image

from src.config import TEMP_DEFAULTS, ConfigHandler
Expand Down Expand Up @@ -82,6 +64,29 @@ def signal_handler(signum, frame):
)
from src.colors import BACKGROUND_SUCCESS, BACKGROUND_WARNING

# Global flag for graceful shutdown
_shutdown_requested = False

# Create module-level logger and placeholder for event loop
logger = logging.getLogger(__name__)
loop = None

def signal_handler(signum, frame):
global _shutdown_requested, loop
_shutdown_requested = True
logger.info("Received signal %s, initiating graceful shutdown...", signum)
try:
# Only attempt to stop the loop if it exists and is running.
if loop is not None and loop.is_running():
loop.call_soon_threadsafe(loop.stop)
except Exception:
# Log the error instead of swallowing it silently
logger.exception("Unexpected error in signal_handler")

# Register signal handlers
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)

log_file = os.path.expanduser("~/printer_data/logs/display_connector.log")
logger = logging.getLogger(__name__)
ch_log = logging.StreamHandler(sys.stdout)
Expand Down Expand Up @@ -1260,6 +1265,7 @@ async def _send_moonraker_request(self, method, params=None):
self.pending_reqs.pop(message["id"], None)
raise
except Exception:
logger.exception("Unexpected error _send_moonraker_request")
await self.close()
raise

Expand Down Expand Up @@ -1472,10 +1478,10 @@ def safe_int_convert(value, default=0):
if "position_max" in new_data["config"]["stepper_x"]:
max_x = int(float(new_data["config"]["stepper_x"]["position_max"])) #added conversion from float numbers, stripping decimal part
if "stepper_y" in new_data["config"]:
if "position_max" in new_data["config"]["stepper_x"]:
if "position_max" in new_data["config"]["stepper_y"]:
max_y = int(float(new_data["config"]["stepper_y"]["position_max"])) #added conversion from float numbers, stripping decimal part
if "stepper_z" in new_data["config"]:
if "position_max" in new_data["config"]["stepper_x"]:
if "position_max" in new_data["config"]["stepper_z"]:
max_z = int(float(new_data["config"]["stepper_z"]["position_max"])) #added conversion from float numbers, stripping decimal part

if max_x > 0 and max_y > 0 and max_z > 0:
Expand Down Expand Up @@ -1732,6 +1738,7 @@ async def fetch_and_parse_thumbnail(self, path):
try:
thumbnail.close()
except Exception:
logger.exception("Unexpected error fetch_and_parse_thumbnail")
pass

async def handle_status_update(self, new_data, data_mapping=None):
Expand Down
61 changes: 37 additions & 24 deletions src/communicator.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,8 @@ async def connect(self):
self.logger.error(f"Failed to connect to display: {str(e)}")
raise

async def write(self, data, timeout=None, blocked_key=None):
# Fast path: decide blocking under lock
async with self._write_lock:
# If someone else is blocking, queue this command
if self.blocked_by and self.blocked_by != blocked_key:
self.blocked_buffer.append((data, timeout))
return

# Claim the block if caller provided a key and none is set
if blocked_key and not self.blocked_by:
self.blocked_by = blocked_key

# Execute the command outside the lock
async def _execute_command(self, data, timeout=None):
"""Execute a display command directly without queueing logic."""
try:
effective_timeout = self.timeout if timeout is None else timeout
await asyncio.wait_for(
Expand All @@ -70,7 +59,6 @@ async def write(self, data, timeout=None, blocked_key=None):
await self.display.reconnect()
except Exception as e:
self.logger.warning(f"Display reconnect failed after timeout: {e}")
return
except CommandFailed as e:
# Expected if we target a control not present on the current page
self.logger.debug(
Expand All @@ -79,24 +67,49 @@ async def write(self, data, timeout=None, blocked_key=None):
except Exception as e:
# Any other error: log and continue
self.logger.warning(f"Unexpected error writing to display: {e}")

async def write(self, data, timeout=None, blocked_key=None):
# Fast path: decide blocking under lock
async with self._write_lock:
# If someone else is blocking, queue this command
if self.blocked_by and self.blocked_by != blocked_key:
self.blocked_buffer.append((data, timeout))
return

# Claim the block if caller provided a key and none is set
if blocked_key and not self.blocked_by:
self.blocked_by = blocked_key

# Execute the command outside the lock
try:
await self._execute_command(data, timeout)
finally:
# If this was a blocking op, release block and send the next queued command (if any)
if blocked_key:
await self.unblock(blocked_key)

async def unblock(self, blocked_key):
next_item = None
# Release the block if we're the current blocker
async with self._write_lock:
if self.blocked_by == blocked_key:
self.blocked_by = None
if self.blocked_buffer:
# pop the next queued command (FIFO)
if self.blocked_by != blocked_key:
return
self.blocked_by = None

# Process all queued commands iteratively (avoids recursion)
while True:
next_item = None
async with self._write_lock:
# Only process queue if no one else has claimed the block
if not self.blocked_by and self.blocked_buffer:
next_item = self.blocked_buffer.pop(0)

# Send the next queued command outside the lock to avoid deadlocks
if next_item is not None and self.blocked_by is None:
data, timeout = next_item
await self.write(data, timeout=timeout)
else:
break

if next_item:
data, timeout = next_item
await self._execute_command(data, timeout)
else:
break

def get_device_name(self):
return self.model
Expand Down
2 changes: 1 addition & 1 deletion src/elegoo_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def __init__(
timeout: int = 5,
) -> None:
# Dynamically import ElegooDisplayCommunicator and ElegooDisplayMapper to avoid circular import
ElegooDisplayCommunicator = importlib.import_module('src.elegoo_display').ElegooDisplayCommunicator
#ElegooDisplayCommunicator = importlib.import_module('src.elegoo_display').ElegooDisplayCommunicator
ElegooDisplayMapper = importlib.import_module('src.elegoo_display').ElegooDisplayMapper

super().__init__(logger, model, port, event_handler, baudrate, timeout)
Expand Down
13 changes: 8 additions & 5 deletions src/elegoo_neptune3.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import importlib
from logging import Logger

MODEL_N3_REGULAR = "N3"
Expand All @@ -8,12 +7,14 @@

MODELS_N3 = [MODEL_N3_REGULAR, MODEL_N3_PRO, MODEL_N3_PLUS, MODEL_N3_MAX]


class ElegooNeptune3DisplayMapper:
def __init__(self):
# Dynamically import ElegooDisplayMapper to avoid circular import
ElegooDisplayMapper = importlib.import_module('src.elegoo_display').ElegooDisplayMapper
#ElegooDisplayMapper = importlib.import_module("src.elegoo_display").ElegooDisplayMapper
super().__init__()


class ElegooNeptune3DisplayCommunicator:
def __init__(
self,
Expand All @@ -25,16 +26,18 @@ def __init__(
timeout: int = 5,
) -> None:
# Dynamically import ElegooDisplayCommunicator to avoid circular import
ElegooDisplayCommunicator = importlib.import_module('src.elegoo_display').ElegooDisplayCommunicator
#ElegooDisplayCommunicator = importlib.import_module("src.elegoo_display").ElegooDisplayCommunicator
super().__init__(logger, model, port, event_handler, baudrate, timeout)
self.mapper = ElegooNeptune3DisplayMapper()


class OpenNeptune3DisplayMapper:
def __init__(self):
# Dynamically import OpenNeptuneDisplayMapper to avoid circular import
OpenNeptuneDisplayMapper = importlib.import_module('src.openneptune_display').OpenNeptuneDisplayMapper
#OpenNeptuneDisplayMapper = importlib.import_module("src.openneptune_display").OpenNeptuneDisplayMapper
super().__init__()


class OpenNeptune3DisplayCommunicator:
def __init__(
self,
Expand All @@ -46,6 +49,6 @@ def __init__(
timeout: int = 5,
) -> None:
# Dynamically import OpenNeptuneDisplayCommunicator to avoid circular import
OpenNeptuneDisplayCommunicator = importlib.import_module('src.openneptune_display').OpenNeptuneDisplayCommunicator
#OpenNeptuneDisplayCommunicator = importlib.import_module("src.openneptune_display").OpenNeptuneDisplayCommunicator
super().__init__(logger, model, port, event_handler, baudrate, timeout)
self.mapper = ElegooNeptune3DisplayMapper()
4 changes: 2 additions & 2 deletions src/lib_col_pic.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# The ElegooNeptuneThumbnails plugin is released under the terms of the AGPLv3 or higher.
from threading import Lock
import numpy as np
from PIL import Image, ImageColor
from PIL import ImageColor

thumbnail_lock = Lock()

Expand Down Expand Up @@ -81,7 +81,7 @@ def ColPicEncode(fromcolor16, picw, pich, outputdata: bytearray, outputmaxtsize,
unique_colors, counts = np.unique(fromcolor16, return_counts=True)
Listu16 = np.array([U16HEAD() for _ in range(len(unique_colors))])

for i, (color, qty) in enumerate(zip(unique_colors, counts)):
for i, (color, qty) in enumerate(zip(unique_colors, counts, strict=True)):
Listu16[i].colo16 = color
Listu16[i].qty = qty
Listu16[i].A0 = (color >> 11) & 31
Expand Down
27 changes: 16 additions & 11 deletions src/tjc.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,12 @@ def _extract_fixed_length_packet(self, expected_length):
self.buffer = self.buffer[expected_length + 1:]
return full_message[:-3], was_keyboard_input

message = self._extract_varied_length_packet()
if message is None:
return None, was_keyboard_input
msg, kb_from_var = self._extract_varied_length_packet()
if msg is None:
# propagate any keyboard flag we might have gotten
return None, was_keyboard_input or kb_from_var

self.dropped_buffer += message + self.EOL
self.dropped_buffer += msg + self.EOL
return self._extract_packet()

self.buffer = self.buffer[expected_length:]
Expand Down Expand Up @@ -168,27 +169,31 @@ async def reconnect(self):
try:
try:
await self._connection.close()
except Exception:
pass
except Exception as exc:
# Non-fatal: log and continue trying to reconnect
self.logger.debug("Error while closing connection during reconnect: %r", exc)
await self.connect()
finally:
# connect() will clear this when it succeeds; make sure we don't get stuck
self.is_reconnecting = False


async def connect(self) -> None:
"""Connect to the device with a quick, safe re-init."""
try:
await self._try_connect_on_different_baudrates()
for method in ("reset_input_buffer", "reset_output_buffer", "flush"):
try:
fn = getattr(self._connection, method)
fn = getattr(self._connection, method, None)
if callable(fn):
r = fn()
if asyncio.iscoroutine(r):
await r
except Exception:
pass
except Exception as exc:
self.logger.debug(
"Error while calling %s() on connection during setup: %r",
method,
exc,
)
# Make panel return detailed acks if it's awake
try:
await self._command("bkcmd=3", attempts=1)
Expand All @@ -203,7 +208,7 @@ async def connect(self) -> None:
try:
await self._command("page main", attempts=1)
# if you track this, keep it in sync
setattr(self, "current_page", "main")
self.current_page = "main"
await asyncio.sleep(0.25) # small settle so HMI swaps pages
except CommandTimeout:
pass
Expand Down
40 changes: 21 additions & 19 deletions tests/communicator_test.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
import logging
from unittest.mock import AsyncMock
import pytest

from src.communicator import DisplayCommunicator


@pytest.mark.asyncio
async def test_valid_version():
communicator = DisplayCommunicator(logging, None, None, None)
communicator.get_firmware_version = AsyncMock(return_value="1.0")
communicator.supported_firmware_versions = ["1.0"]
is_valid = await communicator.check_valid_version()
communicator.get_firmware_version.assert_awaited_once()
assert is_valid
@pytest.fixture
def communicator():
# event_handler can be None for these unit tests, since we patch out I/O
return DisplayCommunicator(logging, "N3", "/dev/ttyS0", None)


def test_get_device_name(communicator):
# Simple sanity check: constructor stores the model name
assert communicator.get_device_name() == "N3"


def test_get_display_type_name(communicator):
# Should just be the class name
assert communicator.get_display_type_name() == "DisplayCommunicator"

@pytest.mark.asyncio
async def test_invalid_version():
communicator = DisplayCommunicator(logging, None, None, None)
communicator.get_firmware_version = AsyncMock(return_value="1.0")
communicator.supported_firmware_versions = ["2.0"]
is_valid = await communicator.check_valid_version()
communicator.get_firmware_version.assert_awaited_once()
assert not is_valid

@pytest.mark.asyncio
async def test_navigate():
communicator = DisplayCommunicator(logging, None, None, None)
async def test_navigate(communicator):
# We don't want real I/O here, so patch write
communicator.write = AsyncMock()

await communicator.navigate_to("1")
communicator.write.assert_awaited_once_with("page 1")

# navigate_to blocks other writes with a blocked_key="__nav__"
communicator.write.assert_awaited_once_with("page 1", blocked_key="__nav__")
Loading