├── examples ├── __init__.py ├── simple_connect.py ├── microphone.py ├── even_ai.py ├── send_text.py ├── send_image.py ├── ppt_teleprompter.py ├── interactions.py └── dashboard.py ├── __init__.py ├── .gitignore ├── requirements.txt ├── setup.py ├── services ├── audio.py ├── __init__.py ├── health.py ├── device.py ├── uart.py ├── events.py ├── display.py ├── status.py └── state.py ├── connector ├── __init__.py ├── commands.py ├── base.py ├── pairing.py └── bluetooth.py ├── utils ├── __init__.py ├── config.py ├── logger.py └── constants.py └── README.md /examples/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example implementations using G1 SDK 3 | """ -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | G1 SDK - Python SDK for interacting with G1 Glasses 3 | """ 4 | 5 | __version__ = "0.1.0" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | build/ 3 | dist/ 4 | venv/ 5 | *.log 6 | g1_config.json 7 | g1_connector.log 8 | *.egg-info/ 9 | *.egg 10 | *.pyc -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bleak>=0.21.1 2 | rich>=13.7.0 3 | asyncio>=3.4.3 4 | setuptools>=65.5.1 5 | pywin32>=305; platform_system == "Windows" # for PowerPoint teleprompter example only (on windows) 6 | -------------------------------------------------------------------------------- /examples/simple_connect.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple connection example 3 | """ 4 | 5 | import asyncio 6 | from connector import G1Connector 7 | 8 | async def main(): 9 | glasses = G1Connector() 10 | await glasses.connect() 11 | # Basic connection example -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="g1_sdk", 5 | version="0.1.0", 6 | packages=find_packages(), 7 | install_requires=[ 8 | "bleak>=0.21.1", 9 | "rich>=13.7.0", 10 | "asyncio>=3.4.3" 11 | ] 12 | ) -------------------------------------------------------------------------------- /services/audio.py: -------------------------------------------------------------------------------- 1 | """ 2 | Audio service implementation for G1 glasses 3 | """ 4 | 5 | import asyncio 6 | from bleak import BleakClient 7 | 8 | from utils.constants import COMMANDS, UUIDS 9 | from utils.logger import user_guidance 10 | 11 | class AudioService: 12 | """Handles audio recording and playback""" 13 | def __init__(self): 14 | pass -------------------------------------------------------------------------------- /connector/__init__.py: -------------------------------------------------------------------------------- 1 | """G1 glasses connector package""" 2 | 3 | from connector.base import G1Connector 4 | from utils.constants import ( 5 | UUIDS, COMMANDS, EventCategories, StateEvent, 6 | ConnectionState, StateColors, StateDisplay 7 | ) 8 | 9 | __all__ = [ 10 | 'G1Connector', 11 | 'UUIDS', 12 | 'COMMANDS', 13 | 'EventCategories', 14 | 'StateEvent', 15 | 'ConnectionState', 16 | 'StateColors', 17 | 'StateDisplay' 18 | ] -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Utility functions and constants for G1 glasses SDK""" 2 | 3 | from utils.logger import setup_logger, user_guidance 4 | from utils.constants import ( 5 | UUIDS, COMMANDS, EventCategories, StateEvent, 6 | ConnectionState, StateColors, StateDisplay 7 | ) 8 | 9 | __all__ = [ 10 | 'setup_logger', 11 | 'user_guidance', 12 | 'UUIDS', 13 | 'COMMANDS', 14 | 'EventCategories', 15 | 'StateEvent', 16 | 'ConnectionState', 17 | 'StateColors', 18 | 'StateDisplay' 19 | ] -------------------------------------------------------------------------------- /services/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | G1 Services module - Service-specific implementations 3 | """ 4 | 5 | from services.uart import UARTService 6 | from services.audio import AudioService 7 | from services.display import DisplayService 8 | from services.events import EventService 9 | from services.status import StatusManager 10 | from services.state import StateManager 11 | from services.device import DeviceManager 12 | 13 | __all__ = [ 14 | 'UARTService', 15 | 'AudioService', 16 | 'DisplayService', 17 | 'EventService', 18 | 'StatusManager', 19 | 'StateManager', 20 | 'DeviceManager' 21 | ] -------------------------------------------------------------------------------- /services/health.py: -------------------------------------------------------------------------------- 1 | """Health monitoring service for G1 glasses""" 2 | from typing import Callable, List 3 | import asyncio 4 | import time 5 | 6 | class HealthMonitor: 7 | def __init__(self, connector): 8 | self.connector = connector 9 | self.logger = connector.logger 10 | self._heartbeat_handlers: List[Callable] = [] 11 | self._last_heartbeat = None 12 | self._connection_quality = { 13 | 'left': {'rssi': None, 'errors': 0, 'last_heartbeat': None}, 14 | 'right': {'rssi': None, 'errors': 0, 'last_heartbeat': None} 15 | } 16 | 17 | def subscribe_heartbeat(self, handler: Callable): 18 | """Subscribe to heartbeat events""" 19 | if handler not in self._heartbeat_handlers: 20 | self._heartbeat_handlers.append(handler) 21 | 22 | async def process_heartbeat(self, side: str, timestamp: float): 23 | """Process heartbeat from a specific side""" 24 | self._last_heartbeat = timestamp 25 | self._connection_quality[side]['last_heartbeat'] = timestamp 26 | 27 | await self._notify_handlers(timestamp) 28 | 29 | async def _notify_handlers(self, timestamp: float): 30 | """Notify all heartbeat handlers""" 31 | for handler in self._heartbeat_handlers: 32 | try: 33 | # Skip if handler is the connector's heartbeat handler to prevent recursion 34 | if handler == self.connector._handle_heartbeat: 35 | continue 36 | 37 | if asyncio.iscoroutinefunction(handler): 38 | await handler(timestamp) 39 | else: 40 | handler(timestamp) 41 | except Exception as e: 42 | self.logger.error(f"Error in heartbeat handler: {e}") -------------------------------------------------------------------------------- /examples/microphone.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of using G1 glasses microphone 3 | note: can we capture the audio, real-time transcribe it, then show it as output from the script? (this would enable usecases such as custom AI and search) 4 | 5 | 6 | from the documentation: 7 | Open Glasses Mic 8 | Command Information 9 | Command: 0x0E 10 | enable: 11 | 0 (Disable) / 1 (Enable) 12 | Description 13 | enable: 14 | 0: Disable the MIC (turn off sound pickup). 15 | 1: Enable the MIC (turn on sound pickup). 16 | Response from Glasses 17 | Command: 0x0E 18 | rsp_status (Response Status): 19 | 0xC9: Success 20 | 0xCA: Failure 21 | enable: 22 | 0: MIC disabled. 23 | 1: MIC enabled. 24 | Example 25 | Command sent to device: 0x0E, with enable = 1 to enable the MIC. 26 | Device response: 27 | If successful: 0x0E with rsp_status = 0xC9 and enable = 1. 28 | If failed: 0x0E with rsp_status = 0xCA and enable = 1. 29 | Receive Glasses Mic data 30 | Command Information 31 | Command: 0xF1 32 | seq (Sequence Number): 0~255 33 | data (Audio Data): Actual MIC audio data being transmitted. 34 | Field Descriptions 35 | seq (Sequence Number): 36 | Range: 0~255 37 | Description: This is the sequence number of the current data packet. It helps to ensure the order of the audio data being received. 38 | data (Audio Data): 39 | Description: The actual audio data captured by the MIC, transmitted in chunks according to the sequence. 40 | Example 41 | Command: 0xF1, with seq = 10 and data = [Audio Data] 42 | Description: This command transmits a chunk of audio data from the glasses' MIC, with a sequence number of 10 to maintain packet order. 43 | """ 44 | 45 | import asyncio 46 | from connector import G1Connector 47 | from services import AudioService 48 | 49 | async def main(): 50 | """ 51 | Demonstrates microphone activation and audio streaming 52 | 53 | Protocol details: 54 | - Uses right-side microphone 55 | - Activated with command 0x0E 56 | - Receives LC3 format audio stream 57 | - Maximum recording duration: 30 seconds 58 | """ 59 | glasses = G1Connector() 60 | await glasses.connect() 61 | 62 | audio = AudioService(glasses) 63 | await audio.start_recording() 64 | # Record for 5 seconds 65 | await asyncio.sleep(5) 66 | await audio.stop_recording() 67 | 68 | if __name__ == "__main__": 69 | asyncio.run(main()) 70 | -------------------------------------------------------------------------------- /examples/even_ai.py: -------------------------------------------------------------------------------- 1 | """ 2 | Even AI interaction example 3 | 4 | documentation: (also see microphone.py as mic is relevant to AI too) 5 | 6 | Start Even AI 7 | Command Information 8 | Command: 0xF5 9 | subcmd (Sub-command): 0~255 10 | param (Parameters): Specific parameters associated with each sub-command. 11 | Sub-command Descriptions 12 | subcmd: 0 (exit to dashboard manually). 13 | Description: Stop all advanced features and return to the dashboard. 14 | subcmd: 1 (page up/down control in manual mode). 15 | Description: page-up(left ble) / page-down (right ble) 16 | subcmd: 23 (start Even AI). 17 | Description: Notify phone to activate Even AI. 18 | subcmd: 24 (stop Even AI recording). 19 | Description: Even AI recording ended. 20 | 21 | Send AI Result 22 | Command Information 23 | Command: 0x4E 24 | seq (Sequence Number): 0~255 25 | total_package_num (Total Package Count): 1~255 26 | current_package_num (Current Package Number): 0~255 27 | newscreen (Screen Status) 28 | Field Descriptions 29 | seq (Sequence Number): 30 | 31 | Range: 0~255 32 | Description: Indicates the sequence of the current package. 33 | total_package_num (Total Package Count): 34 | 35 | Range: 1~255 36 | Description: The total number of packages being sent in this transmission. 37 | current_package_num (Current Package Number): 38 | 39 | Range: 0~255 40 | Description: The current package number within the total, starting from 0. 41 | newscreen (Screen Status): 42 | 43 | Composed of lower 4 bits and upper 4 bits to represent screen status and Even AI mode. 44 | Lower 4 Bits (Screen Action): 45 | 0x01: Display new content 46 | Upper 4 Bits (Even AI Status): 47 | 0x30: Even AI displaying(automatic mode default) 48 | 0x40: Even AI display complete (Used when the last page of automatic mode) 49 | 0x50: Even AI manual mode 50 | 0x60: Even AI network error 51 | Example: 52 | New content + Even AI displaying state is represented as 0x31. 53 | new_char_pos0 and new_char_pos1: 54 | 55 | new_char_pos0: Higher 8 bits of the new character position. 56 | new_char_pos1: Lower 8 bits of the new character position. 57 | current_page_num (Current Page Number): 58 | 59 | Range: 0~255 60 | Description: Represents the current page number. 61 | max_page_num (Maximum Page Number): 62 | 63 | Range: 1~255 64 | Description: The total number of pages. 65 | data (Data): 66 | 67 | Description: The actual data being transmitted in this package. 68 | 69 | """ 70 | 71 | import asyncio 72 | from connector import G1Connector 73 | from services import AudioService 74 | 75 | async def main(): 76 | glasses = G1Connector() 77 | await glasses.connect() 78 | audio = AudioService(glasses) 79 | # Even AI example -------------------------------------------------------------------------------- /examples/send_text.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of sending text to G1 glasses 3 | """ 4 | import asyncio 5 | from connector import G1Connector 6 | from utils.logger import setup_logger 7 | 8 | async def main(): 9 | """Test sequence with different text display methods""" 10 | logger = setup_logger() 11 | glasses = G1Connector() 12 | 13 | # Test texts 14 | single_text = "This is a single text that will display for 5 seconds." 15 | indefinite_text = "This text will display until manually cleared." 16 | large_text = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do 17 | eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim 18 | veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo 19 | consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse 20 | cillum dolore eu fugiat nulla pariatur.""" 21 | 22 | text_sequence = [ 23 | "First text in sequence", 24 | "Second text in sequence", 25 | "Third and final text" 26 | ] 27 | 28 | logger.info("Connecting to glasses...") 29 | await glasses.connect() 30 | logger.info("Connected! Starting tests...") 31 | 32 | try: 33 | # Test 1: Single text with auto-exit 34 | logger.info("\n=== Test 1: Single Text with 5s Duration ===") 35 | await glasses.display.display_text(single_text, hold_time=5) 36 | 37 | await asyncio.sleep(2) # Pause between tests 38 | 39 | # Test 2: Text sequence 40 | logger.info("\n=== Test 2: Text Sequence ===") 41 | await glasses.display.display_text_sequence(text_sequence, hold_time=3) 42 | 43 | await asyncio.sleep(2) 44 | 45 | # Test 3: Large text auto-split 46 | logger.info("\n=== Test 3: Large Text Auto-split ===") 47 | await glasses.display.display_text(large_text, hold_time=4) 48 | 49 | await asyncio.sleep(2) 50 | 51 | # Test 4: Indefinite display 52 | logger.info("\n=== Test 4: Indefinite Display ===") 53 | await glasses.display.display_text(indefinite_text) 54 | logger.info("Text will display until manually cleared...") 55 | await asyncio.sleep(10) # Simulate some time passing 56 | 57 | # Show exit message 58 | logger.info("\n=== Showing Exit Message ===") 59 | await glasses.display.show_exit_message() 60 | 61 | finally: 62 | logger.info("Disconnecting...") 63 | await glasses.disconnect() 64 | logger.info("Test complete") 65 | 66 | if __name__ == "__main__": 67 | try: 68 | asyncio.run(main()) 69 | except KeyboardInterrupt: 70 | print("\nExited by user") 71 | except Exception as e: 72 | print(f"\nError: {e}") 73 | -------------------------------------------------------------------------------- /examples/send_image.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of sending images to G1 glasses 3 | 4 | 5 | from the documentation: 6 | 7 | Image transmission currently supports 1-bit, 576*136 pixel BMP images (refer to image_1.bmp, image_2.bmp in the project). The core process includes three steps: 8 | 9 | Divide the BMP image data into packets (each packet is 194 bytes), then add 0x15 command and syncID to the front of the packet, and send it to the dual BLE in the order of the packets (the left and right sides can be sent independently at the same time). The first packet needs to insert 4 bytes of glasses end storage address 0x00, 0x1c, 0x00, 0x00, so the first packet data is ([0x15, index & 0xff, 0x00, 0x1c, 0x00, 0x00], pack), and other packets do not need addresses 0x00, 0x1c, 0x00, 0x00; 10 | After sending the last packet, it is necessary to send the packet end command [0x20, 0x0d, 0x0e] to the dual BLE; 11 | After the packet end command in step 2 is correctly replied, send the CRC check command to the dual BLE through the 0x16 command. When calculating the CRC, it is necessary to consider the glasses end storage address added when sending the first BMP packet. 12 | For a specific example, click the icon in the upper right corner of the App homepage to enter the Features page. The page contains three buttons: BMP 1, BMP 2, and Exit, which represent the transmission and display of picture 1, the transmission and display of picture 2, and the exit of picture transmission and display. 13 | 14 | 15 | Send bmp data packet 16 | Command Information 17 | Command: 0x15 18 | seq (Sequence Number): 0~255 19 | address: [0x00, 0x1c, 0x00, 0x00] 20 | data0 ~ data194 21 | Field Descriptions 22 | seq (Sequence Number): 23 | Range: 0~255 24 | Description: Indicates the sequence of the current package. 25 | address: bmp address in the Glasses (just attached in the first pack) 26 | data0 ~ data194: 27 | bmp data packet 28 | Bmp data packet transmission ends 29 | Command Information 30 | Command: 0x20 31 | data0: 0x0d 32 | data1: 0x0e 33 | Field Descriptions 34 | Fixed format command: [0x20, 0x0d, 0x0e] 35 | CRC Check 36 | Command Information 37 | Command: 0x16 38 | crc 39 | Field Descriptions 40 | crc: The crc check value calculated using Crc32Xz big endian, combined with the bmp picture storage address and picture data. 41 | """ 42 | 43 | import asyncio 44 | from connector import G1Connector 45 | from services import DisplayService 46 | 47 | async def main(): 48 | """ 49 | Demonstrates sending BMP images to G1 glasses 50 | 51 | Protocol details: 52 | - Supports 1-bit, 576x136 pixel BMP images 53 | - Images are divided into 194-byte packets 54 | - First packet includes storage address 55 | - Requires CRC check after transmission 56 | """ 57 | glasses = G1Connector() 58 | await glasses.connect() 59 | 60 | display = DisplayService(glasses) 61 | await display.send_image("path/to/image.bmp") 62 | 63 | if __name__ == "__main__": 64 | asyncio.run(main()) 65 | -------------------------------------------------------------------------------- /services/device.py: -------------------------------------------------------------------------------- 1 | """ 2 | Device management service for G1 glasses 3 | if battery status is available, add that here too 4 | """ 5 | 6 | from utils.constants import COMMANDS, EventCategories 7 | 8 | class DeviceManager: 9 | """Handles device-wide states and controls""" 10 | 11 | def __init__(self, connector): 12 | self.connector = connector 13 | self.logger = connector.logger 14 | self._silent_mode = False 15 | self._battery_level = { 16 | 'left': None, 17 | 'right': None 18 | } 19 | 20 | @property 21 | def silent_mode(self) -> bool: 22 | """Get current silent mode state""" 23 | return self._silent_mode 24 | 25 | async def set_silent_mode(self, enabled: bool) -> bool: 26 | """Set silent mode state (disables all functionality)""" 27 | try: 28 | if enabled == self._silent_mode: 29 | return True 30 | 31 | # Command structure for silent mode 32 | command = bytes([COMMANDS.DASHBOARD_OPEN, 0x01 if enabled else 0x00]) 33 | 34 | result = await self.connector.command_manager.send_command( 35 | self.connector.right_client, 36 | command, 37 | expect_response=True 38 | ) 39 | 40 | if result and result[1] == EventCategories.COMMAND_RESPONSE: 41 | self._silent_mode = enabled 42 | self.logger.info(f"Silent mode {'enabled' if enabled else 'disabled'}") 43 | await self.connector.update_status() 44 | return True 45 | 46 | self.logger.warning(f"Failed to set silent mode: unexpected response {result[1] if result else 'None'}") 47 | return False 48 | 49 | except Exception as e: 50 | self.logger.error(f"Error setting silent mode: {e}") 51 | return False 52 | 53 | async def set_brightness(self, level: int, auto: bool = False) -> bool: 54 | """Set the brightness level (0-41) for both glasses. If auto is True, enable auto brightness.""" 55 | try: 56 | if not 0 <= level <= 41: 57 | self.logger.error(f"Brightness level {level} out of range (0-41)") 58 | return False 59 | auto_byte = 0x01 if auto else 0x00 60 | command = bytes([COMMANDS.BRIGHTNESS, level, auto_byte]) 61 | success = True 62 | for client in [self.connector.left_client, self.connector.right_client]: 63 | if client and client.is_connected: 64 | await self.connector.command_manager.send_command( 65 | client, 66 | command, 67 | expect_response=False 68 | ) 69 | mode = 'AUTO' if auto else 'MANUAL' 70 | self.logger.info(f"Brightness set to {level} ({mode})") 71 | return success 72 | except Exception as e: 73 | self.logger.error(f"Error setting brightness: {e}") 74 | return False 75 | 76 | @property 77 | def battery_level(self) -> dict: 78 | """Get current battery levels""" 79 | return self._battery_level.copy() 80 | 81 | def update_battery_level(self, side: str, level: int): 82 | """Update battery level for specified side""" 83 | if side in self._battery_level: 84 | self._battery_level[side] = level 85 | self.logger.debug(f"Battery level updated for {side}: {level}%") 86 | -------------------------------------------------------------------------------- /utils/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration management for G1 glasses 3 | """ 4 | import os 5 | import json 6 | from dataclasses import dataclass, asdict 7 | from typing import Optional, Dict 8 | 9 | @dataclass 10 | class Config: 11 | """Configuration for G1 glasses SDK""" 12 | # Get the SDK root directory 13 | SDK_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 14 | CONFIG_FILE = os.path.join(SDK_ROOT, "g1_config.json") 15 | 16 | # Logging configuration 17 | log_level: str = "INFO" 18 | log_file: str = os.path.join(SDK_ROOT, "g1_connector.log") 19 | console_log: bool = True 20 | reset_logs: bool = True 21 | 22 | # Connection configuration 23 | heartbeat_interval: float = 8.0 # Changed to float for more precise timing 24 | reconnect_attempts: int = 3 25 | reconnect_delay: float = 1.0 # seconds 26 | connection_timeout: float = 20.0 # Added connection timeout 27 | 28 | # Device information 29 | left_address: Optional[str] = None 30 | right_address: Optional[str] = None 31 | left_name: Optional[str] = None 32 | right_name: Optional[str] = None 33 | left_paired: bool = False 34 | right_paired: bool = False 35 | 36 | # Service information 37 | discovered_services: Dict[str, Dict] = None 38 | 39 | # Display settings (added) 40 | display_width: int = 488 41 | font_size: int = 21 42 | lines_per_screen: int = 5 43 | 44 | def save(self): 45 | """Save configuration to file with comments""" 46 | config_data = asdict(self) 47 | 48 | # Create SDK directory if it doesn't exist 49 | os.makedirs(os.path.dirname(self.CONFIG_FILE), exist_ok=True) 50 | 51 | comments = { 52 | "log_level": "Logging level (DEBUG/INFO/ERROR)", 53 | "log_file": "Log file path", 54 | "reset_logs": "Reset logs on startup", 55 | "console_log": "Enable console logging", 56 | "heartbeat_interval": "Seconds between heartbeat signals", 57 | "reconnect_attempts": "Number of reconnection attempts", 58 | "reconnect_delay": "Seconds between reconnection attempts", 59 | "left_address": "Left glass BLE address (clear to rescan)", 60 | "right_address": "Right glass BLE address (clear to rescan)", 61 | "left_name": "Left glass device name", 62 | "right_name": "Right glass device name", 63 | "discovered_services": "Discovered BLE services and characteristics" 64 | } 65 | 66 | commented_config = { 67 | "_comment": "G1 Glasses SDK Configuration", 68 | "_instructions": "Clear left_address and right_address to force new device scanning", 69 | "config": config_data 70 | } 71 | 72 | for key, comment in comments.items(): 73 | commented_config[f"_{key}_comment"] = comment 74 | 75 | try: 76 | with open(self.CONFIG_FILE, 'w') as f: 77 | json.dump(commented_config, f, indent=2, sort_keys=False) 78 | except Exception as e: 79 | print(f"Error saving config: {e}") 80 | 81 | @classmethod 82 | def load(cls) -> 'Config': 83 | """Load configuration from file or create default""" 84 | try: 85 | with open(cls.CONFIG_FILE, 'r') as f: 86 | data = json.load(f) 87 | config_data = data.get("config", {}) 88 | return cls(**config_data) 89 | except (FileNotFoundError, json.JSONDecodeError): 90 | # Create new config with defaults 91 | config = cls() 92 | config.save() 93 | return config 94 | except Exception as e: 95 | print(f"Unexpected error loading config: {e}") 96 | return cls() -------------------------------------------------------------------------------- /examples/ppt_teleprompter.py: -------------------------------------------------------------------------------- 1 | """ 2 | PowerPoint teleprompter example for G1 glasses 3 | Displays speaker notes from active PowerPoint presentation 4 | """ 5 | import asyncio 6 | import sys 7 | from connector import G1Connector 8 | from utils.logger import setup_logger 9 | 10 | 11 | print("Script starting...") 12 | 13 | async def main(): 14 | """Run PowerPoint teleprompter""" 15 | logger = setup_logger() 16 | print("Logger setup complete") 17 | 18 | # Initialize G1 connection 19 | glasses = G1Connector() 20 | logger.info("Connecting to glasses...") 21 | await glasses.connect() 22 | logger.info("Connected to glasses!") 23 | 24 | try: 25 | print("Attempting win32com import...") 26 | import win32com.client 27 | print("win32com imported successfully") 28 | 29 | # Initialize PowerPoint 30 | powerpoint = win32com.client.Dispatch("PowerPoint.Application") 31 | 32 | # Track current state 33 | current_slide = None 34 | current_notes = None 35 | notes_cache = {} # Add cache for slide notes 36 | 37 | while True: 38 | try: 39 | # Get current slide info 40 | if powerpoint.SlideShowWindows.Count > 0: 41 | # Slideshow mode 42 | slideshow = powerpoint.SlideShowWindows(1) 43 | slide_number = slideshow.View.CurrentShowPosition 44 | current_slide_obj = powerpoint.ActivePresentation.Slides(slide_number) 45 | else: 46 | # Normal mode 47 | slide = powerpoint.ActiveWindow.View.Slide 48 | slide_number = slide.SlideNumber 49 | current_slide_obj = slide 50 | 51 | # Only process if slide changed 52 | if slide_number != current_slide: 53 | current_slide = slide_number 54 | 55 | # Check cache first 56 | if slide_number in notes_cache: 57 | notes_text = notes_cache[slide_number] 58 | else: 59 | # Get notes using the working method 60 | notes_text = "" 61 | if current_slide_obj.HasNotesPage: 62 | notes_page = current_slide_obj.NotesPage 63 | # Get text from all shapes in notes page 64 | for shape in notes_page.Shapes: 65 | if shape.HasTextFrame: 66 | text = shape.TextFrame.TextRange.Text.strip() 67 | # Filter out standalone slide numbers 68 | if text and text != str(slide_number): 69 | notes_text += text + "\n" 70 | 71 | notes_text = notes_text.strip() 72 | notes_cache[slide_number] = notes_text # Cache the result 73 | 74 | if notes_text and notes_text != current_notes: 75 | current_notes = notes_text 76 | logger.debug(f"Notes for Slide {slide_number}") 77 | # Let display service handle text formatting and chunking 78 | await glasses.display.display_text(notes_text) 79 | 80 | except Exception as e: 81 | logger.error(f"Error in monitoring loop: {e}") 82 | 83 | await asyncio.sleep(0.1) 84 | 85 | except Exception as e: 86 | logger.error(f"Error: {e}") 87 | finally: 88 | logger.info("Stopping monitor...") 89 | await glasses.display.show_exit_message() 90 | await glasses.disconnect() 91 | logger.info("Monitor stopped") 92 | 93 | if __name__ == "__main__": 94 | try: 95 | asyncio.run(main()) 96 | except KeyboardInterrupt: 97 | print("\nExited by user") 98 | except Exception as e: 99 | print(f"\nError: {e}") -------------------------------------------------------------------------------- /services/uart.py: -------------------------------------------------------------------------------- 1 | """UART service for G1 glasses""" 2 | import asyncio 3 | from typing import Optional, Dict, Any, Callable 4 | from bleak import BleakClient 5 | from utils.constants import UUIDS, EventCategories 6 | import time 7 | 8 | class UARTService: 9 | """Handles UART communication with G1 glasses""" 10 | 11 | def __init__(self, connector): 12 | """Initialize UART service""" 13 | self.connector = connector 14 | self.logger = connector.logger 15 | self._notification_callbacks = [] 16 | self._shutting_down = False 17 | 18 | async def send_command_with_retry(self, client: BleakClient, data: bytes, retries: int = 3) -> bool: 19 | """Send command with retry logic similar to official app""" 20 | for attempt in range(retries): 21 | try: 22 | await client.write_gatt_char(UUIDS.UART_TX, data, response=True) 23 | self.logger.debug(f"Command sent successfully on attempt {attempt + 1}") 24 | return True 25 | except Exception as e: 26 | self.logger.warning(f"Command failed on attempt {attempt + 1}: {e}") 27 | if attempt < retries - 1: 28 | await asyncio.sleep(0.5) # Wait before retry 29 | return False 30 | 31 | async def start_notifications(self, client: BleakClient, side: str): 32 | """Start UART notifications for a client""" 33 | try: 34 | await client.start_notify( 35 | UUIDS.UART_RX, 36 | lambda _, data: asyncio.create_task( 37 | self._handle_notification(side, data) 38 | ) 39 | ) 40 | self.logger.debug(f"Started UART notifications for {side} glass") 41 | 42 | except Exception as e: 43 | self.logger.error(f"Error starting notifications for {side} glass: {e}") 44 | raise 45 | 46 | async def _handle_notification(self, side: str, data: bytes): 47 | """Process incoming UART notification""" 48 | try: 49 | if self._shutting_down: # Add early return if shutting down 50 | return 51 | 52 | if not data: 53 | return 54 | 55 | # Log raw data at debug level 56 | self.logger.debug(f"Received from {side}: {data.hex()}") 57 | notification_type = data[0] 58 | 59 | # Process based on notification type 60 | if notification_type == EventCategories.STATE_CHANGE: 61 | await self.connector.state_manager.process_raw_state(data, side) 62 | elif notification_type == EventCategories.HEARTBEAT: 63 | await self.connector.health_monitor.process_heartbeat(side, time.time()) 64 | 65 | # Forward to event service if not shutting down 66 | if not self._shutting_down: 67 | await self.connector.event_service.process_notification(side, data) 68 | 69 | except Exception as e: 70 | if not self._shutting_down: # Only log errors if not shutting down 71 | self.logger.error(f"Error handling notification: {e}") 72 | 73 | def add_notification_callback(self, callback: Callable): 74 | """Add callback for notifications""" 75 | if callback not in self._notification_callbacks: 76 | self._notification_callbacks.append(callback) 77 | 78 | def remove_notification_callback(self, callback: Callable): 79 | """Remove callback for notifications""" 80 | if callback in self._notification_callbacks: 81 | self._notification_callbacks.remove(callback) 82 | 83 | async def stop_notifications(self, client: BleakClient) -> None: 84 | """Stop notifications for UART service""" 85 | self._shutting_down = True # Set shutdown flag first 86 | try: 87 | await client.stop_notify(UUIDS.UART_RX) 88 | self.logger.debug("Stopped UART notifications") 89 | except Exception as e: 90 | if "17" not in str(e): # Ignore error code 17 during disconnect 91 | self.logger.error(f"Error stopping notifications: {e}") -------------------------------------------------------------------------------- /utils/logger.py: -------------------------------------------------------------------------------- 1 | """Logging utilities for G1 glasses SDK""" 2 | import os 3 | import logging 4 | import sys 5 | from rich.console import Console 6 | from rich.logging import RichHandler 7 | from typing import Optional 8 | from utils.config import Config 9 | 10 | # Global console instance 11 | _console: Optional[Console] = None 12 | _dashboard_mode: bool = False 13 | 14 | def get_console() -> Console: 15 | """Get or create global console instance""" 16 | global _console 17 | if _console is None: 18 | _console = Console() 19 | return _console 20 | 21 | def set_dashboard_mode(enabled: bool): 22 | """Toggle dashboard mode to suppress console output""" 23 | global _dashboard_mode 24 | _dashboard_mode = enabled 25 | 26 | def setup_logger(config: Optional[Config] = None) -> logging.Logger: 27 | """Set up logger with rich handler""" 28 | # Create logger 29 | logger = logging.getLogger("G1") 30 | 31 | if not logger.handlers: # Only add handlers if none exist 32 | # Set base level to DEBUG to capture everything 33 | logger.setLevel(logging.DEBUG) 34 | 35 | # Clear any existing handlers 36 | logger.handlers.clear() 37 | 38 | # Prevent propagation to root logger 39 | logger.propagate = False 40 | 41 | # File handler - logs everything with detailed formatting 42 | if config and config.log_file: 43 | os.makedirs(os.path.dirname(config.log_file), exist_ok=True) 44 | mode = 'w' if getattr(config, 'reset_logs', True) else 'a' 45 | file_formatter = logging.Formatter( 46 | '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s' 47 | ) 48 | file_handler = logging.FileHandler(config.log_file, mode=mode) 49 | file_handler.setFormatter(file_formatter) 50 | file_handler.setLevel(logging.DEBUG) # Log everything to file 51 | logger.addHandler(file_handler) 52 | 53 | # Console handler with rich formatting 54 | if config and config.console_log: 55 | console_handler = RichHandler( 56 | rich_tracebacks=True, 57 | markup=True, 58 | show_time=False, # Time shown in file logs only 59 | show_path=False, # Path shown in file logs only 60 | console=Console(force_terminal=True) 61 | ) 62 | 63 | # Custom emit method to handle dashboard mode 64 | original_emit = console_handler.emit 65 | def custom_emit(record): 66 | if not _dashboard_mode: 67 | original_emit(record) 68 | console_handler.emit = custom_emit 69 | 70 | # Set console level from config or default to INFO 71 | level = logging.INFO if not config else getattr(config, 'log_level', logging.INFO) 72 | console_handler.setLevel(level) 73 | logger.addHandler(console_handler) 74 | 75 | # Add convenience methods 76 | def success(self, message: str): 77 | """Log success message in green""" 78 | plain_msg = message.replace("[green]", "").replace("[/green]", "") 79 | self.info(plain_msg, extra={"markup": False}) # For file log 80 | self.info(f"[green]{message}[/green]", extra={"markup": True}) # For console 81 | 82 | def user(self, message: str): 83 | """Log user-friendly message in yellow""" 84 | plain_msg = message.replace("[yellow]", "").replace("[/yellow]", "") 85 | self.info(plain_msg, extra={"markup": False}) # For file log 86 | self.info(f"[yellow]{message}[/yellow]", extra={"markup": True}) # For console 87 | 88 | def debug_raw(self, message: str): 89 | """Log raw debug data""" 90 | self.debug(message) # Goes to file only due to level 91 | 92 | logger.success = success.__get__(logger) 93 | logger.user = user.__get__(logger) 94 | logger.debug_raw = debug_raw.__get__(logger) 95 | 96 | return logger 97 | 98 | def user_guidance(logger: logging.Logger, message: str): 99 | """Log user guidance messages without duplication""" 100 | # Only log once, with markup for console and plain for file 101 | logger.info(message, extra={"markup": True}) # Console gets formatted 102 | 103 | # If we have file logging enabled, log without markup 104 | if any(isinstance(h, logging.FileHandler) for h in logger.handlers): 105 | plain_msg = message.replace("[yellow]", "").replace("[/yellow]", "") 106 | plain_msg = plain_msg.replace("[green]", "").replace("[/green]", "") 107 | plain_msg = plain_msg.replace("[red]", "").replace("[/red]", "") 108 | logger.debug(plain_msg, extra={"markup": False}) # File gets plain text -------------------------------------------------------------------------------- /services/events.py: -------------------------------------------------------------------------------- 1 | """ 2 | Event handling service for G1 glasses 3 | Provides high-level event orchestration and subscription management 4 | """ 5 | import asyncio 6 | from dataclasses import dataclass, field 7 | from typing import Callable, Dict, List, Optional, Any 8 | from time import time 9 | 10 | from utils.constants import StateEvent, EventCategories, COMMANDS 11 | from utils.logger import user_guidance 12 | 13 | @dataclass 14 | class EventContext: 15 | """Context information for events""" 16 | side: Optional[str] # 'left' or 'right' 17 | timestamp: float 18 | raw_data: Optional[bytes] = None 19 | metadata: Dict[str, Any] = field(default_factory=dict) 20 | 21 | class EventService: 22 | """Handles event processing and distribution""" 23 | def __init__(self, connector): 24 | self.connector = connector 25 | self.logger = connector.logger 26 | self._shutting_down = False # Add shutdown flag 27 | 28 | # Initialize handlers 29 | self._state_handlers = { 30 | 'physical': {}, # For PHYSICAL_STATES 31 | 'battery': {}, # For BATTERY_STATES 32 | 'device': {}, # For DEVICE_STATES 33 | 'interaction': {} # For INTERACTIONS 34 | } 35 | self._connection_handlers = {} # Connection state handlers 36 | self._raw_handlers = {} # Raw event handlers 37 | 38 | def subscribe_connection(self, callback): 39 | """Subscribe to connection state changes""" 40 | if callback not in self._connection_handlers: 41 | self._connection_handlers[callback] = True 42 | self.logger.debug(f"Added connection handler: {callback}") 43 | 44 | def subscribe_raw(self, event_type: int, callback): 45 | """Subscribe to raw event data for specific event type""" 46 | if event_type not in self._raw_handlers: 47 | self._raw_handlers[event_type] = {} 48 | if callback not in self._raw_handlers[event_type]: 49 | self._raw_handlers[event_type][callback] = True 50 | self.logger.debug(f"Added raw event handler for type 0x{event_type:02x}: {callback}") 51 | 52 | def unsubscribe_raw(self, event_type: int, callback): 53 | """Unsubscribe from raw event data for specific event type""" 54 | if event_type in self._raw_handlers and callback in self._raw_handlers[event_type]: 55 | del self._raw_handlers[event_type][callback] 56 | self.logger.debug(f"Removed raw event handler for type 0x{event_type:02x}: {callback}") 57 | # Clean up empty event type dict 58 | if not self._raw_handlers[event_type]: 59 | del self._raw_handlers[event_type] 60 | 61 | async def process_notification(self, side: str, data: bytes): 62 | """Process and distribute notifications""" 63 | if self._shutting_down or not data: # Check shutdown flag 64 | return 65 | 66 | event_type = data[0] 67 | context = EventContext(side=side, timestamp=time(), raw_data=data) 68 | 69 | # Handle state events (0xF5) 70 | if event_type == 0xF5: 71 | state_code = data[1] 72 | # First update state manager 73 | await self.connector.state_manager.process_raw_state(data, side) 74 | 75 | # Then distribute to appropriate handlers based on state type 76 | if state_code in StateEvent.PHYSICAL_STATES: 77 | await self._dispatch_event(state_code, context, self._state_handlers['physical']) 78 | elif state_code in StateEvent.BATTERY_STATES: 79 | await self._dispatch_event(state_code, context, self._state_handlers['battery']) 80 | elif state_code in StateEvent.DEVICE_STATES: 81 | await self._dispatch_event(state_code, context, self._state_handlers['device']) 82 | elif state_code in StateEvent.INTERACTIONS: 83 | await self._dispatch_event(state_code, context, self._state_handlers['interaction']) 84 | 85 | # Handle heartbeat responses (0x25) 86 | elif event_type == COMMANDS.HEARTBEAT: 87 | await self.connector.state_manager.process_raw_state(data, side) 88 | if event_type in self._raw_handlers: 89 | await self._dispatch_event(event_type, context, self._raw_handlers[event_type]) 90 | else: 91 | # Forward to raw handlers 92 | if event_type in self._raw_handlers: 93 | await self._dispatch_event(event_type, context, self._raw_handlers[event_type]) 94 | 95 | async def _dispatch_event(self, event_type: int, context: EventContext, handlers: dict): 96 | """Dispatch event to registered handlers""" 97 | try: 98 | # For raw handlers, handlers is the dict of handlers for this event type 99 | if isinstance(handlers, dict) and handlers: # If handlers is a non-empty dict 100 | for handler in handlers.keys(): 101 | try: 102 | await handler(context.raw_data, context.side) 103 | except Exception as e: 104 | self.logger.error(f"Error in event handler: {e}") 105 | except Exception as e: 106 | self.logger.error(f"Error dispatching event: {e}") 107 | 108 | def shutdown(self): 109 | """Initiate graceful shutdown""" 110 | self._shutting_down = True 111 | # Clear all handlers to prevent further callbacks 112 | self._state_handlers = {key: {} for key in self._state_handlers} 113 | self._connection_handlers = {} 114 | self._raw_handlers = {} -------------------------------------------------------------------------------- /utils/constants.py: -------------------------------------------------------------------------------- 1 | """Constants for G1 glasses SDK""" 2 | from enum import Enum, IntEnum 3 | from typing import Dict, Tuple 4 | 5 | class ConnectionState(str, Enum): 6 | """Connection states for G1 glasses""" 7 | DISCONNECTED = "Disconnected" 8 | CONNECTING = "Connecting..." 9 | CONNECTED = "Connected" 10 | SCANNING = "Scanning..." 11 | PAIRING = "Pairing..." 12 | PAIRING_FAILED = "Pairing Failed" 13 | 14 | class StateColors: 15 | """Color definitions for different states""" 16 | SUCCESS = "green" 17 | WARNING = "yellow" 18 | ERROR = "red" 19 | INFO = "blue" 20 | NEUTRAL = "grey70" 21 | HIGHLIGHT = "cyan" 22 | BRIGHT = "bright_blue" 23 | 24 | class StateEvent: 25 | """State events (0xF5) and their subcategories""" 26 | 27 | # Physical States with their display properties 28 | PHYSICAL_STATES: Dict[int, Tuple[str, str, str]] = { 29 | 0x06: ("WEARING", "Wearing", StateColors.SUCCESS), 30 | 0x07: ("TRANSITIONING", "Transitioning", StateColors.WARNING), 31 | 0x08: ("CRADLE", "Cradle open", StateColors.INFO), 32 | 0x09: ("CRADLE_FULL", "Charged in cradle", StateColors.SUCCESS), 33 | 0x0b: ("CRADLE_CLOSED", "Cradle closed", StateColors.INFO), 34 | } 35 | 36 | # Device States 37 | DEVICE_STATES: Dict[int, Tuple[str, str]] = { 38 | 0x0a: ("DEVICE_UNKNOWN_0a", "Device unknown 0a"), # this one started appearing after the firmware update on 2025-01-02 (left and right) 39 | 0x11: ("CONNECTED", "Successfully connected"), # assumed because its usualy at the start of the connection 40 | 0x12: ("DEVICE_UNKNOWN_12", "Device unknown 12"), # seen, unclear purpose 41 | 0x14: ("DEVICE_UNKNOWN_15", "Device unknown 14"), # seen, unclear purpose 42 | 0x15: ("DEVICE_UNKNOWN_16", "Device unknown 15") # seen, unclear purpose 43 | } 44 | 45 | # Battery States - (code, system_name, display_label) 46 | BATTERY_STATES: Dict[int, Tuple[str, str]] = { 47 | 0x09: ("GLASSES_CHARGED", "Glasses fully charged"), 48 | 0x0e: ("CABLE_CHARGING", "Cradle charging cable state changed"), 49 | 0x0f: ("CRADLE_CHARGED", "Cradle fully charged"), 50 | } 51 | 52 | # Interactions 53 | INTERACTIONS: Dict[int, Tuple[str, str]] = { 54 | 0x00: ("DOUBLE_TAP", "Double tap"), 55 | 0x01: ("SINGLE_TAP", "Single tap"), 56 | 0x17: ("LONG_PRESS", "Long press"), 57 | 0x04: ("SILENT_MODE_ON", "Silent mode enabled"), 58 | 0x05: ("SILENT_MODE_OFF", "Silent mode disabled"), 59 | 0x02: ("OPEN_DASHBOARD_START", "Open dashboard start"), 60 | 0x03: ("CLOSE_DASHBOARD_START", "Close dashboard start"), 61 | 0x1E: ("OPEN_DASHBOARD_CONFIRM", "Open dashboard confirmed"), 62 | 0x1F: ("CLOSE_DASHBOARD_CONFIRM", "Close dashboard confirmed") 63 | } 64 | 65 | @classmethod 66 | def get_physical_state(cls, code) -> Tuple[str, str, str]: 67 | """Get physical state name, label and color""" 68 | try: 69 | if isinstance(code, str): 70 | if code.startswith('f5'): 71 | code = int(code[2:], 16) 72 | else: 73 | code = int(code, 16) 74 | 75 | if code in cls.PHYSICAL_STATES: 76 | return cls.PHYSICAL_STATES[code] 77 | 78 | return "UNKNOWN", f"Unknown (0x{code:02x})", StateColors.ERROR 79 | 80 | except (ValueError, TypeError): 81 | return "UNKNOWN", "Invalid State Code", StateColors.ERROR 82 | 83 | @classmethod 84 | def get_device_state(cls, code: int) -> Tuple[str, str]: 85 | """Get device state name and label""" 86 | return cls.DEVICE_STATES.get(code, ("UNKNOWN", f"Unknown (0x{code:02X})")) 87 | 88 | @classmethod 89 | def get_interaction(cls, code: int) -> Tuple[str, str]: 90 | """Get interaction name and label""" 91 | return cls.INTERACTIONS.get(code, ("UNKNOWN", f"Unknown (0x{code:02X})")) 92 | 93 | class UUIDS: 94 | """Bluetooth UUIDs for G1 glasses""" 95 | UART_SERVICE = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" 96 | UART_TX = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" 97 | UART_RX = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" 98 | 99 | class COMMANDS: 100 | """Command codes for G1 glasses""" 101 | HEARTBEAT = 0x25 102 | SILENT_MODE_ON = 0x04 # 3 taps 103 | SILENT_MODE_OFF = 0x05 # 3 taps 104 | AI_ENABLE = 0x17 #long press left 105 | HEARTBEAT_CMD = bytes([0x25, 0x06, 0x00, 0x01, 0x04, 0x01]) 106 | BRIGHTNESS = 0x01 107 | 108 | class StateDisplay: 109 | """Display information derived from StateEvent definitions""" 110 | @staticmethod 111 | def get_physical_states() -> Dict[str, Tuple[str, str]]: 112 | """Generate physical states display dictionary""" 113 | states = { 114 | name: (color, label) 115 | for _, (name, label, color) in StateEvent.PHYSICAL_STATES.items() 116 | } 117 | # Add unknown state 118 | states["UNKNOWN"] = (StateColors.ERROR, "Unknown") 119 | return states 120 | 121 | # Access as a class variable 122 | PHYSICAL_STATES = get_physical_states() 123 | 124 | CONNECTION_STATES = { 125 | ConnectionState.CONNECTED: StateColors.SUCCESS, 126 | ConnectionState.DISCONNECTED: StateColors.ERROR, 127 | ConnectionState.CONNECTING: StateColors.WARNING, 128 | ConnectionState.SCANNING: StateColors.INFO, 129 | ConnectionState.PAIRING: StateColors.WARNING, 130 | ConnectionState.PAIRING_FAILED: StateColors.ERROR 131 | } 132 | 133 | class EventCategories: 134 | """Event categories for G1 glasses""" 135 | STATE_CHANGE = 0xf5 136 | DASHBOARD = 0x22 137 | HEARTBEAT = 0x25 138 | RESPONSE = 0x03 139 | ERROR = 0x04 140 | 141 | 142 | -------------------------------------------------------------------------------- /services/display.py: -------------------------------------------------------------------------------- 1 | """ 2 | Display service implementation for G1 glasses 3 | """ 4 | import asyncio 5 | from bleak import BleakClient 6 | from typing import List, Optional 7 | from utils.logger import setup_logger 8 | 9 | class DisplayService: 10 | """Handles text and image display""" 11 | 12 | # Constants from documentation 13 | #MAX_WIDTH_PIXELS = 488 14 | #FONT_SIZE = 21 15 | LINES_PER_SCREEN = 5 16 | CHARS_PER_LINE = 55 # Slightly reduced from 60 to give some margin for word wrapping 17 | MAX_TEXT_LENGTH = CHARS_PER_LINE * LINES_PER_SCREEN # About 275 characters 18 | 19 | def __init__(self, connector): 20 | self.connector = connector 21 | self.logger = setup_logger() 22 | self._current_text = None # Track currently displayed text 23 | 24 | def _split_text_into_chunks(self, text: str) -> List[str]: 25 | """Split text into screen-sized chunks, preserving word boundaries""" 26 | chunks = [] 27 | lines = [] 28 | current_line = [] 29 | current_length = 0 30 | 31 | words = text.split() 32 | 33 | for word in words: 34 | word_length = len(word) 35 | # Check if adding this word would exceed line length 36 | if current_length + word_length + (1 if current_line else 0) > self.CHARS_PER_LINE: 37 | # Save current line and start new one 38 | if current_line: 39 | lines.append(' '.join(current_line)) 40 | current_line = [word] 41 | current_length = word_length 42 | else: 43 | current_line.append(word) 44 | current_length += word_length + (1 if current_line else 0) 45 | 46 | # Add last line if exists 47 | if current_line: 48 | lines.append(' '.join(current_line)) 49 | 50 | # Combine lines into chunks that fit on screen 51 | current_chunk = [] 52 | for line in lines: 53 | if len(current_chunk) >= self.LINES_PER_SCREEN: 54 | chunks.append('\n'.join(current_chunk)) 55 | current_chunk = [] 56 | current_chunk.append(line) 57 | 58 | if current_chunk: 59 | chunks.append('\n'.join(current_chunk)) 60 | 61 | return chunks 62 | 63 | def validate_text(self, text: str) -> bool: 64 | """Validate text length and content""" 65 | if not text: 66 | raise ValueError("Text cannot be empty") 67 | return True 68 | 69 | async def send_text_sequential(self, text: str, hold_time: Optional[int] = None, show_exit: bool = True): 70 | """Send text to both glasses in sequence with acknowledgment""" 71 | # Keep existing connection checks 72 | if not self.connector.left_client.is_connected or not self.connector.right_client.is_connected: 73 | self.logger.error("One or both glasses disconnected. Please reconnect.") 74 | return False 75 | 76 | # Quick validation 77 | if not text: 78 | raise ValueError("Text cannot be empty") 79 | 80 | # Only chunk if text exceeds display limits 81 | chunks = self._split_text_into_chunks(text) 82 | text_to_send = chunks[0] # Get first chunk with line breaks 83 | 84 | # Prepare command once for both glasses 85 | command = bytearray([ 86 | 0x4E, # Text command 87 | 0x00, # Sequence number 88 | 0x01, # Total packages 89 | 0x00, # Current package 90 | 0x71, # Screen status (0x70 Text Show + 0x01 New Content) 91 | 0x00, 0x00, # Character position 92 | 0x00, # Current page 93 | 0x01, # Max pages 94 | ]) 95 | command.extend(text_to_send.encode('utf-8')) 96 | 97 | try: 98 | # Send to both glasses simultaneously 99 | tasks = [ 100 | self.connector.uart_service.send_command_with_retry(self.connector.left_client, command), 101 | self.connector.uart_service.send_command_with_retry(self.connector.right_client, command) 102 | ] 103 | results = await asyncio.gather(*tasks) 104 | 105 | if all(results): 106 | if hold_time: 107 | await asyncio.sleep(hold_time) 108 | if show_exit: 109 | await self.show_exit_message() 110 | return True 111 | else: 112 | self.logger.error("Failed to send to one or both glasses") 113 | return False 114 | 115 | except Exception as e: 116 | self.logger.error(f"Error sending text: {e}") 117 | return False 118 | 119 | async def display_text(self, text: str, hold_time: Optional[int] = None): 120 | """Display a single text with optional hold time""" 121 | self.validate_text(text) 122 | 123 | if len(text) <= self.MAX_TEXT_LENGTH: 124 | return await self.send_text_sequential(text, hold_time) 125 | else: 126 | self.logger.info("Text exceeds screen size, splitting into chunks...") 127 | chunks = self._split_text_into_chunks(text) 128 | return await self.display_text_sequence(chunks, hold_time) 129 | 130 | async def display_text_sequence(self, texts: List[str], hold_time: Optional[int] = 5): 131 | """Display a sequence of texts with specified hold time""" 132 | if not texts: 133 | raise ValueError("Text sequence cannot be empty") 134 | 135 | # Validate all texts first 136 | for text in texts: 137 | self.validate_text(text) 138 | if len(text) > self.MAX_TEXT_LENGTH: 139 | raise ValueError(f"Text exceeds maximum length of {self.MAX_TEXT_LENGTH} characters: {text[:50]}...") 140 | 141 | # Display each text in sequence 142 | for i, text in enumerate(texts, 1): 143 | self.logger.info(f"Displaying text {i} of {len(texts)}") 144 | show_exit = (i == len(texts)) # Only show exit on last text 145 | if not await self.send_text_sequential(text, hold_time, show_exit=show_exit): 146 | return False 147 | 148 | return True 149 | 150 | async def show_exit_message(self): 151 | """Display exit message and wait for user action""" 152 | if self._current_text != "Activity completed, double-tap to exit": 153 | await self.send_text_sequential("Activity completed, double-tap to exit", hold_time=3) -------------------------------------------------------------------------------- /services/status.py: -------------------------------------------------------------------------------- 1 | """Status display service for G1 glasses""" 2 | import asyncio 3 | import time 4 | from rich.table import Table 5 | from rich.live import Live 6 | from rich.text import Text 7 | 8 | from utils.constants import StateEvent, EventCategories 9 | 10 | class StatusManager: 11 | """Manages status display and dashboard""" 12 | 13 | def __init__(self, connector): 14 | self.connector = connector 15 | self.logger = connector.logger 16 | self._live = None 17 | self._running = False 18 | 19 | def generate_table(self) -> Table: 20 | """Generate status table for display""" 21 | table = Table(title="G1 Glasses Status") 22 | 23 | # Add columns first for consistent layout 24 | table.add_column("Device", style="cyan") 25 | table.add_column("Status", style="green") 26 | table.add_column("Signal", style="yellow") 27 | table.add_column("Errors", style="red") 28 | 29 | # Connection Status Section 30 | self._add_connection_status(table) 31 | 32 | # Device State Section 33 | self._add_device_states(table) 34 | 35 | # Event Status Section 36 | self._add_event_status(table) 37 | 38 | # System Status Section 39 | self._add_system_status(table) 40 | 41 | return table 42 | 43 | def _add_connection_status(self, table: Table): 44 | """Add connection status information""" 45 | # Left glass status 46 | left_rssi = self.connector._connection_quality['left']['rssi'] 47 | left_errors = self.connector._connection_quality['left']['errors'] 48 | table.add_row( 49 | f"Left Glass ({self.connector.config.left_name or 'Not Found'})", 50 | "Connected" if self.connector.left_client and self.connector.left_client.is_connected else "Disconnected", 51 | f"{left_rssi}dBm" if left_rssi else "N/A", 52 | str(left_errors) 53 | ) 54 | 55 | # Right glass status 56 | right_rssi = self.connector._connection_quality['right']['rssi'] 57 | right_errors = self.connector._connection_quality['right']['errors'] 58 | table.add_row( 59 | f"Right Glass ({self.connector.config.right_name or 'Not Found'})", 60 | "Connected" if self.connector.right_client and self.connector.right_client.is_connected else "Disconnected", 61 | f"{right_rssi}dBm" if right_rssi else "N/A", 62 | str(right_errors) 63 | ) 64 | 65 | def _add_device_states(self, table: Table): 66 | """Add device state information""" 67 | # Physical State - use both state manager and event service 68 | physical_state = self.connector.state_manager.physical_state 69 | event_state = self._get_last_event_of_type(StateEvent.PHYSICAL_STATES) 70 | table.add_row( 71 | "Physical State", 72 | f"{physical_state} ({event_state})" if event_state else physical_state, 73 | "", 74 | "" 75 | ) 76 | 77 | # Device State 78 | device_state = self._get_last_event_of_type(StateEvent.DEVICE_STATES) 79 | if device_state: 80 | table.add_row("Device State", device_state, "", "") 81 | 82 | # Battery State - Added 83 | battery_state = self.connector.state_manager.battery_state 84 | if battery_state: 85 | table.add_row("Battery State", battery_state, "", "") 86 | 87 | # AI Status 88 | table.add_row( 89 | "AI Status", 90 | "Enabled" if self.connector.event_service._ai_enabled else "Disabled", 91 | "", 92 | "" 93 | ) 94 | 95 | # Silent Mode 96 | table.add_row( 97 | "Silent Mode", 98 | "On" if self.connector.event_service._silent_mode else "Off", 99 | "", 100 | "" 101 | ) 102 | 103 | def _add_event_status(self, table: Table): 104 | """Add recent event information""" 105 | # Last Interaction - use both state manager and event service 106 | interaction = self.connector.state_manager.last_interaction 107 | if interaction and interaction != "None": 108 | table.add_row( 109 | "Last Interaction", 110 | interaction, 111 | "", 112 | "" 113 | ) 114 | 115 | # Last Heartbeat 116 | last_heartbeat = self.connector.event_service.last_heartbeat 117 | if last_heartbeat: 118 | time_ago = time.time() - last_heartbeat 119 | table.add_row( 120 | "Last Heartbeat", 121 | f"{time_ago:.1f}s ago", 122 | "", 123 | "" 124 | ) 125 | 126 | def _add_system_status(self, table: Table): 127 | """Add system status information""" 128 | table.add_section() 129 | 130 | # Connection State 131 | table.add_row( 132 | "Connection", 133 | self.connector.state_manager.connection_state, 134 | "", 135 | "" 136 | ) 137 | 138 | # Add any error counts or system messages 139 | error_count = sum(side['errors'] for side in self.connector._connection_quality.values()) 140 | if error_count > 0: 141 | table.add_row( 142 | "Total Errors", 143 | str(error_count), 144 | "", 145 | "" 146 | ) 147 | 148 | def _get_last_event_of_type(self, event_dict: dict) -> str: 149 | """Helper to get last event of a specific type""" 150 | recent_events = self.connector.event_service.get_recent_events() 151 | for event_code, context in reversed(recent_events): 152 | if event_code in event_dict: 153 | return event_dict[event_code][1] 154 | return "Unknown" 155 | 156 | async def start(self) -> None: 157 | """Start live status display""" 158 | if self._running: 159 | return 160 | 161 | self._running = True 162 | self._live = Live( 163 | self.generate_table(), 164 | refresh_per_second=1, 165 | console=self.connector.console 166 | ) 167 | self._live.start() 168 | 169 | while self._running: 170 | self._live.update(self.generate_table()) 171 | await asyncio.sleep(1) 172 | 173 | async def stop(self) -> None: 174 | """Stop live status display""" 175 | self._running = False 176 | if self._live: 177 | self._live.stop() 178 | 179 | async def update(self) -> None: 180 | """Update status display""" 181 | if self._live: 182 | try: 183 | self._live.update(self.generate_table()) 184 | except Exception as e: 185 | self.logger.error(f"Error updating status display: {e}") -------------------------------------------------------------------------------- /connector/commands.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command handling for G1 glasses 3 | """ 4 | import asyncio 5 | import time 6 | from typing import Dict, Any, Optional, Tuple 7 | from bleak import BleakClient 8 | from utils.constants import UUIDS, COMMANDS, EventCategories 9 | 10 | class CommandManager: 11 | """Manages command queuing and execution""" 12 | 13 | def __init__(self, connector): 14 | self.connector = connector 15 | self.logger = connector.logger 16 | self._command_lock = asyncio.Lock() 17 | self._command_queue = asyncio.Queue() 18 | self._command_task = None 19 | self._heartbeat_task = None 20 | self._heartbeat_seq = 0 21 | self.last_heartbeat = None 22 | self.heartbeat_interval = 8.0 # Default interval 23 | 24 | async def start(self): 25 | """Start command processing""" 26 | if not self._command_task: 27 | self._command_task = asyncio.create_task(self._process_command_queue()) 28 | if not self._heartbeat_task: 29 | self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) 30 | 31 | async def stop(self): 32 | """Stop command processing""" 33 | if self._command_task: 34 | self._command_task.cancel() 35 | try: 36 | await self._command_task 37 | except asyncio.CancelledError: 38 | pass 39 | self._command_task = None 40 | if self._heartbeat_task: 41 | self._heartbeat_task.cancel() 42 | try: 43 | await self._heartbeat_task 44 | except asyncio.CancelledError: 45 | pass 46 | self._heartbeat_task = None 47 | 48 | async def send_command(self, client: BleakClient, command: bytes, 49 | expect_response: bool = False, 50 | timeout: float = 2.0) -> Optional[Tuple[bytes, int]]: 51 | """Send command and optionally wait for response""" 52 | try: 53 | await self._command_queue.put((command, client)) 54 | 55 | if expect_response: 56 | # Wait for response through event service 57 | response = await self._wait_for_response(command[0], timeout) 58 | if response: 59 | return response.raw_data, response.raw_data[0] 60 | return None 61 | 62 | except Exception as e: 63 | self.logger.error(f"Error sending command: {e}") 64 | return None 65 | 66 | async def _wait_for_response(self, command_type: int, timeout: float) -> Optional[Any]: 67 | """Wait for command response""" 68 | try: 69 | # Create future for response 70 | future = asyncio.Future() 71 | 72 | def response_handler(data: bytes, context: Any): 73 | if data[0] in [EventCategories.COMMAND_RESPONSE, EventCategories.ERROR_RESPONSE]: 74 | future.set_result(context) 75 | 76 | # Subscribe to raw events temporarily 77 | self.connector.event_service.subscribe_raw(EventCategories.RESPONSE, response_handler) 78 | 79 | try: 80 | return await asyncio.wait_for(future, timeout) 81 | except asyncio.TimeoutError: 82 | self.logger.warning(f"Command response timeout for type 0x{command_type:02x}") 83 | return None 84 | finally: 85 | self.connector.event_service.unsubscribe(response_handler) 86 | 87 | except Exception as e: 88 | self.logger.error(f"Error waiting for response: {e}") 89 | return None 90 | 91 | async def send_command_with_retry(self, client: BleakClient, data: bytes, retries: int = 3) -> bool: 92 | """Send command with retry logic similar to official app""" 93 | for attempt in range(retries): 94 | try: 95 | await client.write_gatt_char(UUIDS.UART_TX, data, response=True) 96 | self.logger.debug(f"Command sent successfully on attempt {attempt + 1}") 97 | return True 98 | except Exception as e: 99 | self.logger.warning(f"Command failed on attempt {attempt + 1}: {e}") 100 | if attempt < retries - 1: 101 | await asyncio.sleep(0.5) # Wait before retry 102 | return False 103 | 104 | async def send_heartbeat(self, client: BleakClient) -> None: 105 | """Send heartbeat command with proper structure""" 106 | try: 107 | await self.send_command_with_retry(client, COMMANDS.HEARTBEAT_CMD) # Restored original command 108 | self.last_heartbeat = time.time() 109 | # Log heartbeat to file only 110 | self.logger.debug(f"Heartbeat sent") 111 | 112 | except Exception as e: 113 | self.logger.error(f"Error sending heartbeat: {e}") 114 | raise 115 | 116 | async def _heartbeat_loop(self): 117 | """Maintain heartbeat with both glasses""" 118 | while True: 119 | try: 120 | # Send to both glasses 121 | for client in [self.connector.left_client, self.connector.right_client]: 122 | if client and client.is_connected: 123 | await self.send_heartbeat(client) 124 | 125 | await asyncio.sleep(self.heartbeat_interval) 126 | 127 | except asyncio.CancelledError: 128 | self.logger.debug("Heartbeat loop cancelled") 129 | break 130 | except Exception as e: 131 | self.logger.error(f"Error in heartbeat loop: {e}") 132 | await asyncio.sleep(1) 133 | 134 | async def _process_command_queue(self): 135 | """Process commands in queue to prevent conflicts""" 136 | while True: 137 | try: 138 | command, client = await self._command_queue.get() 139 | async with self._command_lock: 140 | await self.send_command_with_retry(client, command) 141 | await asyncio.sleep(0.1) # Small delay between commands 142 | except asyncio.CancelledError: 143 | break 144 | except Exception as e: 145 | self.logger.error(f"Error processing command queue: {e}") 146 | await asyncio.sleep(1) 147 | 148 | async def queue_command(self, command: bytes, client: BleakClient): 149 | """Queue a command for processing""" 150 | await self._command_queue.put((command, client)) 151 | 152 | def start_heartbeat(self): 153 | """Start the heartbeat task""" 154 | if not self._heartbeat_task: 155 | self.logger.debug("Starting heartbeat task") 156 | self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) 157 | 158 | def stop_heartbeat(self): 159 | """Stop the heartbeat task""" 160 | if self._heartbeat_task: 161 | self.logger.debug("Stopping heartbeat task") 162 | self._heartbeat_task.cancel() 163 | self._heartbeat_task = None -------------------------------------------------------------------------------- /examples/interactions.py: -------------------------------------------------------------------------------- 1 | """Example showing G1 glasses state changes and interactions""" 2 | import asyncio 3 | import logging 4 | from datetime import datetime 5 | from connector import G1Connector 6 | from utils.constants import ( 7 | StateEvent, EventCategories, StateColors, 8 | StateDisplay, ConnectionState 9 | ) 10 | from utils.logger import setup_logger 11 | from rich.console import Console 12 | import re 13 | 14 | class InteractionLogger: 15 | def __init__(self): 16 | self.logger = setup_logger() 17 | self.log_count = 0 18 | self.console = Console() 19 | 20 | def print_header(self): 21 | """Print column headers""" 22 | self.console.print("\n[white]Timestamp Category Code Type Side Label[/white]") 23 | self.console.print("[white]" + "-" * 120 + "[/white]") 24 | 25 | def log_event(self, raw_code: int, category: str, event_type: str, side: str, label: str): 26 | """Log event in standardized format""" 27 | if self.log_count % 15 == 0: 28 | self.print_header() 29 | self.log_count += 1 30 | 31 | timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 32 | side_str = side if side else "both" 33 | 34 | # Strip any existing color tags using regex 35 | label = re.sub(r'\[.*?\]', '', label) 36 | category = re.sub(r'\[.*?\]', '', category) 37 | event_type = re.sub(r'\[.*?\]', '', event_type) 38 | side_str = re.sub(r'\[.*?\]', '', side_str) 39 | 40 | # Determine if state is defined and if label contains "unknown" 41 | is_defined = ( 42 | raw_code in ( 43 | StateEvent.BATTERY_STATES | 44 | StateEvent.PHYSICAL_STATES | 45 | StateEvent.DEVICE_STATES | 46 | StateEvent.INTERACTIONS 47 | ) if "state" in category 48 | else False # All non-state events (like dashboard) are undefined for now 49 | ) 50 | 51 | # Use StateColors constants for consistent coloring 52 | row_color = ( 53 | StateColors.WARNING if (is_defined and "unknown" in label.lower()) 54 | else StateColors.ERROR if not is_defined 55 | else StateColors.SUCCESS 56 | ) 57 | 58 | # Format the entire row as one string 59 | row = ( 60 | f"{timestamp} " # timestamp (19 chars) + 2 spaces 61 | f"{category:<20} " # category (20 chars) 62 | f"0x{raw_code:02x} " # code (6 chars) + 6 spaces 63 | f"{event_type:<12} " # type (12 chars) 64 | f"{side_str:<10}" # side (10 chars) 65 | f"{label}" # label (remaining space) 66 | ) 67 | 68 | # Apply color to the entire row 69 | self.console.print(f"[{row_color}]{row}[/{row_color}]") 70 | 71 | class EventContext: 72 | """Context object for events""" 73 | def __init__(self, raw_data: bytes, side: str): 74 | self.raw_data = raw_data 75 | self.side = side 76 | 77 | async def main(): 78 | # Make logger accessible to handlers 79 | global logger 80 | logger = InteractionLogger() 81 | 82 | # Disable all console logging 83 | logging.getLogger().setLevel(logging.ERROR) 84 | 85 | glasses = G1Connector() 86 | 87 | # Connect first 88 | print("Connecting to glasses...") 89 | success = await glasses.connect() 90 | if not success: 91 | print("\nFailed to connect to glasses") 92 | return 93 | 94 | # Clear console and show monitoring message 95 | print("\033[H\033[J") # Clear screen 96 | print("Monitoring state changes (Ctrl+C to exit)...") 97 | await asyncio.sleep(0.5) 98 | 99 | # Now show the table headers 100 | logger.print_header() 101 | 102 | async def handle_state_change(raw_code: int, side: str, label: str): 103 | """Handle state changes from the state manager""" 104 | 105 | # For state events (0xF5), we get just the state code 106 | category = "state (0xf5)" 107 | state_code = raw_code 108 | event_type = "unknown" 109 | 110 | # Determine type from the state code 111 | if state_code in StateEvent.BATTERY_STATES: 112 | event_type = "battery" 113 | elif state_code in StateEvent.PHYSICAL_STATES: 114 | event_type = "physical" 115 | elif state_code in StateEvent.DEVICE_STATES: 116 | event_type = "device" 117 | elif state_code in StateEvent.INTERACTIONS: 118 | event_type = "interaction" 119 | 120 | logger.log_event(state_code, category, event_type, side, label) 121 | 122 | async def handle_raw_event(raw_data: bytes, side: str): 123 | """Handle raw events from UART service""" 124 | if not raw_data: 125 | return 126 | 127 | category_byte = raw_data[0] 128 | event_code = raw_data[1] if len(raw_data) > 1 else 0 129 | 130 | # Skip heartbeat events (0x25) 131 | if category_byte == EventCategories.HEARTBEAT: 132 | return 133 | 134 | # Map category to name 135 | if category_byte == EventCategories.DASHBOARD: # 0x22 136 | category = "dashboard (0x22)" 137 | # Parse dashboard event 138 | if len(raw_data) >= 9: # Dashboard events are 9 bytes 139 | event_type = "dashboard" 140 | label = f"Unknown 0x{event_code:02x}" # Changed to "Unknown" prefix 141 | logger.log_event(event_code, category, event_type, side, label) 142 | elif category_byte == EventCategories.STATE: # 0xf5 143 | return # Handled by state manager 144 | else: 145 | category = f"unknown (0x{category_byte:02x})" 146 | event_type = "unknown" 147 | label = f"Raw event 0x{event_code:02x}" 148 | logger.log_event(event_code, category, event_type, side, label) 149 | 150 | async def handle_any_event(raw_data: bytes, side: str): 151 | """Handle any event type from UART service""" 152 | if not raw_data: 153 | return 154 | 155 | category_byte = raw_data[0] 156 | event_code = raw_data[1] if len(raw_data) > 1 else 0 157 | 158 | # Skip heartbeat events (0x25) 159 | if category_byte == EventCategories.HEARTBEAT: 160 | return 161 | 162 | # Map category to name 163 | if category_byte == EventCategories.DASHBOARD: # 0x22 164 | category = "dashboard (0x22)" 165 | if len(raw_data) >= 9: 166 | event_type = "dashboard" 167 | label = f"Unknown 0x{event_code:02x}" 168 | logger.log_event(event_code, category, event_type, side, label) 169 | elif category_byte == EventCategories.STATE: # 0xf5 170 | return # Handled by state manager 171 | else: 172 | # Log any other category we haven't seen before 173 | category = f"unknown (0x{category_byte:02x})" 174 | event_type = "unknown" 175 | label = f"Raw event 0x{event_code:02x}" 176 | logger.log_event(event_code, category, event_type, side, label) 177 | 178 | # Register specific handlers for known categories 179 | glasses.event_service.subscribe_raw(EventCategories.DASHBOARD, handle_raw_event) 180 | glasses.state_manager.add_raw_state_callback(handle_state_change) 181 | 182 | # Add catch-all handler for unknown categories 183 | glasses.uart_service.add_notification_callback(handle_any_event) 184 | 185 | try: 186 | while True: 187 | await asyncio.sleep(0.1) 188 | except KeyboardInterrupt: 189 | print("\nExited by user") 190 | finally: 191 | # Clean up all handlers 192 | glasses.state_manager.remove_raw_state_callback(handle_state_change) 193 | glasses.event_service.unsubscribe_raw(EventCategories.DASHBOARD, handle_raw_event) 194 | glasses.uart_service.remove_notification_callback(handle_any_event) 195 | await glasses.disconnect() 196 | 197 | if __name__ == "__main__": 198 | try: 199 | asyncio.run(main()) 200 | except KeyboardInterrupt: 201 | print("\nExited by user") 202 | except Exception as e: 203 | print(f"\nError: {e}") -------------------------------------------------------------------------------- /connector/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base connector class for G1 glasses 3 | """ 4 | from rich.console import Console 5 | from rich.live import Live 6 | from rich.table import Table 7 | import os 8 | import time 9 | import logging 10 | import asyncio 11 | from dataclasses import dataclass 12 | from typing import Optional 13 | from bleak import BleakClient 14 | 15 | from connector.bluetooth import BLEManager 16 | from connector.pairing import PairingManager 17 | from connector.commands import CommandManager 18 | from services.state import StateManager 19 | from services.uart import UARTService 20 | from services.events import EventService 21 | from services.status import StatusManager 22 | from services.health import HealthMonitor 23 | from utils.logger import setup_logger 24 | from utils.config import Config 25 | from utils.constants import UUIDS, COMMANDS, StateEvent, EventCategories 26 | from services.device import DeviceManager 27 | from services.display import DisplayService 28 | 29 | @dataclass 30 | class G1Config: 31 | """Configuration for G1 glasses SDK""" 32 | CONFIG_FILE = "g1_config.json" 33 | 34 | # Logging configuration 35 | log_level: str = "INFO" 36 | log_file: str = "g1_connector.log" 37 | console_log: bool = True 38 | 39 | # Connection configuration 40 | heartbeat_interval: int = 8 # seconds 41 | reconnect_attempts: int = 3 42 | reconnect_delay: float = 1.0 # seconds 43 | 44 | # Device information (auto-populated) 45 | left_address: Optional[str] = None 46 | right_address: Optional[str] = None 47 | left_name: Optional[str] = None 48 | right_name: Optional[str] = None 49 | 50 | class G1Connector: 51 | """Main connector class for G1 glasses""" 52 | 53 | def __init__(self, config: Optional[Config] = None): 54 | """Initialize connector with optional config""" 55 | # Core initialization 56 | self.config = config or Config.load() 57 | self.logger = setup_logger(self.config) 58 | 59 | # Initialize Rich console for status display 60 | self.console = Console() 61 | 62 | # Client connections 63 | self.left_client: Optional[BleakClient] = None 64 | self.right_client: Optional[BleakClient] = None 65 | 66 | # Connection quality tracking 67 | self._connection_quality = { 68 | 'left': {'rssi': None, 'errors': 0}, 69 | 'right': {'rssi': None, 'errors': 0} 70 | } 71 | 72 | # Initialize all services and managers 73 | self._initialize_services() 74 | 75 | def _initialize_services(self): 76 | """Initialize services in correct order""" 77 | from connector.bluetooth import BLEManager 78 | from connector.commands import CommandManager 79 | from services.events import EventService 80 | from services.uart import UARTService 81 | from services.device import DeviceManager 82 | from services.state import StateManager 83 | 84 | # Core services first 85 | self.state_manager = StateManager(self) 86 | self.event_service = EventService(self) 87 | self.health_monitor = HealthMonitor(self) 88 | 89 | # Then dependent services 90 | self.uart_service = UARTService(self) 91 | self.device_manager = DeviceManager(self) 92 | self.display = DisplayService(self) # Add display service 93 | 94 | # Finally managers 95 | self.command_manager = CommandManager(self) 96 | self.ble_manager = BLEManager(self) 97 | 98 | # Initialize Rich console for status display 99 | self.console = Console() 100 | 101 | # Initialize device managers 102 | self.pairing_manager = PairingManager(self) # Device pairing 103 | 104 | # Set up event subscriptions 105 | self._setup_event_handlers() 106 | 107 | def _setup_event_handlers(self): 108 | """Set up core event handlers""" 109 | # Subscribe to connection state changes 110 | self.event_service.subscribe_connection(self._handle_connection_state) 111 | 112 | # Subscribe to error events 113 | self.event_service.subscribe_raw(EventCategories.ERROR, self._handle_error_event) 114 | 115 | # Subscribe to heartbeat events via health monitor 116 | self.health_monitor.subscribe_heartbeat(self._handle_heartbeat) 117 | 118 | async def _handle_connection_state(self, state): 119 | """Handle connection state changes""" 120 | try: 121 | # Update connection quality tracking 122 | if state == "Connected": 123 | for side in ['left', 'right']: 124 | if getattr(self, f"{side}_client"): 125 | self._connection_quality[side]['last_connected'] = time.time() 126 | 127 | # Update status display if running 128 | if hasattr(self, 'status_manager'): 129 | await self.status_manager.update() 130 | 131 | except Exception as e: 132 | self.logger.error(f"Error handling connection state: {e}") 133 | 134 | async def _handle_error_event(self, data, side): 135 | """Handle error events""" 136 | try: 137 | if side in self._connection_quality: 138 | self._connection_quality[side]['errors'] += 1 139 | except Exception as e: 140 | self.logger.error(f"Error handling error event: {e}") 141 | 142 | async def _handle_heartbeat(self, timestamp): 143 | """Handle heartbeat events""" 144 | try: 145 | # Update connection quality via health monitor 146 | for side in ['left', 'right']: 147 | if getattr(self, f"{side}_client"): 148 | await self.health_monitor.process_heartbeat(side, timestamp) 149 | except Exception as e: 150 | self.logger.error(f"Error handling heartbeat: {e}") 151 | 152 | async def connect(self): 153 | """Connect to glasses""" 154 | try: 155 | self.state_manager.connection_state = "Connecting..." 156 | 157 | # Check if we need to do initial scanning 158 | if not self.config.left_address or not self.config.right_address: 159 | self.logger.info("No saved glasses found. Starting initial scan...") 160 | if not await self.ble_manager.scan_for_glasses(): 161 | self.state_manager.connection_state = "Disconnected" 162 | return False 163 | 164 | # Attempt connection 165 | if not await self.ble_manager.connect_to_glasses(): 166 | self.state_manager.connection_state = "Disconnected" 167 | return False 168 | 169 | self.state_manager.connection_state = "Connected" 170 | return True 171 | 172 | except Exception as e: 173 | self.logger.error(f"Unexpected error: {e}") 174 | self.state_manager.connection_state = "Disconnected" 175 | return False 176 | 177 | async def disconnect(self): 178 | """Disconnect from glasses""" 179 | try: 180 | await self.ble_manager.disconnect() 181 | self.state_manager.connection_state = "Disconnected" 182 | except Exception as e: 183 | self.logger.error(f"Error during disconnect: {e}") 184 | 185 | def get_connection_quality(self, side: str) -> dict: 186 | """Get connection quality metrics for a side""" 187 | return self._connection_quality.get(side, {}) 188 | 189 | async def update_status(self): 190 | """Update and display current status""" 191 | try: 192 | table = Table(show_header=True, header_style="bold magenta") 193 | table.add_column("Status", style="dim") 194 | table.add_column("Value") 195 | 196 | # Add status rows 197 | table.add_row( 198 | "Left Glass", 199 | "Connected" if self.left_client and self.left_client.is_connected else "Disconnected" 200 | ) 201 | table.add_row( 202 | "Right Glass", 203 | "Connected" if self.right_client and self.right_client.is_connected else "Disconnected" 204 | ) 205 | table.add_row( 206 | "State", 207 | self.state_manager.physical_state 208 | ) 209 | 210 | # Add silent mode status if device manager exists 211 | if hasattr(self, 'device_manager'): 212 | table.add_row( 213 | "Silent Mode", 214 | "Enabled" if self.device_manager.silent_mode else "Disabled" 215 | ) 216 | 217 | # Add error counts if any 218 | left_errors = self._connection_quality['left']['errors'] 219 | right_errors = self._connection_quality['right']['errors'] 220 | if left_errors > 0 or right_errors > 0: 221 | table.add_row( 222 | "Errors", 223 | f"Left: {left_errors}, Right: {right_errors}" 224 | ) 225 | 226 | # Clear screen and display new status 227 | self.console.clear() 228 | self.console.print("\n[bold]G1 Glasses Status[/bold]") 229 | self.console.print(table) 230 | 231 | except Exception as e: 232 | self.logger.error(f"Error updating status: {e}") -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # G1 SDK 2 | 3 | Python SDK for interacting with G1 Smart Glasses via Bluetooth LE. Provides a high-level interface for device communication, state management, and feature control. 4 | 5 | ## Installation 6 | 7 | Requirements: 8 | - Python 3.7+ 9 | - Bluetooth LE support 10 | 11 | ```bash 12 | pip install -r requirements.txt 13 | ``` 14 | 15 | 16 | Required packages: 17 | - bleak>=0.21.1: Bluetooth LE communication 18 | - rich>=13.7.0: Enhanced console output 19 | - asyncio>=3.4.3: Asynchronous I/O support 20 | 21 | ## Quick Start 22 | 23 | Simple connection example: 24 | ```python 25 | from connector import G1Connector 26 | import asyncio 27 | 28 | async def main(): 29 | # Initialize connector 30 | glasses = G1Connector() 31 | 32 | # Connect to glasses (includes automatic retry logic) 33 | if await glasses.connect(): 34 | print("Successfully connected to G1 glasses") 35 | # Your code here 36 | else: 37 | print("Failed to connect to glasses") 38 | 39 | if __name__ == "__main__": 40 | asyncio.run(main()) 41 | ``` 42 | 43 | ## Features 44 | 45 | ### Bluetooth Management 46 | - Robust BLE connection handling with configurable retry logic 47 | - Automatic reconnection on disconnect 48 | - Dual glass (left/right) connection support 49 | - Connection state tracking and event notifications 50 | 51 | ### State Management 52 | - Physical state monitoring (wearing, charging, cradle) 53 | - Battery level tracking 54 | - Device state updates 55 | - Interaction event processing 56 | - Dashboard mode support 57 | 58 | ### Event Handling 59 | Supports various interaction types: 60 | - Single/Double tap detection 61 | - Long press actions 62 | - Dashboard open/close events 63 | - Silent mode toggles 64 | 65 | ### Display Control 66 | Text display features: 67 | - Multi-line text support 68 | - Configurable font sizes 69 | - Page-based text display 70 | - Screen status tracking 71 | - Brightness level adjustment 72 | - Auto brightness on/off 73 | 74 | Image display capabilities: (TODO) 75 | - 1-bit, 576x136 pixel BMP format 76 | - Packet-based transmission 77 | - CRC verification 78 | - Left/right glass synchronization 79 | 80 | ### Audio Features 81 | Microphone control: (TODO) 82 | - Right-side microphone activation 83 | - LC3 format audio streaming 84 | - 30-second maximum recording duration 85 | - Real-time audio data access 86 | 87 | ### Even AI Integration 88 | AI feature support: (TODO) 89 | - Start/stop AI recording 90 | - Manual/automatic modes 91 | - Result display handling 92 | - Network status monitoring 93 | 94 | ## Core Components 95 | 96 | ### Connector Module 97 | Core connection and communication handling: 98 | - `base.py`: Base class for all connectors 99 | - `bluetooth.py`: BLE device management and connection handling 100 | - `commands.py`: Command protocol implementation 101 | - `pairing.py`: Device pairing and authentication 102 | 103 | ### Services Module 104 | Individual feature implementations: 105 | - `audio.py`: Microphone control and audio processing (TODO) 106 | - `device.py`: Device interactions (TODO) 107 | - `display.py`: Text and image display management (TODO) 108 | - `events.py`: Listening and responding to G1 events 109 | - `state.py`: State tracking and event management 110 | - `status.py`: 111 | - `uart.py`: Low-level UART communication 112 | 113 | ### Utils Module 114 | Supporting utilities: 115 | - `config.py`: Configuration management 116 | - `constants.py`: Protocol constants and enums 117 | - `logger.py`: Logging configuration 118 | 119 | 120 | ## Flow 121 | 122 | ### First Time Setup 123 | 1. **Config Initialization** 124 | - `utils/config.py`: Creates default configuration if none exists 125 | - Default settings loaded from `g1_config.json`: 126 | ```json 127 | { 128 | "reconnect_attempts": 3, 129 | "reconnect_delay": 1.0, 130 | "connection_timeout": 20.0 131 | } 132 | ``` 133 | 134 | 2. **Logging Setup** 135 | - `utils/logger.py`: Configures logging handlers 136 | - Creates log directory if not exists 137 | - Initializes both file and console logging 138 | - Log files stored in `./g1_connector.log` 139 | 140 | 3. **Bluetooth Discovery** 141 | - `connector/bluetooth.py`: `BLEManager.scan_for_glasses()` 142 | - Searches for devices matching G1 identifier 143 | - Returns list of discovered G1 glasses with addresses 144 | 145 | 4. **Pairing Process** 146 | - `connector/pairing.py`: Handles initial device pairing 147 | - Establishes secure connection with both left and right glasses 148 | - Validates device authenticity 149 | - Stores pairing information 150 | 151 | 5. **Connection Confirmation** 152 | - `connector/bluetooth.py`: `_connect_glass()` 153 | - Verifies successful connection to both glasses 154 | - Initializes UART service 155 | - Sets up notification handlers 156 | 157 | 6. **Device ID Storage** 158 | - Stores validated glass IDs in config file 159 | - Left glass address: `left_address` 160 | - Right glass address: `right_address` 161 | 162 | ### Subsequent Connections 163 | 1. **Direct Connection** 164 | - Reads stored glass IDs from config 165 | - `connector/bluetooth.py`: Attempts direct connection 166 | - Uses configured retry logic: 167 | ```python 168 | for attempt in range(self.connector.config.reconnect_attempts): 169 | # Connection attempt logic 170 | ``` 171 | 172 | 2. **Connection Maintenance** 173 | - `connector/commands.py`: Sends periodic heartbeat 174 | - Default heartbeat command: `HEARTBEAT_CMD = bytes([0x25, 0x06, 0x00, 0x01, 0x04, 0x01])` 175 | - Monitors connection status 176 | - Automatic reconnection on disconnect 177 | 178 | 3. **Event Monitoring** 179 | - `services/state.py`: Manages state tracking 180 | - `services/uart.py`: Handles UART notifications 181 | - Event types defined in `utils/constants.py`: 182 | - Physical states (wearing, charging) 183 | - Device states (connected, operational) 184 | - Interactions (taps, gestures) 185 | - Battery status 186 | 187 | ## Examples 188 | 189 | ### Basic Usage 190 | 191 | #### Simple Connect 192 | `simple_connect.py` 193 | Basic connection demonstration 194 | 195 | ```bash 196 | python -m examples.simple_connect 197 | ``` 198 | 199 | #### Monitor State Changes 200 | `interactions.py` 201 | Monitor and log device interactions 202 | 203 | ```bash 204 | python -m examples.interactions 205 | ``` 206 | 207 | #### Dashboard 208 | `dashboard.py` 209 | Monitor statuses and logs 210 | 211 | ```bash 212 | python -m examples.dashboard 213 | ``` 214 | 215 | ### Display Features 216 | 217 | #### Text Display 218 | `send_text.py` 219 | Text display with multi-line support 220 | 221 | ```bash 222 | python -m examples.send_text 223 | ``` 224 | 225 | #### PowerPoint Display (In progress) 226 | `ppt_teleprompter.py` 227 | Send speaker notes from an active PowerPoint presentation to the glasses 228 | 229 | ```bash 230 | python -m examples.ppt_teleprompter 231 | ``` 232 | 233 | #### Image Display 234 | `send_image.py` (TODO) 235 | Image transmission (1-bit, 576x136 BMP) 236 | 237 | ```bash 238 | python -m examples.send_image 239 | ``` 240 | 241 | ### Advanced Features 242 | 243 | #### Microphone 244 | `microphone.py` (TODO) 245 | Audio recording demonstration 246 | 247 | ```bash 248 | python -m examples.microphone 249 | ``` 250 | 251 | #### Even AI 252 | `even_ai.py` (TODO) 253 | Even AI integration example 254 | 255 | ```bash 256 | python -m examples.even_ai 257 | ``` 258 | 259 | ## Protocol Details 260 | 261 | ### Display Protocol 262 | - Text display supports configurable font sizes and line counts 263 | - Images must be 1-bit, 576x136 pixel BMP format 264 | - Packet-based transmission with CRC verification 265 | 266 | ### Audio Protocol 267 | - LC3 format audio streaming 268 | - Right-side microphone activation 269 | - 30-second maximum recording duration 270 | 271 | ### State Management 272 | - Physical state tracking (wearing, charging, etc.) 273 | - Battery level monitoring 274 | - Interaction event processing 275 | 276 | ## Configuration 277 | 278 | Default settings can be modified in `g1_config.json`: 279 | - `device_name`: Customize the device name for pairing 280 | - `device_address`: Manually set the device address 281 | - `auto_reconnect`: Enable or disable automatic reconnection 282 | - `log_level`: Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) 283 | - `log_file`: Specify a custom log file path 284 | - `log_to_console`: Enable or disable logging to the console 285 | - `log_to_file`: Enable or disable logging to a file 286 | 287 | 288 | ## Roadmap & Planned Improvements 289 | 290 | ### Immediate Priority 291 | - [ ] clean up the console logs for a new connection, many duplicates and logs with markup 292 | - [ ] Implement text sending functionality 293 | - [ ] Add comprehensive examples for text display and interaction 294 | - [ ] Document text positioning and formatting options 295 | 296 | ### High Priority (Developer Experience) 297 | 1. Connection Management 298 | - [ ] Implement Windows BLE connection recovery 299 | * Detect and reuse existing connections 300 | * Add configurable retry timing for connection attempts 301 | * Implement force-disconnect on SDK exit 302 | * Add connection state detection and logging 303 | 304 | 2. Pairing Improvements 305 | - [ ] Add robust pairing state detection and management 306 | * Detect proper pairing state vs. just connected state 307 | * Add automatic re-pairing when connection is incomplete avoiding the need to delete the config file 308 | * Add user guidance for connection troubleshooting 309 | 310 | 3. Documentation Improvements (add more detail to this readme doc) 311 | - [ ] Add inline examples in docstrings 312 | - [ ] add more detail/quickstarts/examples to this readme doc 313 | - [ ] Document error scenarios and solutions 314 | 315 | 4. Developer Tools 316 | - [ ] Create higher-level abstractions for common patterns 317 | - [ ] Add more example applications 318 | - [ ] Implement helper utilities for content formatting 319 | 320 | 5. Reliability Enhancements 321 | - [ ] Improve connection state recovery 322 | - [ ] Add automated reconnection strategies 323 | - [ ] Enhance error handling with specific solutions 324 | - [ ] Implement better battery and health monitoring (if supported by the device, events are a bit vague) 325 | 326 | 6. Code Structure 327 | - [ ] Split G1Connector into focused components 328 | - [ ] Add more type hints and dataclasses 329 | - [ ] Implement async context managers 330 | - [ ] Add configuration validation 331 | 332 | 333 | ### Medium Priority 334 | - [ ] Structured telemetry collection 335 | - [ ] Performance benchmarks 336 | - [ ] Circuit breakers for problematic operations 337 | - [ ] Formal dependency injection 338 | - [ ] Structured logging 339 | - [ ] Performance optimization 340 | -------------------------------------------------------------------------------- /examples/dashboard.py: -------------------------------------------------------------------------------- 1 | """Dashboard example for G1 glasses""" 2 | import asyncio 3 | from rich.live import Live 4 | from rich.table import Table 5 | from rich.console import Console 6 | from rich.layout import Layout 7 | from rich.panel import Panel 8 | from rich.logging import RichHandler 9 | from collections import deque 10 | import logging 11 | from connector import G1Connector 12 | from utils.constants import ( 13 | UUIDS, COMMANDS, EventCategories, StateEvent, 14 | ConnectionState, StateColors, StateDisplay 15 | ) 16 | import time 17 | from rich.box import ROUNDED 18 | from utils.logger import set_dashboard_mode 19 | 20 | class LogPanel: 21 | """Panel to display recent log messages""" 22 | def __init__(self, max_lines=10): 23 | self.logs = deque(maxlen=max_lines) 24 | # Create a custom handler that adds logs to our deque 25 | self.handler = logging.Handler() 26 | self.handler.emit = self.emit 27 | 28 | def emit(self, record): 29 | """Custom emit method to handle log records""" 30 | try: 31 | msg = record.getMessage() 32 | 33 | # Skip status data logs entirely 34 | if "Status data:" in msg: 35 | return 36 | 37 | # Handle disconnect messages as errors 38 | if "glass disconnected" in msg.lower(): 39 | self.logs.append(f"[red]{msg}[/red]") 40 | return 41 | 42 | # Skip certain messages during initial connection 43 | if any(skip in msg for skip in [ 44 | "Connection state changed to", 45 | "Physical state changed to", 46 | "Battery state changed to", 47 | "Glasses now in", 48 | "Verifying pairing", 49 | "Started notifications", 50 | "Status data:", 51 | "Connecting to G1" 52 | ]): 53 | return 54 | 55 | # Show interaction events in cyan 56 | if "Interaction detected:" in msg: 57 | self.logs.append(f"[cyan]{msg}[/cyan]") 58 | return 59 | 60 | # Show battery events in yellow 61 | if "Battery state changed to:" in msg: 62 | self.logs.append(f"[yellow]{msg}[/yellow]") 63 | return 64 | 65 | # Show physical state changes in blue 66 | if "Physical state changed to:" in msg: 67 | self.logs.append(f"[blue]{msg}[/blue]") 68 | return 69 | 70 | # Only show essential connection messages 71 | if "Connecting to G1" in msg: 72 | self.logs.append("[yellow]Connecting to G1, please wait...[/yellow]") 73 | return 74 | 75 | if "Connected successfully" in msg: 76 | self.logs.append("[green]Connected successfully[/green]") 77 | return 78 | 79 | if "Error connecting" in msg or "Connection failed" in msg: 80 | self.logs.append("[red]Error connecting, retrying...[/red]") 81 | return 82 | 83 | # Format other messages 84 | if record.levelno >= logging.ERROR: 85 | msg = f"[red]{msg}[/red]" 86 | elif record.levelno >= logging.WARNING: 87 | msg = f"[orange3]{msg}[/orange3]" 88 | elif "success" in msg.lower(): 89 | msg = f"[green]{msg}[/green]" 90 | else: 91 | msg = f"[white]{msg}[/white]" 92 | 93 | self.logs.append(msg) 94 | 95 | except Exception as e: 96 | print(f"Error in log panel: {e}") 97 | 98 | def __rich__(self): 99 | return Panel( 100 | "\n".join(self.logs), 101 | title="Recent Logs", 102 | border_style="blue" 103 | ) 104 | 105 | def create_layout(glasses, log_panel) -> Layout: 106 | """Create dashboard layout with status and logs""" 107 | layout = Layout() 108 | 109 | # Create status panel 110 | status_panel = Panel( 111 | create_status_table(glasses), 112 | title="Status", 113 | border_style="blue" 114 | ) 115 | 116 | # Use log_panel directly as it's already a Panel 117 | 118 | # Split into left (status) and right (logs) panels 119 | layout.split_row( 120 | Layout(status_panel, ratio=3), 121 | Layout(log_panel, ratio=2) # log_panel is already a Panel 122 | ) 123 | 124 | return layout 125 | 126 | def create_status_table(glasses: G1Connector) -> Table: 127 | """Create status table from G1 connector state""" 128 | try: 129 | table = Table(box=ROUNDED) 130 | table.add_column("[bold cyan]Item", style="cyan") 131 | table.add_column("[bold cyan]Status", style="white") 132 | 133 | # Connection status with error counts 134 | left_name = glasses.config.left_name or "Unknown" 135 | right_name = glasses.config.right_name or "Unknown" 136 | left_errors = glasses.state_manager.error_counts["left"] 137 | right_errors = glasses.state_manager.error_counts["right"] 138 | 139 | left_status = f"[green]Connected ({left_name})[/green]" if glasses.left_client and glasses.left_client.is_connected else "[red]Disconnected[/red]" 140 | right_status = f"[green]Connected ({right_name})[/green]" if glasses.right_client and glasses.right_client.is_connected else "[red]Disconnected[/red]" 141 | 142 | # Add error counts in red if there are any 143 | if left_errors > 0: 144 | left_status += f" [red]({left_errors} errors)[/red]" 145 | if right_errors > 0: 146 | right_status += f" [red]({right_errors} errors)[/red]" 147 | 148 | table.add_row("Left Glass", left_status) 149 | table.add_row("Right Glass", right_status) 150 | 151 | # Physical state 152 | state = glasses.state_manager.physical_state 153 | state_info = StateDisplay.PHYSICAL_STATES.get(state, StateDisplay.PHYSICAL_STATES["UNKNOWN"]) 154 | color, label = state_info 155 | table.add_row("State", f"[{color}]{label}[/{color}]") 156 | 157 | # Battery state 158 | battery = glasses.state_manager.battery_state 159 | if battery in StateEvent.BATTERY_STATES: 160 | _, label = StateEvent.BATTERY_STATES[battery] 161 | table.add_row("Battery", f"[{StateColors.INFO}]{label}[/{StateColors.INFO}]") 162 | else: 163 | table.add_row("Battery", f"[{StateColors.NEUTRAL}]Unknown[/{StateColors.NEUTRAL}]") 164 | 165 | # Last heartbeat 166 | last_heartbeat = glasses.state_manager.last_heartbeat 167 | if last_heartbeat: 168 | time_since = time.time() - last_heartbeat 169 | heartbeat_status = f"{time_since:.1f}s ago" 170 | color = StateColors.SUCCESS if time_since < 5 else StateColors.WARNING 171 | else: 172 | heartbeat_status = "None" 173 | color = StateColors.NEUTRAL 174 | table.add_row("Last Heartbeat", f"[{color}]{heartbeat_status}[/{color}]") 175 | 176 | # Last interaction 177 | last_interaction = glasses.state_manager.last_interaction 178 | if last_interaction: 179 | table.add_row("Last Interaction", f"[{StateColors.INFO}]{last_interaction}[/{StateColors.INFO}]") 180 | else: 181 | table.add_row("Last Interaction", f"[{StateColors.NEUTRAL}]None[/{StateColors.NEUTRAL}]") 182 | 183 | # Device state 184 | device_state = glasses.state_manager.device_state 185 | if device_state and device_state != "UNKNOWN": 186 | table.add_row("Device State", f"[{StateColors.INFO}]{device_state}[/{StateColors.INFO}]") 187 | else: 188 | table.add_row("Device State", f"[{StateColors.NEUTRAL}]None[/{StateColors.NEUTRAL}]") 189 | 190 | # Silent mode 191 | silent_mode = "On" if glasses.state_manager.silent_mode else "Off" 192 | color = StateColors.WARNING if glasses.state_manager.silent_mode else StateColors.SUCCESS 193 | table.add_row("Silent Mode", f"[{color}]{silent_mode}[/{color}]") 194 | 195 | return table 196 | except Exception as e: 197 | glasses.logger.error(f"Error creating status table: {e}", exc_info=True) 198 | error_table = Table(box=ROUNDED) 199 | error_table.add_row("[red]Error creating status display[/red]") 200 | return error_table 201 | 202 | async def main(): 203 | """Run the dashboard example""" 204 | glasses = G1Connector() 205 | console = Console() 206 | log_panel = LogPanel(max_lines=15) 207 | 208 | try: 209 | console.clear() 210 | glasses.state_manager.set_dashboard_mode(True) 211 | 212 | success = await glasses.connect() 213 | 214 | if not success: 215 | console.print("[red]Failed to connect to glasses[/red]") 216 | return 217 | 218 | console.print("[green]Connected successfully[/green]") 219 | await asyncio.sleep(1) 220 | console.clear() 221 | 222 | glasses.logger.addHandler(log_panel.handler) 223 | 224 | with Live(create_layout(glasses, log_panel), console=console, refresh_per_second=4) as live: 225 | try: 226 | while True: 227 | live.update(create_layout(glasses, log_panel)) 228 | await asyncio.sleep(0.25) 229 | except KeyboardInterrupt: 230 | # Handle the interrupt gracefully 231 | live.stop() 232 | console.clear() 233 | console.print("[yellow]Shutting down...[/yellow]") 234 | 235 | except KeyboardInterrupt: 236 | console.print("[yellow]Shutting down...[/yellow]") 237 | except Exception as e: 238 | glasses.logger.error(f"Dashboard error: {e}", exc_info=True) 239 | finally: 240 | # Ensure clean shutdown 241 | try: 242 | glasses.logger.removeHandler(log_panel.handler) 243 | glasses.state_manager.set_dashboard_mode(False) 244 | glasses.state_manager.shutdown() 245 | await glasses.disconnect() 246 | console.print("[green]Dashboard exited[/green]") 247 | except Exception as e: 248 | console.print(f"[red]Error during shutdown: {e}[/red]") 249 | 250 | if __name__ == "__main__": 251 | try: 252 | asyncio.run(main()) 253 | except KeyboardInterrupt: 254 | print("\nExited by user") 255 | except Exception as e: 256 | print(f"\nError: {e}") -------------------------------------------------------------------------------- /services/state.py: -------------------------------------------------------------------------------- 1 | """State management service for G1 glasses""" 2 | from typing import Optional, List, Callable 3 | import asyncio 4 | import time 5 | from utils.constants import ConnectionState, StateEvent, EventCategories, COMMANDS, StateColors 6 | 7 | class StateManager: 8 | """Manages state for G1 glasses""" 9 | 10 | def __init__(self, connector): 11 | self.connector = connector 12 | self.logger = connector.logger 13 | self._connection_state = ConnectionState.DISCONNECTED 14 | self._physical_state = None 15 | self._last_interaction = None 16 | self._raw_state_callbacks: List[Callable] = [] 17 | self._state_callbacks: List[Callable] = [] 18 | 19 | # Restore additional state tracking 20 | self._battery_state = None 21 | self._last_interaction_side = None 22 | self._last_interaction_time = None 23 | self._shutting_down = False 24 | self._last_heartbeat = None 25 | self._ai_enabled = False 26 | self._unrecognized_states = set() 27 | 28 | # Dashboard-specific attributes 29 | self._dashboard_mode = False 30 | self.silent_mode = False 31 | self._last_device_state = None 32 | self._last_device_state_time = None 33 | self._last_device_state_label = "Unknown" 34 | self.battery_state = "Unknown" 35 | 36 | self._last_known_state = None 37 | self._last_update = None 38 | 39 | # Add error tracking 40 | self._error_counts = {"left": 0, "right": 0} 41 | self._last_error_time = {"left": None, "right": None} 42 | 43 | def add_raw_state_callback(self, callback: Callable): 44 | """Add callback for raw state updates""" 45 | if callback not in self._raw_state_callbacks: 46 | self._raw_state_callbacks.append(callback) 47 | 48 | def remove_raw_state_callback(self, callback: Callable): 49 | """Remove raw state callback""" 50 | if callback in self._raw_state_callbacks: 51 | self._raw_state_callbacks.remove(callback) 52 | 53 | def add_state_callback(self, callback: Callable): 54 | """Add callback for processed state updates""" 55 | if callback not in self._state_callbacks: 56 | self._state_callbacks.append(callback) 57 | 58 | def remove_state_callback(self, callback: Callable): 59 | """Remove state callback""" 60 | if callback in self._state_callbacks: 61 | self._state_callbacks.remove(callback) 62 | 63 | @property 64 | def connection_state(self) -> ConnectionState: 65 | """Get current connection state""" 66 | return self._connection_state 67 | 68 | @connection_state.setter 69 | def connection_state(self, state: ConnectionState): 70 | """Set connection state""" 71 | if state != self._connection_state: 72 | self._connection_state = state 73 | self.logger.info(f"Connection state changed to: {state}") 74 | self._notify_state_callbacks() 75 | 76 | def set_connection_state(self, state: ConnectionState): 77 | """Set connection state (method form)""" 78 | self.connection_state = state 79 | 80 | @property 81 | def physical_state(self) -> str: 82 | """Get current physical state""" 83 | if self._physical_state is None: 84 | return "UNKNOWN" 85 | # Get all three values but only return the name 86 | name, _, _ = StateEvent.PHYSICAL_STATES.get(self._physical_state, ("UNKNOWN", "Unknown", StateColors.ERROR)) 87 | return name 88 | 89 | @property 90 | def battery_state(self) -> str: 91 | """Get current battery state""" 92 | if self._battery_state is None: 93 | return "UNKNOWN" 94 | name, label = StateEvent.BATTERY_STATES.get(self._battery_state, ("UNKNOWN", "Unknown")) 95 | return label 96 | 97 | @property 98 | def device_state(self) -> str: 99 | """Get current device state""" 100 | if self._last_device_state is None: 101 | return "UNKNOWN" 102 | name, label = StateEvent.DEVICE_STATES.get(self._last_device_state, ("UNKNOWN", "Unknown")) 103 | return label 104 | 105 | @property 106 | def last_interaction(self) -> str: 107 | """Get last interaction""" 108 | if self._last_interaction: 109 | if self._last_interaction_side: 110 | return f"{self._last_interaction} ({self._last_interaction_side})" 111 | return self._last_interaction 112 | return "None" 113 | 114 | @property 115 | def last_heartbeat(self) -> float: 116 | """Get last heartbeat timestamp""" 117 | return self._last_heartbeat if self._last_heartbeat else None 118 | 119 | def update_physical_state(self, state: int): 120 | """Update physical state""" 121 | if state != self._physical_state: 122 | self._physical_state = state 123 | name, label = StateEvent.get_physical_state(state) 124 | self.logger.info(f"Physical state changed to: {label}") 125 | self._notify_state_callbacks() 126 | 127 | def update_interaction(self, interaction: str): 128 | """Update last interaction""" 129 | self._last_interaction = interaction 130 | self.logger.debug(f"Last interaction updated: {interaction}") 131 | self._notify_state_callbacks() 132 | 133 | def _notify_state_callbacks(self): 134 | """Notify all state callbacks""" 135 | for callback in self._state_callbacks: 136 | try: 137 | callback() 138 | except Exception as e: 139 | self.logger.error(f"Error in state callback: {e}") 140 | 141 | async def process_raw_state(self, data: bytes, side: str): 142 | """Process raw state data from glasses""" 143 | try: 144 | hex_data = data.hex() 145 | self.logger.debug(f"Processing raw state from {side}: {hex_data}") 146 | 147 | # Handle state change events (0xF5) 148 | if data[0] == 0xF5: 149 | state_code = data[1] 150 | 151 | # Get state label based on type 152 | label = "Unknown" 153 | if state_code in StateEvent.PHYSICAL_STATES: 154 | _, label, _ = StateEvent.PHYSICAL_STATES[state_code] 155 | self._physical_state = state_code 156 | self.logger.debug(f"Updated physical state to: {label} ({hex_data})") 157 | elif state_code in StateEvent.BATTERY_STATES: 158 | _, label = StateEvent.BATTERY_STATES[state_code] 159 | self._battery_state = state_code 160 | self.logger.debug(f"Updated battery state to: {label} ({hex_data})") 161 | elif state_code in StateEvent.DEVICE_STATES: 162 | _, label = StateEvent.DEVICE_STATES[state_code] 163 | self._last_device_state = state_code 164 | self.logger.debug(f"Updated device state to: {label} ({hex_data})") 165 | elif state_code in StateEvent.INTERACTIONS: 166 | _, label = StateEvent.INTERACTIONS[state_code] 167 | self._last_interaction = label 168 | self.logger.debug(f"Updated interaction to: {label} ({side})") 169 | 170 | # Notify raw state callbacks with the processed state code and label 171 | for callback in self._raw_state_callbacks: 172 | try: 173 | await callback(state_code, side, label) 174 | except Exception as e: 175 | self.logger.error(f"Error in raw state callback: {e}") 176 | 177 | self._notify_state_callbacks() 178 | 179 | # Handle heartbeat responses (0x25) 180 | elif data[0] == COMMANDS.HEARTBEAT: 181 | self._last_heartbeat = time.time() 182 | self.logger.debug(f"Updated heartbeat from {side}") 183 | self._notify_state_callbacks() 184 | 185 | # Handle silent mode changes 186 | elif data[0] == COMMANDS.SILENT_MODE_ON: 187 | self.silent_mode = True 188 | self.logger.debug("Silent mode enabled") 189 | self._notify_state_callbacks() # Notify callbacks for UI update 190 | elif data[0] == COMMANDS.SILENT_MODE_OFF: 191 | self.silent_mode = False 192 | self.logger.debug("Silent mode disabled") 193 | self._notify_state_callbacks() # Notify callbacks for UI update 194 | 195 | except Exception as e: 196 | self.logger.error(f"Error processing state: {e}") 197 | 198 | def set_dashboard_mode(self, enabled: bool): 199 | """Enable or disable dashboard mode""" 200 | self._dashboard_mode = enabled 201 | if enabled: 202 | self.logger.debug("Dashboard mode enabled") 203 | else: 204 | self.logger.debug("Dashboard mode disabled") 205 | 206 | def shutdown(self): 207 | """Clean shutdown of state manager""" 208 | self._shutting_down = True 209 | self._connection_state = ConnectionState.DISCONNECTED 210 | self._dashboard_mode = False 211 | self._state_callbacks.clear() 212 | 213 | @property 214 | def battery_state(self): 215 | """Get current battery state""" 216 | return self._battery_state 217 | 218 | @battery_state.setter 219 | def battery_state(self, value): 220 | """Set battery state""" 221 | self._battery_state = value 222 | 223 | async def handle_state_change(self, new_state: int, side: str = None): 224 | """Handle state change from glasses""" 225 | if self._shutting_down: 226 | return 227 | 228 | try: 229 | # Update appropriate state based on category 230 | if new_state in StateEvent.BATTERY_STATES: 231 | self._battery_state = new_state 232 | 233 | if new_state in StateEvent.INTERACTIONS: 234 | self._last_interaction = new_state 235 | self._last_interaction_side = side 236 | self._last_interaction_time = time.time() 237 | 238 | # Track unrecognized states 239 | if not any(new_state in category for category in [ 240 | StateEvent.PHYSICAL_STATES, 241 | StateEvent.DEVICE_STATES, 242 | StateEvent.BATTERY_STATES, 243 | StateEvent.INTERACTIONS 244 | ]): 245 | if new_state not in self._unrecognized_states: 246 | self._unrecognized_states.add(new_state) 247 | self.logger.debug(f"Unrecognized state: 0x{new_state:02x} ({new_state}) from {side} glass") 248 | 249 | await self._notify_state_callbacks() 250 | 251 | except Exception as e: 252 | self.logger.error(f"Error handling state change: {e}") 253 | 254 | def increment_error_count(self, side: str): 255 | """Increment error count for specified side""" 256 | if side in self._error_counts: 257 | self._error_counts[side] += 1 258 | self._last_error_time[side] = time.time() 259 | self.logger.debug(f"Error count for {side}: {self._error_counts[side]}") 260 | self._notify_state_callbacks() 261 | 262 | @property 263 | def error_counts(self): 264 | """Get current error counts""" 265 | return self._error_counts 266 | -------------------------------------------------------------------------------- /connector/pairing.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pairing and device management for G1 glasses 3 | """ 4 | import asyncio 5 | from typing import Optional, Dict 6 | from bleak import BleakScanner, BleakClient 7 | from utils.constants import EventCategories 8 | 9 | class PairingManager: 10 | """Handles device pairing and management""" 11 | 12 | def __init__(self, connector): 13 | self.connector = connector 14 | self.logger = connector.logger 15 | self._pairing_lock = asyncio.Lock() 16 | self._discovery_cache = {} 17 | self._last_scan = 0 18 | 19 | async def _verify_windows_pairing(self, address: str) -> bool: 20 | """Verify device is paired in Windows""" 21 | try: 22 | # Use BleakScanner to get paired devices 23 | devices = await BleakScanner.discover(timeout=5.0) 24 | for device in devices: 25 | if device.address.lower() == address.lower(): 26 | # Check if device is paired using Bleak's internal API 27 | if hasattr(device, '_device_info') and hasattr(device._device_info, 'pairing'): 28 | return device._device_info.pairing.is_paired 29 | return True # Fallback if we can't check pairing status 30 | return False 31 | except Exception as e: 32 | self.logger.error(f"Error verifying Windows pairing: {e}") 33 | return False 34 | 35 | async def verify_pairing(self) -> bool: 36 | """Verify existing pairing is valid""" 37 | try: 38 | self.logger.debug("Verifying pairing...") 39 | 40 | if not self.connector.config.left_address or not self.connector.config.right_address: 41 | self.logger.debug("No saved addresses found") 42 | return False 43 | 44 | # First verification 45 | for side, addr in [("left", self.connector.config.left_address), 46 | ("right", self.connector.config.right_address)]: 47 | try: 48 | client = BleakClient(addr) 49 | await client.connect(timeout=5.0) 50 | await client.disconnect() 51 | self.logger.debug(f"Successfully verified {side} glass pairing") 52 | except Exception as e: 53 | self.logger.warning(f"Could not verify {side} glass pairing: {e}") 54 | return False 55 | 56 | # If not paired, do first-time pairing 57 | if not self.connector.config.left_paired or not self.connector.config.right_paired: 58 | self.logger.info("\nFirst time connection detected!") 59 | self.logger.info("The glasses will be paired with your device. This only happens once.") 60 | self.logger.info("Please wait while the pairing is completed...") 61 | 62 | # Second verification with pairing 63 | for side, addr in [("left", self.connector.config.left_address), 64 | ("right", self.connector.config.right_address)]: 65 | try: 66 | client = BleakClient(addr) 67 | await client.connect(timeout=5.0) 68 | await client.pair() 69 | await client.disconnect() 70 | self.logger.debug(f"Successfully verified {side} glass pairing") 71 | if side == "left": 72 | self.connector.config.left_paired = True 73 | else: 74 | self.connector.config.right_paired = True 75 | except Exception as e: 76 | self.logger.warning(f"Could not verify {side} glass pairing: {e}") 77 | return False 78 | 79 | self.connector.config.save() 80 | self.logger.info("Pairing verification successful") 81 | 82 | return True 83 | 84 | except Exception as e: 85 | self.logger.error(f"Error verifying pairing: {e}") 86 | return False 87 | 88 | async def _attempt_pairing(self, client: BleakClient, glass_name: str, max_attempts: int = 3) -> bool: 89 | """Attempt to pair with a glass""" 90 | try: 91 | is_left = glass_name == "Left glass" 92 | address = client.address 93 | side = "left" if is_left else "right" 94 | 95 | self.logger.debug(f"Starting first-time pairing for {glass_name}") 96 | self.connector.console.print(f"\n[yellow]Performing first-time pairing for {glass_name}...[/yellow]") 97 | 98 | for attempt in range(1, max_attempts + 1): 99 | try: 100 | # Add delay between attempts 101 | if attempt > 1: 102 | await asyncio.sleep(2) 103 | 104 | client = BleakClient(address) 105 | 106 | # First try to connect without pairing 107 | await client.connect(timeout=20.0) 108 | 109 | if client.is_connected: 110 | self.logger.debug("Connection established, attempting pairing...") 111 | 112 | try: 113 | # Try to pair 114 | await client.pair() 115 | except Exception as pair_error: 116 | # If pairing fails with error 19, try to disconnect and retry 117 | if "19" in str(pair_error): 118 | self.logger.debug(f"Pairing error 19, attempting recovery for {glass_name}") 119 | await client.disconnect() 120 | await asyncio.sleep(2) # Wait for Windows to clean up 121 | 122 | # Try to connect and pair again 123 | await client.connect(timeout=20.0) 124 | await client.pair() 125 | 126 | self.logger.debug("Pairing successful") 127 | 128 | # Update config 129 | if is_left: 130 | self.connector.config.left_paired = True 131 | else: 132 | self.connector.config.right_paired = True 133 | self.connector.config.save() 134 | 135 | # Disconnect to finalize pairing 136 | await client.disconnect() 137 | await asyncio.sleep(2) # Increased delay after disconnect 138 | 139 | self.connector.console.print(f"[green]{glass_name} paired and connected![/green]") 140 | 141 | # Notify event service of successful pairing 142 | if self.connector.event_service: 143 | await self.connector.event_service._handle_pairing_complete(side, True) 144 | 145 | return True 146 | 147 | except Exception as e: 148 | self.logger.error(f"Connection attempt {attempt} failed: {e}") 149 | if attempt < max_attempts: 150 | self.connector.console.print("[yellow]Retrying connection...[/yellow]") 151 | await asyncio.sleep(2) 152 | continue 153 | 154 | # Notify event service of failed pairing 155 | if self.connector.event_service: 156 | await self.connector.event_service._handle_pairing_complete(side, False) 157 | 158 | return False 159 | 160 | except Exception as e: 161 | self.logger.error(f"Pairing attempt failed: {e}") 162 | return False 163 | 164 | async def discover_glasses(self, timeout: float = 15.0) -> Dict[str, Dict]: 165 | """Scan for available G1 glasses""" 166 | try: 167 | async with self._pairing_lock: 168 | self.logger.info("Starting glasses discovery...") 169 | devices = await BleakScanner.discover(timeout=timeout) 170 | 171 | discovered = {} 172 | for device in devices: 173 | if device.name: 174 | if "_L_" in device.name: 175 | discovered['left'] = { 176 | 'address': device.address, 177 | 'name': device.name, 178 | 'rssi': device.rssi 179 | } 180 | self.logger.info(f"Found left glass: {device.name}") 181 | elif "_R_" in device.name: 182 | discovered['right'] = { 183 | 'address': device.address, 184 | 'name': device.name, 185 | 'rssi': device.rssi 186 | } 187 | self.logger.info(f"Found right glass: {device.name}") 188 | 189 | self._discovery_cache = discovered 190 | self._last_scan = asyncio.get_event_loop().time() 191 | 192 | # Notify event service of discovery completion 193 | if self.connector.event_service: 194 | await self.connector.event_service._handle_discovery_complete(discovered) 195 | 196 | return discovered 197 | 198 | except Exception as e: 199 | self.logger.error(f"Discovery failed: {e}") 200 | return {} 201 | 202 | async def pair_glasses(self) -> bool: 203 | """Pair with discovered glasses""" 204 | try: 205 | # Check if we need a new scan 206 | if not self._discovery_cache or \ 207 | asyncio.get_event_loop().time() - self._last_scan > 60: 208 | await self.discover_glasses() 209 | 210 | if 'left' not in self._discovery_cache or 'right' not in self._discovery_cache: 211 | self.logger.error("Could not find both glasses") 212 | return False 213 | 214 | # Update config with discovered devices 215 | self.connector.config.left_address = self._discovery_cache['left']['address'] 216 | self.connector.config.right_address = self._discovery_cache['right']['address'] 217 | self.connector.config.left_name = self._discovery_cache['left']['name'] 218 | self.connector.config.right_name = self._discovery_cache['right']['name'] 219 | 220 | # Create clients for pairing 221 | left_client = BleakClient(self.connector.config.left_address) 222 | right_client = BleakClient(self.connector.config.right_address) 223 | 224 | # Attempt pairing 225 | if not await self._attempt_pairing(left_client, "Left glass"): 226 | return False 227 | 228 | if not await self._attempt_pairing(right_client, "Right glass"): 229 | return False 230 | 231 | self.logger.info("Successfully paired with both glasses") 232 | return True 233 | 234 | except Exception as e: 235 | self.logger.error(f"Pairing failed: {e}") 236 | return False 237 | 238 | async def unpair_glasses(self) -> None: 239 | """Unpair from glasses""" 240 | try: 241 | self.connector.config.left_paired = False 242 | self.connector.config.right_paired = False 243 | self.connector.config.left_address = None 244 | self.connector.config.right_address = None 245 | self.connector.config.left_name = None 246 | self.connector.config.right_name = None 247 | 248 | self.logger.info("Unpaired from glasses") 249 | 250 | except Exception as e: 251 | self.logger.error(f"Error unpairing: {e}") -------------------------------------------------------------------------------- /connector/bluetooth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Bluetooth specific functionality for G1 glasses 3 | """ 4 | import asyncio 5 | import time 6 | from bleak import BleakClient, BleakScanner 7 | from typing import Optional 8 | from rich.table import Table 9 | 10 | from utils.constants import ( 11 | UUIDS, COMMANDS, EventCategories, StateEvent, 12 | StateColors, StateDisplay, ConnectionState 13 | ) 14 | from utils.logger import user_guidance 15 | from connector.pairing import PairingManager 16 | 17 | class BLEManager: 18 | """Manages BLE connections for G1 glasses""" 19 | 20 | def __init__(self, connector): 21 | """Initialize BLE manager""" 22 | self.connector = connector 23 | self.logger = connector.logger 24 | self._error_count = 0 25 | self._last_error = None 26 | self._silent_mode = False 27 | self._last_heartbeat = None 28 | self._monitoring_task = None 29 | self.pairing_manager = PairingManager(connector) 30 | self._shutting_down = False 31 | 32 | async def scan_for_glasses(self) -> bool: 33 | """Scan for G1 glasses and save their addresses""" 34 | try: 35 | self.connector.state_manager.set_connection_state(ConnectionState.SCANNING) 36 | self.logger.info("Starting scan for glasses...") 37 | user_guidance(self.logger, "\n[yellow]Scanning for G1 glasses...[/yellow]") 38 | 39 | left_found = right_found = False 40 | devices = await BleakScanner.discover(timeout=15.0) 41 | 42 | # Log all found devices for debugging 43 | self.logger.debug("Found devices:") 44 | for device in devices: 45 | self.logger.debug(f" {device.name} ({device.address})") 46 | if device.name: 47 | if "_L_" in device.name: 48 | self.connector.config.left_address = device.address 49 | self.connector.config.left_name = device.name 50 | left_found = True 51 | user_guidance(self.logger, f"[green]Found left glass: {device.name}[/green]") 52 | elif "_R_" in device.name: 53 | self.connector.config.right_address = device.address 54 | self.connector.config.right_name = device.name 55 | right_found = True 56 | user_guidance(self.logger, f"[green]Found right glass: {device.name}[/green]") 57 | 58 | if not (left_found and right_found): 59 | self.connector.state_manager.set_connection_state(ConnectionState.DISCONNECTED) 60 | user_guidance(self.logger, "\n[yellow]Glasses not found. Please ensure:[/yellow]") 61 | user_guidance(self.logger, "1. Glasses are properly prepared and seated in the powered cradle:") 62 | user_guidance(self.logger, " - First close the left temple/arm") 63 | user_guidance(self.logger, " - Then close the right temple/arm") 64 | user_guidance(self.logger, " - Place glasses in cradle with both arms closed") 65 | user_guidance(self.logger, "2. Bluetooth is enabled on your computer (sometimes wifi on your computer can interfere with bluetooth)") 66 | user_guidance(self.logger, "3. Bluetooth is disabled on other nearby devices that have paired with the glasses") 67 | user_guidance(self.logger, "4. Glasses have not been added to Windows Bluetooth manager (remove if present)") 68 | user_guidance(self.logger, "5. If still not working, connect with the offical app and restart the glasses, then try again.") 69 | return False 70 | 71 | self.connector.config.save() 72 | return True 73 | 74 | except Exception as e: 75 | self.logger.error(f"Scan failed: {e}") 76 | self.connector.state_manager.set_connection_state(ConnectionState.DISCONNECTED) 77 | user_guidance(self.logger, f"\n[red]Error during scan: {e}[/red]") 78 | return False 79 | 80 | async def connect_to_glasses(self) -> bool: 81 | """Connect to both glasses""" 82 | try: 83 | self.logger.info("[yellow]Connecting to G1, please wait...[/yellow]") 84 | 85 | self.connector.state_manager.set_connection_state(ConnectionState.CONNECTING) 86 | 87 | # First verify/attempt pairing 88 | if not await self.pairing_manager.verify_pairing(): 89 | self.logger.error("[red]Error connecting, retrying...[/red]") 90 | self.connector.state_manager.set_connection_state(ConnectionState.DISCONNECTED) 91 | return False 92 | 93 | # Connect to both glasses 94 | success = await self._connect_glass('left') and await self._connect_glass('right') 95 | 96 | if success: 97 | # Start command manager and heartbeat 98 | await self.connector.command_manager.start() 99 | self.logger.info("[green]Connected successfully[/green]") 100 | self.connector.state_manager.set_connection_state(ConnectionState.CONNECTED) 101 | # Start monitoring 102 | self._monitoring_task = asyncio.create_task(self._monitor_connection_quality()) 103 | else: 104 | self.logger.error("[red]Error connecting, retrying...[/red]") 105 | self.connector.state_manager.set_connection_state(ConnectionState.DISCONNECTED) 106 | 107 | return success 108 | 109 | except Exception as e: 110 | self.logger.error(f"[red]Connection failed: {e}[/red]") 111 | self.connector.state_manager.set_connection_state(ConnectionState.DISCONNECTED) 112 | return False 113 | 114 | async def disconnect(self): 115 | """Disconnect from glasses""" 116 | try: 117 | self._shutting_down = True 118 | 119 | # Cancel monitoring task 120 | if self._monitoring_task: 121 | self._monitoring_task.cancel() 122 | try: 123 | await self._monitoring_task 124 | except asyncio.CancelledError: 125 | pass 126 | self._monitoring_task = None 127 | 128 | # Stop command manager 129 | await self.connector.command_manager.stop() 130 | 131 | # Disconnect both glasses 132 | for side in ['left', 'right']: 133 | client = getattr(self.connector, f"{side}_client", None) 134 | if client and client.is_connected: 135 | await client.disconnect() 136 | setattr(self.connector, f"{side}_client", None) 137 | 138 | self.connector.state_manager.set_connection_state(ConnectionState.DISCONNECTED) 139 | 140 | except Exception as e: 141 | self.logger.error(f"Error during disconnect: {e}") 142 | 143 | def _create_status_table(self) -> Table: 144 | """Create status table with all required information""" 145 | table = Table(box=True, border_style="blue", title="G1 Glasses Status") 146 | 147 | # Connection states with colors 148 | for side in ['Left', 'Right']: 149 | client = getattr(self.connector, f"{side.lower()}_client", None) 150 | status = "[green]Connected[/green]" if client and client.is_connected else "[red]Disconnected[/red]" 151 | table.add_row(f"{side} Glass", status) 152 | 153 | # Physical state with appropriate color 154 | system_name, _ = StateEvent.get_physical_state(self.connector.state_manager._physical_state) 155 | state_colors = { 156 | "WEARING": "green", 157 | "TRANSITIONING": "yellow", 158 | "CRADLE": "blue", 159 | "CRADLE_CHARGING": "yellow", 160 | "CRADLE_FULL": "bright_blue", 161 | "UNKNOWN": "red" 162 | } 163 | color = state_colors.get(system_name, "white") 164 | state = self.connector.state_manager.physical_state 165 | table.add_row("State", f"[{color}]{state}[/{color}]") 166 | 167 | # Add last interaction if any 168 | interaction = self.connector.state_manager.last_interaction 169 | if interaction and interaction != "None": 170 | table.add_row("Last Interaction", f"[{StateColors.HIGHLIGHT}]{interaction}[/{StateColors.HIGHLIGHT}]") 171 | 172 | # Last heartbeat timing 173 | if self._last_heartbeat: 174 | elapsed = time.time() - self._last_heartbeat 175 | table.add_row("Last Heartbeat", f"{elapsed:.1f}s ago") 176 | 177 | # Silent mode status 178 | table.add_row("Silent Mode", 179 | f"[{StateColors.WARNING}]On[/{StateColors.WARNING}]" if self._silent_mode 180 | else f"[{StateColors.NEUTRAL}]Off[/{StateColors.NEUTRAL}]") 181 | 182 | # Error information 183 | if self._error_count > 0: 184 | table.add_row("Errors", f"[{StateColors.ERROR}]{self._error_count}[/{StateColors.ERROR}]") 185 | if self._last_error: 186 | table.add_row("Last Error", f"[{StateColors.ERROR}]{self._last_error}[/{StateColors.ERROR}]") 187 | 188 | return table 189 | 190 | async def _verify_connection(self, client: BleakClient, glass_name: str) -> bool: 191 | """Verify connection and services are available""" 192 | try: 193 | max_retries = 3 194 | for attempt in range(max_retries): 195 | try: 196 | self.logger.debug(f"Verifying {glass_name} connection...") 197 | 198 | # Get UART service 199 | uart_service = client.services.get_service(UUIDS.UART_SERVICE) 200 | if not uart_service: 201 | if attempt < max_retries - 1: 202 | self.logger.debug(f"UART service not found for {glass_name}, retrying...") 203 | continue 204 | self.logger.error(f"UART service not found for {glass_name}") 205 | return False 206 | 207 | # Verify characteristics 208 | uart_tx = uart_service.get_characteristic(UUIDS.UART_TX) 209 | uart_rx = uart_service.get_characteristic(UUIDS.UART_RX) 210 | 211 | if not uart_tx or not uart_rx: 212 | if attempt < max_retries - 1: 213 | self.logger.debug(f"UART characteristics not found for {glass_name}, retrying...") 214 | continue 215 | self.logger.error(f"UART characteristics not found for {glass_name}") 216 | return False 217 | 218 | self.logger.info(f"{glass_name} connection verified successfully") 219 | return True 220 | 221 | except Exception as e: 222 | if attempt < max_retries - 1: 223 | self.logger.debug(f"Error verifying {glass_name} connection (attempt {attempt + 1}): {e}") 224 | await asyncio.sleep(1) 225 | continue 226 | self.logger.error(f"Error verifying {glass_name} connection: {e}") 227 | return False 228 | 229 | return False 230 | 231 | except Exception as e: 232 | self.logger.error(f"Error in verification process for {glass_name}: {e}") 233 | return False 234 | 235 | async def send_heartbeat(self, client: BleakClient) -> None: 236 | """Send heartbeat command to specified glass""" 237 | await self.connector.command_manager.send_heartbeat(client) 238 | 239 | async def reconnect(self) -> bool: 240 | """Reconnect to both glasses""" 241 | try: 242 | await self.disconnect() 243 | return await self.connect_to_glasses() 244 | except Exception as e: 245 | self.logger.error(f"Reconnection failed: {e}") 246 | return False 247 | 248 | async def _monitor_connection_quality(self): 249 | """Monitor basic connection status""" 250 | self.logger.debug("Starting connection monitoring") 251 | 252 | while not self._shutting_down: 253 | try: 254 | # Check if clients are still connected 255 | for side in ['left', 'right']: 256 | if self._shutting_down: 257 | return 258 | 259 | client = getattr(self.connector, f"{side}_client", None) 260 | if client and not client.is_connected: 261 | self.logger.warning(f"{side.title()} glass disconnected") 262 | self._error_count += 1 263 | self._last_error = f"{side.title()} glass disconnected" 264 | 265 | await asyncio.sleep(10) 266 | 267 | except Exception as e: 268 | if not self._shutting_down: 269 | self._error_count += 1 270 | self._last_error = str(e) 271 | self.logger.error(f"Error in connection monitoring: {e}") 272 | await asyncio.sleep(10) 273 | 274 | async def verify_connection(self, client: BleakClient) -> bool: 275 | """Verify connection is working with heartbeat""" 276 | try: 277 | # Send initial heartbeat 278 | await self.send_heartbeat(client) 279 | 280 | # Wait for response 281 | start_time = time.time() 282 | while time.time() - start_time < 2.0: # 2 second timeout 283 | if self.connector.uart_service.last_heartbeat and \ 284 | self.connector.uart_service.last_heartbeat > start_time: 285 | return True 286 | await asyncio.sleep(0.1) 287 | 288 | self.logger.warning("Connection verification failed - no heartbeat response") 289 | return False 290 | 291 | except Exception as e: 292 | self.logger.error(f"Error verifying connection: {e}") 293 | return False 294 | 295 | def _update_connection_quality(self, side: str, rssi: Optional[int] = None, error: bool = False): 296 | """Update connection quality metrics""" 297 | if side not in self.connector._connection_quality: 298 | self.connector._connection_quality[side] = {'rssi': None, 'errors': 0} 299 | 300 | if rssi is not None: 301 | self.connector._connection_quality[side]['rssi'] = rssi 302 | 303 | if error: 304 | self.connector._connection_quality[side]['errors'] += 1 305 | 306 | async def start_monitoring(self): 307 | """Start connection quality monitoring""" 308 | if not self._monitoring_task: 309 | self.logger.info("Starting connection quality monitoring") 310 | self._monitoring_task = asyncio.create_task(self._monitor_connection_quality()) 311 | 312 | async def stop_monitoring(self): 313 | """Stop connection quality monitoring""" 314 | if self._monitoring_task: 315 | self._monitoring_task.cancel() 316 | try: 317 | await self._monitoring_task 318 | except asyncio.CancelledError: 319 | pass 320 | self._monitoring_task = None 321 | 322 | async def _heartbeat_loop(self): 323 | """Maintain connection with regular heartbeats""" 324 | seq = 0 325 | while True: 326 | try: 327 | if self.connector.left_client and self.connector.right_client: 328 | seq = (seq + 1) & 0xFF 329 | data = bytes([0x25, 0x06, 0x00, seq, 0x04, seq]) 330 | 331 | # Send to both glasses in sequence 332 | await self.send_command(self.connector.left_client, data) 333 | self.logger.debug(f"Heartbeat sent to Left: {data.hex()}") 334 | await asyncio.sleep(0.2) 335 | 336 | await self.send_command(self.connector.right_client, data) 337 | self.logger.debug(f"Heartbeat sent to Right: {data.hex()}") 338 | 339 | self._last_heartbeat = time.time() 340 | await asyncio.sleep(self.connector.config.heartbeat_interval) 341 | 342 | except Exception as e: 343 | self._error_count += 1 344 | self._last_error = f"Heartbeat failed: {e}" 345 | self.logger.error(self._last_error) 346 | await asyncio.sleep(2) 347 | 348 | async def _handle_disconnect(self, side: str): 349 | """Handle disconnection and attempt reconnection""" 350 | if self._shutting_down: 351 | return False 352 | 353 | self.logger.warning(f"{side.title()} glass disconnected") 354 | self._error_count += 1 355 | self._last_error = f"{side.title()} glass disconnected" 356 | 357 | for attempt in range(self.connector.config.reconnect_attempts): 358 | if self._shutting_down: 359 | return False 360 | 361 | try: 362 | self.logger.info(f"Attempting to reconnect {side} glass (attempt {attempt + 1})") 363 | if await self._connect_glass(side): 364 | self.logger.info(f"Successfully reconnected {side} glass") 365 | return True 366 | await asyncio.sleep(self.connector.config.reconnect_delay) 367 | except Exception as e: 368 | self.logger.error(f"Reconnection attempt {attempt + 1} failed: {e}") 369 | 370 | return False 371 | 372 | async def _connect_glass(self, side: str) -> bool: 373 | """Connect to a single glass with disconnect callback""" 374 | try: 375 | address = getattr(self.connector.config, f"{side}_address") 376 | if not address: 377 | self.logger.error(f"No {side} glass address configured") 378 | return False 379 | 380 | for attempt in range(self.connector.config.reconnect_attempts): 381 | try: 382 | self.logger.info(f"Attempting to connect {side} glass (attempt {attempt + 1})") 383 | client = BleakClient( 384 | address, 385 | disconnected_callback=lambda c: asyncio.create_task(self._handle_disconnect(side)) 386 | ) 387 | 388 | await client.connect(timeout=self.connector.config.connection_timeout) 389 | if client.is_connected: 390 | setattr(self.connector, f"{side}_client", client) 391 | await self.connector.uart_service.start_notifications(client, side) 392 | return True 393 | 394 | await asyncio.sleep(self.connector.config.reconnect_delay) 395 | 396 | except Exception as e: 397 | self.logger.error(f"Connection attempt {attempt + 1} failed: {e}") 398 | if attempt < self.connector.config.reconnect_attempts - 1: 399 | await asyncio.sleep(self.connector.config.reconnect_delay) 400 | 401 | return False 402 | 403 | except Exception as e: 404 | self.logger.error(f"Error connecting to {side} glass: {e}") 405 | return False 406 | 407 | def set_silent_mode(self, enabled: bool): 408 | """Toggle silent mode""" 409 | self._silent_mode = enabled 410 | # Log the change 411 | self.logger.info(f"Silent mode {'enabled' if enabled else 'disabled'}") 412 | # Update the status immediately 413 | self.connector.state_manager.update_status(self.get_status_data()) 414 | 415 | def get_status_data(self) -> dict: 416 | """Get current status data for external display""" 417 | return { 418 | 'connection': { 419 | 'left': { 420 | 'connected': bool(self.connector.left_client and self.connector.left_client.is_connected), 421 | 'errors': self._error_count, 422 | 'last_error': self._last_error 423 | }, 424 | 'right': { 425 | 'connected': bool(self.connector.right_client and self.connector.right_client.is_connected), 426 | 'errors': self._error_count, 427 | 'last_error': self._last_error 428 | } 429 | }, 430 | 'heartbeat': self._last_heartbeat, 431 | 'silent_mode': self._silent_mode 432 | } --------------------------------------------------------------------------------