Add .gitignore and eink-terminal.service; enhance virtual terminal with preview toggle and quadrant zoom

This commit is contained in:
KacperLa 2026-01-16 10:28:34 -05:00
parent 9005e49d82
commit 8880aa6b32
3 changed files with 270 additions and 21 deletions

134
.gitignore vendored Normal file
View file

@ -0,0 +1,134 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
Pipfile.lock
# PEP 582
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

20
eink-terminal.service Normal file
View file

@ -0,0 +1,20 @@
[Unit]
Description=E-Ink Virtual Terminal
After=bluetooth.target multi-user.target
Wants=bluetooth.target
[Service]
Type=simple
User=kacper
WorkingDirectory=/home/kacper/Documents/eink
ExecStart=/usr/bin/python3 /home/kacper/Documents/eink/virtual_terminal_eink.py --no-preview
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
# Allow access to GPIO/SPI and input devices
SupplementaryGroups=gpio spi input
[Install]
WantedBy=multi-user.target

View file

@ -68,7 +68,7 @@ DISPLAY_CONFIGS = {
} }
class VirtualTerminalEInk: class VirtualTerminalEInk:
def __init__(self, display_type='epd2in13_V4', use_display=True, zoom=1.0): def __init__(self, display_type='epd2in13_V4', use_display=True, zoom=1.0, show_preview=True):
""" """
Initialize the virtual terminal for e-ink display Initialize the virtual terminal for e-ink display
@ -76,10 +76,12 @@ class VirtualTerminalEInk:
display_type: Type of e-ink display (see DISPLAY_CONFIGS) display_type: Type of e-ink display (see DISPLAY_CONFIGS)
use_display: Whether to actually use the e-ink display (False for testing) use_display: Whether to actually use the e-ink display (False for testing)
zoom: Zoom multiplier for rendering (1.0 = normal, 2.0 = 2x larger text, etc.) zoom: Zoom multiplier for rendering (1.0 = normal, 2.0 = 2x larger text, etc.)
show_preview: Whether to show ASCII preview in console output
""" """
self.display_type = display_type self.display_type = display_type
self.use_display = use_display self.use_display = use_display
self.zoom = max(1.0, float(zoom)) # Ensure zoom is at least 1.0 self.zoom = max(1.0, float(zoom)) # Ensure zoom is at least 1.0
self.show_preview = show_preview
self.config = DISPLAY_CONFIGS.get(display_type) self.config = DISPLAY_CONFIGS.get(display_type)
# Serialize console output (preview redraw + status messages) so it doesn't # Serialize console output (preview redraw + status messages) so it doesn't
@ -118,6 +120,10 @@ class VirtualTerminalEInk:
self._update_timer = None self._update_timer = None
self._update_lock = threading.Lock() self._update_lock = threading.Lock()
# Quadrant zoom feature (F5-F8 keys)
self._quadrant_view = None # None, 'top_left', 'top_right', 'bottom_left', 'bottom_right'
self._quadrant_changed = False # Flag to force refresh when quadrant changes
# Initialize e-ink display # Initialize e-ink display
self.epd = None self.epd = None
if self.use_display: if self.use_display:
@ -464,8 +470,9 @@ class VirtualTerminalEInk:
def _do_update_display(self): def _do_update_display(self):
"""Internal: Actually perform the display update (called after debounce).""" """Internal: Actually perform the display update (called after debounce)."""
with self._console_lock: with self._console_lock:
# Display preview in console # Display preview in console (if enabled)
self.display_preview() if self.show_preview:
self.display_preview()
# Update e-ink display (silence driver prints to avoid tearing the preview) # Update e-ink display (silence driver prints to avoid tearing the preview)
if self.use_display and self.epd: if self.use_display and self.epd:
@ -479,6 +486,12 @@ class VirtualTerminalEInk:
curr_cursor, curr_cursor,
) )
# Force update if quadrant view changed (even if screen didn't change)
if self._quadrant_changed:
self._safe_print(f"[DEBUG] Forcing display update due to quadrant view change")
dirty_cells = (0, 0, self.cols - 1, self.rows - 1) # Force full refresh
self._quadrant_changed = False # Reset flag
# Nothing changed -> skip panel update. # Nothing changed -> skip panel update.
if dirty_cells is None: if dirty_cells is None:
self._last_snapshot = curr_snapshot self._last_snapshot = curr_snapshot
@ -493,7 +506,27 @@ class VirtualTerminalEInk:
image = self.render_to_image() image = self.render_to_image()
# Apply zoom if needed # Apply quadrant view if active (before zoom)
if self._quadrant_view is not None:
self._safe_print(f"[DEBUG] Applying quadrant view: {self._quadrant_view}")
# Crop to the selected quadrant
half_width = image.width // 2
half_height = image.height // 2
if self._quadrant_view == 'top_left':
image = image.crop((0, 0, half_width, half_height))
elif self._quadrant_view == 'top_right':
image = image.crop((half_width, 0, image.width, half_height))
elif self._quadrant_view == 'bottom_left':
image = image.crop((0, half_height, half_width, image.height))
elif self._quadrant_view == 'bottom_right':
image = image.crop((half_width, half_height, image.width, image.height))
self._safe_print(f"[DEBUG] Cropped to size: {image.size}, scaling to display: {self.config['width']}x{self.config['height']}")
# Scale the quadrant to full display size
image = image.resize((self.config['width'], self.config['height']), Image.NEAREST)
# Apply zoom if needed (independent of quadrant view)
if self.zoom > 1.0: if self.zoom > 1.0:
new_width = int(image.width * self.zoom) new_width = int(image.width * self.zoom)
new_height = int(image.height * self.zoom) new_height = int(image.height * self.zoom)
@ -586,7 +619,7 @@ class VirtualTerminalEInk:
# Proper QWERTY keyboard layout mapping based on actual evdev keycodes # Proper QWERTY keyboard layout mapping based on actual evdev keycodes
key_map = { key_map = {
# Special keys # Special keys
ecodes.KEY_ENTER: '\n', ecodes.KEY_ENTER: '\r', # Send carriage return (terminal driver handles conversion)
ecodes.KEY_TAB: '\t', ecodes.KEY_TAB: '\t',
ecodes.KEY_SPACE: ' ', ecodes.KEY_SPACE: ' ',
ecodes.KEY_BACKSPACE: '\x7f', ecodes.KEY_BACKSPACE: '\x7f',
@ -603,6 +636,18 @@ class VirtualTerminalEInk:
ecodes.KEY_DOT: '.', ecodes.KEY_DOT: '.',
ecodes.KEY_SLASH: '/', ecodes.KEY_SLASH: '/',
# Arrow keys (ANSI escape sequences for terminal navigation)
ecodes.KEY_UP: '\x1b[A',
ecodes.KEY_DOWN: '\x1b[B',
ecodes.KEY_RIGHT: '\x1b[C',
ecodes.KEY_LEFT: '\x1b[D',
# Function keys (for quadrant zoom)
ecodes.KEY_F5: '<F5>',
ecodes.KEY_F6: '<F6>',
ecodes.KEY_F7: '<F7>',
ecodes.KEY_F8: '<F8>',
# Number row (unshifted) # Number row (unshifted)
ecodes.KEY_1: '1', ecodes.KEY_2: '2', ecodes.KEY_3: '3', ecodes.KEY_1: '1', ecodes.KEY_2: '2', ecodes.KEY_3: '3',
ecodes.KEY_4: '4', ecodes.KEY_5: '5', ecodes.KEY_6: '6', ecodes.KEY_4: '4', ecodes.KEY_5: '5', ecodes.KEY_6: '6',
@ -662,6 +707,11 @@ class VirtualTerminalEInk:
if event.type == ecodes.EV_KEY: if event.type == ecodes.EV_KEY:
key_event = categorize(event) key_event = categorize(event)
# Debug: show all key events
if event.value == 1: # Key press
key_name = ecodes.KEY[event.code] if event.code in ecodes.KEY else f"UNKNOWN({event.code})"
self._safe_print(f"[DEBUG] Key pressed: {key_name} (code: {event.code})")
# Track modifier keys # Track modifier keys
if event.code in (ecodes.KEY_LEFTSHIFT, ecodes.KEY_RIGHTSHIFT): if event.code in (ecodes.KEY_LEFTSHIFT, ecodes.KEY_RIGHTSHIFT):
shift_pressed = (event.value == 1) shift_pressed = (event.value == 1)
@ -674,32 +724,73 @@ class VirtualTerminalEInk:
if event.value != 1: if event.value != 1:
continue continue
# Handle Ctrl+D to exit # Handle Ctrl+letter combinations
if ctrl_pressed and event.code == ecodes.KEY_D: if ctrl_pressed:
self.stream.feed("\r\n[Exiting...]\r\n") # Map letter keys to Ctrl codes (Ctrl+A = 1, Ctrl+B = 2, ..., Ctrl+Z = 26)
self.update_display() ctrl_letter_map = {
self.running = False ecodes.KEY_A: 1, ecodes.KEY_B: 2, ecodes.KEY_C: 3, ecodes.KEY_D: 4,
break ecodes.KEY_E: 5, ecodes.KEY_F: 6, ecodes.KEY_G: 7, ecodes.KEY_H: 8,
ecodes.KEY_I: 9, ecodes.KEY_J: 10, ecodes.KEY_K: 11, ecodes.KEY_L: 12,
ecodes.KEY_M: 13, ecodes.KEY_N: 14, ecodes.KEY_O: 15, ecodes.KEY_P: 16,
ecodes.KEY_Q: 17, ecodes.KEY_R: 18, ecodes.KEY_S: 19, ecodes.KEY_T: 20,
ecodes.KEY_U: 21, ecodes.KEY_V: 22, ecodes.KEY_W: 23, ecodes.KEY_X: 24,
ecodes.KEY_Y: 25, ecodes.KEY_Z: 26,
}
if event.code in ctrl_letter_map:
ctrl_char = chr(ctrl_letter_map[event.code])
os.write(self.master_fd, ctrl_char.encode())
# Ctrl+D exits the terminal
if event.code == ecodes.KEY_D:
self.stream.feed("\r\n[Exiting...]\r\n")
self.update_display()
self.running = False
break
continue
# Handle Ctrl+C # Handle F5-F8 for quadrant zoom
if ctrl_pressed and event.code == ecodes.KEY_C: if event.code == ecodes.KEY_F5:
os.write(self.master_fd, b'\x03') self._safe_print(f"[DEBUG] F5 pressed - zooming to top-left quadrant")
self._quadrant_view = 'top_left'
self._quadrant_changed = True
self.update_display()
continue
elif event.code == ecodes.KEY_F6:
self._safe_print(f"[DEBUG] F6 pressed - zooming to top-right quadrant")
self._quadrant_view = 'top_right'
self._quadrant_changed = True
self.update_display()
continue
elif event.code == ecodes.KEY_F7:
self._safe_print(f"[DEBUG] F7 pressed - zooming to bottom-left quadrant")
self._quadrant_view = 'bottom_left'
self._quadrant_changed = True
self.update_display()
continue
elif event.code == ecodes.KEY_F8:
self._safe_print(f"[DEBUG] F8 pressed - zooming to bottom-right quadrant")
self._quadrant_view = 'bottom_right'
self._quadrant_changed = True
self.update_display()
continue continue
# Map key to character # Map key to character
char = key_map.get(event.code) char = key_map.get(event.code)
if char: if char:
# Apply shift modifier # Reset quadrant view on any normal keypress
if shift_pressed: if self._quadrant_view is not None:
self._safe_print(f"[DEBUG] Resetting quadrant view to full screen")
self._quadrant_view = None
self._quadrant_changed = True
self.update_display()
# Apply shift modifier (ctrl is handled separately above)
if shift_pressed and not ctrl_pressed:
if char.isalpha(): if char.isalpha():
char = char.upper() char = char.upper()
elif char in shift_map: elif char in shift_map:
char = shift_map[char] char = shift_map[char]
# Apply ctrl modifier (for letters only)
if ctrl_pressed and char.isalpha():
char = chr(ord(char.upper()) - ord('A') + 1)
# Send to PTY # Send to PTY
os.write(self.master_fd, char.encode()) os.write(self.master_fd, char.encode())
@ -833,6 +924,9 @@ def main():
type=float, type=float,
default=1.0, default=1.0,
help='Zoom multiplier for text size (e.g., 2.0 for 2x larger text)') help='Zoom multiplier for text size (e.g., 2.0 for 2x larger text)')
parser.add_argument('-p', '--no-preview',
action='store_true',
help='Disable ASCII preview in console output (useful for service mode)')
args = parser.parse_args() args = parser.parse_args()
@ -840,7 +934,8 @@ def main():
vt = VirtualTerminalEInk( vt = VirtualTerminalEInk(
display_type=args.display, display_type=args.display,
use_display=not args.no_display, use_display=not args.no_display,
zoom=args.zoom zoom=args.zoom,
show_preview=not args.no_preview
) )
if args.command: if args.command: