diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6a7023 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/eink-terminal.service b/eink-terminal.service new file mode 100644 index 0000000..74130a6 --- /dev/null +++ b/eink-terminal.service @@ -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 diff --git a/virtual_terminal_eink.py b/virtual_terminal_eink.py index eda6e2e..fd22220 100755 --- a/virtual_terminal_eink.py +++ b/virtual_terminal_eink.py @@ -68,7 +68,7 @@ DISPLAY_CONFIGS = { } 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 @@ -76,10 +76,12 @@ class VirtualTerminalEInk: display_type: Type of e-ink display (see DISPLAY_CONFIGS) 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.) + show_preview: Whether to show ASCII preview in console output """ self.display_type = display_type self.use_display = use_display 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) # Serialize console output (preview redraw + status messages) so it doesn't @@ -118,6 +120,10 @@ class VirtualTerminalEInk: self._update_timer = None 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 self.epd = None if self.use_display: @@ -464,8 +470,9 @@ class VirtualTerminalEInk: def _do_update_display(self): """Internal: Actually perform the display update (called after debounce).""" with self._console_lock: - # Display preview in console - self.display_preview() + # Display preview in console (if enabled) + if self.show_preview: + self.display_preview() # Update e-ink display (silence driver prints to avoid tearing the preview) if self.use_display and self.epd: @@ -479,6 +486,12 @@ class VirtualTerminalEInk: 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. if dirty_cells is None: self._last_snapshot = curr_snapshot @@ -493,7 +506,27 @@ class VirtualTerminalEInk: 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: new_width = int(image.width * 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 key_map = { # Special keys - ecodes.KEY_ENTER: '\n', + ecodes.KEY_ENTER: '\r', # Send carriage return (terminal driver handles conversion) ecodes.KEY_TAB: '\t', ecodes.KEY_SPACE: ' ', ecodes.KEY_BACKSPACE: '\x7f', @@ -603,6 +636,18 @@ class VirtualTerminalEInk: ecodes.KEY_DOT: '.', 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: '', + ecodes.KEY_F6: '', + ecodes.KEY_F7: '', + ecodes.KEY_F8: '', + # Number row (unshifted) ecodes.KEY_1: '1', ecodes.KEY_2: '2', ecodes.KEY_3: '3', ecodes.KEY_4: '4', ecodes.KEY_5: '5', ecodes.KEY_6: '6', @@ -662,6 +707,11 @@ class VirtualTerminalEInk: if event.type == ecodes.EV_KEY: 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 if event.code in (ecodes.KEY_LEFTSHIFT, ecodes.KEY_RIGHTSHIFT): shift_pressed = (event.value == 1) @@ -674,32 +724,73 @@ class VirtualTerminalEInk: if event.value != 1: continue - # Handle Ctrl+D to exit - if ctrl_pressed and event.code == ecodes.KEY_D: - self.stream.feed("\r\n[Exiting...]\r\n") - self.update_display() - self.running = False - break + # Handle Ctrl+letter combinations + if ctrl_pressed: + # Map letter keys to Ctrl codes (Ctrl+A = 1, Ctrl+B = 2, ..., Ctrl+Z = 26) + ctrl_letter_map = { + ecodes.KEY_A: 1, ecodes.KEY_B: 2, ecodes.KEY_C: 3, ecodes.KEY_D: 4, + 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 - if ctrl_pressed and event.code == ecodes.KEY_C: - os.write(self.master_fd, b'\x03') + # Handle F5-F8 for quadrant zoom + if event.code == ecodes.KEY_F5: + 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 # Map key to character char = key_map.get(event.code) if char: - # Apply shift modifier - if shift_pressed: + # Reset quadrant view on any normal keypress + 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(): char = char.upper() elif char in shift_map: 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 os.write(self.master_fd, char.encode()) @@ -833,6 +924,9 @@ def main(): type=float, default=1.0, 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() @@ -840,7 +934,8 @@ def main(): vt = VirtualTerminalEInk( display_type=args.display, use_display=not args.no_display, - zoom=args.zoom + zoom=args.zoom, + show_preview=not args.no_preview ) if args.command: