""" Braille display output using brltty and liblouis. This module provides Braille display support for BookStorm, allowing users to read book content on refreshable Braille displays. It uses: - brltty (via BrlAPI): Hardware communication layer - liblouis: Text-to-Braille translation Supports Grade 1, Grade 2, and UEB Braille tables. """ import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) class BrailleOutput: """Manages Braille display output via brltty and liblouis.""" # Translation table mappings TABLES = { "grade1": "en-us-g1.ctb", "grade2": "en-us-g2.ctb", "ueb": "en-ueb-g2.ctb" } def __init__(self, enabled=False, translationTable="grade2", syncWithTts=True, showStatus=True, muteVoice=False): """ Initialize Braille output. Args: enabled: Whether Braille output is enabled translationTable: Translation table name (grade1/grade2/ueb) syncWithTts: Whether to sync Braille with TTS playback showStatus: Whether to show status messages on Braille display muteVoice: Whether to mute TTS when Braille is active (Braille-only mode) """ self.enabled = enabled self.translationTable = translationTable self.syncWithTts = syncWithTts self.showStatus = showStatus self.muteVoice = muteVoice self.brl = None self.displaySize = (0, 0) self.currentText = "" self.panOffset = 0 # Import libraries only if enabled if self.enabled: self._initialize_braille() def _initialize_braille(self): """Initialize brltty connection and liblouis.""" try: import brlapi import louis self.brlapi = brlapi self.louis = louis print("DEBUG: Attempting to connect to brltty...") logger.info("Attempting to connect to brltty daemon") # Connect to brltty daemon self.brl = brlapi.Connection() self.displaySize = self.brl.displaySize print(f"DEBUG: Braille display connected! Size: {self.displaySize[0]} x {self.displaySize[1]} cells") print(f"DEBUG: Driver: {self.brl.driverName}") logger.info(f"Braille display connected: {self.displaySize[0]} x {self.displaySize[1]} cells") # Enter TTY mode - this takes exclusive control from screen reader print("DEBUG: Entering TTY mode (taking control from screen reader)...") self.brl.enterTtyMode() print("DEBUG: TTY mode entered successfully") logger.info("Entered TTY mode - have exclusive control of Braille display") # Verify translation table exists tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"]) try: # Test translation testResult = self.louis.translateString([tablePath], "test") print(f"DEBUG: Braille translation working. Table: {tablePath}") logger.info(f"Using Braille table: {tablePath}") except Exception as e: print(f"DEBUG: Translation table error: {e}, falling back to grade2") logger.warning(f"Translation table error: {e}, falling back to grade2") self.translationTable = "grade2" except ImportError as e: print(f"DEBUG: ImportError - BrlAPI or liblouis not installed: {e}") logger.error("BrlAPI or liblouis not installed. Install python3-brlapi and python3-louis") self.enabled = False self.brl = None except Exception as e: print(f"DEBUG: Could not connect to brltty: {e}") print("DEBUG: Is brltty daemon running? Check with: systemctl status brltty") logger.warning(f"Could not connect to brltty: {e}") self.enabled = False self.brl = None def show_text(self, text, cursorPos=0): """ Display text on Braille display. Args: text: Text to display (will be translated to Braille) cursorPos: Cursor position (0-based) """ if not self.enabled or not self.brl: return try: # Store current text for panning self.currentText = text self.panOffset = 0 # Translate to Braille tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"]) brailleText = self.louis.translateString([tablePath], text) # Handle text longer than display width displayWidth = self.displaySize[0] if len(brailleText) > displayWidth: # Show first portion brailleText = brailleText[:displayWidth] # Send to display self.brl.writeText(brailleText, cursorPos) except Exception as e: logger.error(f"Error displaying Braille text: {e}") def show_paragraph(self, paragraph, offset=0): """ Display a paragraph with panning support. Args: paragraph: Full paragraph text offset: Character offset for panning """ if not self.enabled: print("DEBUG: Braille output disabled, skipping") return if not self.brl: print("DEBUG: No Braille connection, skipping") return try: # Store for manual panning self.currentText = paragraph self.panOffset = offset print(f"DEBUG: Translating paragraph (length {len(paragraph)}) to Braille...") # Translate entire paragraph tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"]) brailleText = self.louis.translateString([tablePath], paragraph) print(f"DEBUG: Braille text length: {len(brailleText)}, Display width: {self.displaySize[0]}") # Extract window based on offset displayWidth = self.displaySize[0] window = brailleText[offset:offset + displayWidth] # Pad if necessary if len(window) < displayWidth: window = window + " " * (displayWidth - len(window)) print(f"DEBUG: Sending {len(window)} cells to Braille display...") self.brl.writeText(window) print("DEBUG: Braille text sent successfully") except Exception as e: print(f"DEBUG: Error displaying paragraph: {e}") logger.error(f"Error displaying paragraph: {e}") def show_status(self, message): """ Display a status message on Braille display. Args: message: Status message to display """ if not self.enabled or not self.brl or not self.showStatus: return try: # Translate and display tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"]) brailleText = self.louis.translateString([tablePath], message) # Truncate if too long displayWidth = self.displaySize[0] if len(brailleText) > displayWidth: brailleText = brailleText[:displayWidth] self.brl.writeText(brailleText) except Exception as e: logger.error(f"Error displaying status: {e}") def show_menu_item(self, itemText, position=None, total=None): """ Display a menu item on Braille display. Args: itemText: Menu item text position: Current position (1-based), optional total: Total items, optional """ if not self.enabled or not self.brl: return try: # Format with position if provided if position is not None and total is not None: displayText = f"{position}/{total}: {itemText}" else: displayText = itemText self.show_text(displayText) except Exception as e: logger.error(f"Error displaying menu item: {e}") def pan_forward(self): """Pan Braille display forward (right).""" if not self.enabled or not self.brl or not self.currentText: return # Translate full text tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"]) brailleText = self.louis.translateString([tablePath], self.currentText) displayWidth = self.displaySize[0] maxOffset = max(0, len(brailleText) - displayWidth) # Advance by display width self.panOffset = min(self.panOffset + displayWidth, maxOffset) # Show new window self.show_paragraph(self.currentText, self.panOffset) def pan_backward(self): """Pan Braille display backward (left).""" if not self.enabled or not self.brl or not self.currentText: return displayWidth = self.displaySize[0] # Go back by display width self.panOffset = max(0, self.panOffset - displayWidth) # Show new window self.show_paragraph(self.currentText, self.panOffset) def read_key(self, timeout=0): """ Read a key press from the Braille display. Args: timeout: Timeout in milliseconds (0 = non-blocking) Returns: Key code or None if no key pressed """ if not self.enabled or not self.brl: return None try: # Read key with timeout (non-blocking if timeout=0) keyCode = self.brl.readKey(wait=False) return keyCode except Exception as e: # No key pressed or error return None def clear(self): """Clear the Braille display.""" if not self.enabled or not self.brl: return try: self.brl.writeText("") except Exception as e: logger.error(f"Error clearing display: {e}") def set_table(self, tableName): """ Change the Braille translation table. Args: tableName: Table name (grade1/grade2/ueb) """ if tableName in self.TABLES: self.translationTable = tableName logger.info(f"Switched to Braille table: {tableName}") else: logger.warning(f"Unknown table: {tableName}, keeping {self.translationTable}") def set_sync_mode(self, syncWithTts): """ Set sync mode. Args: syncWithTts: True to sync with TTS, False for manual only """ self.syncWithTts = syncWithTts logger.info(f"Braille sync mode: {'TTS sync' if syncWithTts else 'manual'}") def set_show_status(self, showStatus): """ Set whether to show status messages. Args: showStatus: True to show status messages """ self.showStatus = showStatus logger.info(f"Braille status messages: {'enabled' if showStatus else 'disabled'}") def set_mute_voice(self, muteVoice): """ Set whether to mute TTS when Braille is active. Args: muteVoice: True to mute TTS (Braille-only mode) """ self.muteVoice = muteVoice logger.info(f"Braille-only mode: {'enabled' if muteVoice else 'disabled (dual output)'}") def close(self): """Close brltty connection and release display.""" if self.brl: try: print("DEBUG: Leaving TTY mode and closing Braille connection...") # Leave TTY mode first to release control back to screen reader self.brl.leaveTtyMode() self.brl.closeConnection() print("DEBUG: Braille display connection closed") logger.info("Braille display connection closed") except Exception as e: print(f"DEBUG: Error closing Braille connection: {e}") logger.error(f"Error closing Braille connection: {e}") finally: self.brl = None self.enabled = False def __del__(self): """Ensure connection is closed on cleanup.""" self.close()