diff --git a/.gitignore b/.gitignore index 62cf441..c40a041 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.pyc *.egg-info *~ +.pytest_cache/ +tests/__pycache__ __pycache__/** /build /dist diff --git a/AUTHORS b/AUTHORS index f4e931f..349eab4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,3 +1,3 @@ Jasem Mutlaq Juan Menendez - +Jens Scheidtmann diff --git a/indiweb/evt_indi_client.py b/indiweb/evt_indi_client.py new file mode 100644 index 0000000..065d9ee --- /dev/null +++ b/indiweb/evt_indi_client.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python + +import logging +import asyncio +from typing import Dict, Set +from fastapi import WebSocket +from collections import defaultdict + + +class WebSocketManager: + """ + Manages WebSocket connections for INDI device updates. + Bridges INDI client callbacks to WebSocket clients. + """ + + def __init__(self): + # Dictionary of device_name -> set of WebSocket connections + self.connections: Dict[str, Set[WebSocket]] = defaultdict(set) + # Lock for thread-safe operations + self._lock = asyncio.Lock() + + async def connect(self, websocket: WebSocket, device_name: str): + """Register a new WebSocket connection for a device""" + await websocket.accept() + async with self._lock: + self.connections[device_name].add(websocket) + logging.info(f"WebSocket connected for device: {device_name} (total connections: {len(self.connections[device_name])})") + + async def disconnect(self, websocket: WebSocket, device_name: str): + """Remove a WebSocket connection for a device""" + async with self._lock: + self.connections[device_name].discard(websocket) + logging.info(f"WebSocket disconnected for device: {device_name} (remaining connections: {len(self.connections[device_name])})") + + async def send_event(self, device_name: str, event_type: str, data: dict): + """ + Send an event to all WebSocket clients listening to a specific device. + + Args: + device_name: Name of the device + event_type: Type of event (property_updated, property_defined, message, etc.) + data: Event data to send + """ + if device_name not in self.connections or not self.connections[device_name]: + return + + message = { + "event": event_type, + "device": device_name, + "data": data + } + + # Create a copy of connections to avoid modification during iteration + async with self._lock: + connections = list(self.connections[device_name]) + + # Send to all connected clients + disconnected = [] + for websocket in connections: + try: + await websocket.send_json(message) + except Exception as e: + logging.error(f"Error sending to WebSocket for {device_name}: {e}") + disconnected.append(websocket) + + # Clean up disconnected clients + if disconnected: + async with self._lock: + for websocket in disconnected: + self.connections[device_name].discard(websocket) + + def get_connection_count(self, device_name: str = None) -> int: + """Get the number of active connections for a device or all devices""" + if device_name: + return len(self.connections.get(device_name, set())) + return sum(len(conns) for conns in self.connections.values()) + + +# Global WebSocket manager instance +websocket_manager = WebSocketManager() + + +def get_websocket_manager() -> WebSocketManager: + """Get the global WebSocket manager instance""" + return websocket_manager + + +def create_indi_event_listener(indi_client, event_loop): + """ + Create and register an INDI event listener that forwards events to WebSocket clients. + + Args: + indi_client: The INDI client instance + event_loop: The asyncio event loop to use for async operations + + Returns: + The listener function (for potential removal later) + """ + manager = get_websocket_manager() + + def indi_event_listener(event_type: str, device_name: str, data: dict): + """ + Callback for INDI client events. Runs in INDI client thread. + Forwards events to WebSocket clients in the main event loop. + """ + if not data: + return + + # Schedule the coroutine in the main event loop (thread-safe) + asyncio.run_coroutine_threadsafe( + manager.send_event(device_name, event_type, data), + event_loop + ) + + # Register the listener with the INDI client + indi_client.add_listener(indi_event_listener) + + logging.info("INDI event listener registered with WebSocket manager") + + return indi_event_listener diff --git a/indiweb/indi_client.py b/indiweb/indi_client.py new file mode 100644 index 0000000..4201834 --- /dev/null +++ b/indiweb/indi_client.py @@ -0,0 +1,651 @@ +#!/usr/bin/env python + +import time +import logging +import threading +from collections import defaultdict + +try: + import PyIndi +except ImportError: + print("PyIndi module not found. Please install pyindi-client.") + raise + + +class INDIClient(PyIndi.BaseClient): + """ + An INDI client for connecting to an INDI server and managing device properties. + Based on the INDI protocol: https://indilib.org/develop/developer-manual/101-standard-properties.html + """ + + def __init__(self, host='localhost', port=7624): + super(INDIClient, self).__init__() + self.host = host + self.port = port + self.connected = False + self.properties = defaultdict(dict) + self.auto_connect_devices = True + self.listeners = [] + self.connection_lock = threading.Lock() + + def connect(self): + """Connect to the INDI server""" + try: + with self.connection_lock: + self.setServer(self.host, self.port) + success = self.connectServer() + if success: + # Don't set connected=True here, wait for serverConnected() callback + logging.info(f"Attempting to connect to INDI server at {self.host}:{self.port}") + # Wait a bit for connection to establish and initial properties to load + time.sleep(2) + return self.connected # Return actual connection status from callback + return False + except Exception as e: + logging.error(f"Failed to connect to INDI server: {e}") + self.connected = False + return False + + def disconnect(self): + """Disconnect from the INDI server""" + with self.connection_lock: + if self.connected: + self.disconnectServer() + self.connected = False + logging.info("Disconnected from INDI server") + + # PyIndi callback methods + def newDevice(self, device): + """Called when a new device is created""" + device_name = device.getDeviceName() + logging.debug(f"New device: {device_name}") + self._notify_listeners('device_added', device_name, None) + + def removeDevice(self, device): + """Called when a device is removed""" + device_name = device.getDeviceName() + if device_name in self.properties: + del self.properties[device_name] + logging.debug(f"Removed device: {device_name}") + self._notify_listeners('device_deleted', device_name, None) + + def newProperty(self, prop): + """Called when a new property is created""" + device_name = prop.getDeviceName() + prop_name = prop.getName() + + property_data = self._convert_property_to_dict(prop) + + if device_name not in self.properties: + self.properties[device_name] = {} + + self.properties[device_name][prop_name] = property_data + + # Auto-connect device if it has a CONNECTION property + if self.auto_connect_devices and prop_name == 'CONNECTION': + self._auto_connect_device(device_name) + + # Notify listeners + self._notify_listeners('property_defined', device_name, property_data) + + def updateProperty(self, prop): + """Called when a property is updated""" + device_name = prop.getDeviceName() + prop_name = prop.getName() + + property_data = self._convert_property_to_dict(prop) + + if device_name in self.properties and prop_name in self.properties[device_name]: + # Update the property data + self.properties[device_name][prop_name] = property_data + + # Notify listeners + self._notify_listeners('property_updated', device_name, property_data) + + def removeProperty(self, prop): + """Called when a property is removed""" + device_name = prop.getDeviceName() + prop_name = prop.getName() + + if device_name in self.properties and prop_name in self.properties[device_name]: + del self.properties[device_name][prop_name] + self._notify_listeners('property_deleted', device_name, {'name': prop_name}) + + def newMessage(self, device, message_id): + """Called when a new message arrives""" + device_name = device.getDeviceName() if device else "Server" + message_text = device.messageQueue(message_id) if device else "" + + self._notify_listeners('message', device_name, { + 'message': message_text, + 'timestamp': str(time.time()) + }) + + def serverConnected(self): + """Called when server connects""" + self.connected = True + logging.info(f"Server connected ({self.getHost()}:{self.getPort()})") + + def serverDisconnected(self, exit_code): + """Called when server disconnects""" + self.connected = False + logging.info(f"Server disconnected (exit code = {exit_code})") + + def _convert_property_to_dict(self, prop): + """Convert a PyIndi property to dictionary format""" + device_name = prop.getDeviceName() + prop_name = prop.getName() + prop_label = prop.getLabel() + prop_group = prop.getGroupName() + prop_state = prop.getStateAsString().lower() + # Convert permission number to string + perm_num = prop.getPermission() + if perm_num == PyIndi.IP_RO: + prop_perm = 'ro' + elif perm_num == PyIndi.IP_WO: + prop_perm = 'wo' + elif perm_num == PyIndi.IP_RW: + prop_perm = 'rw' + else: + prop_perm = 'rw' # default + prop_type_str = prop.getTypeAsString().lower() + + # Map PyIndi property types to our format + # PyIndi returns "INDI_Text", "INDI_Number", etc. + type_mapping = { + 'indi_text': 'text', + 'indi_number': 'number', + 'indi_switch': 'switch', + 'indi_light': 'light', + 'indi_blob': 'blob' + } + prop_type = type_mapping.get(prop_type_str, prop_type_str) + + elements = {} + switch_rule = None # Initialize for switch properties + + if prop.getType() == PyIndi.INDI_TEXT: + text_prop = PyIndi.PropertyText(prop) + for widget in text_prop: + elements[widget.name] = { + 'name': widget.name, + 'label': widget.label, + 'value': widget.text + } + + elif prop.getType() == PyIndi.INDI_NUMBER: + number_prop = PyIndi.PropertyNumber(prop) + for widget in number_prop: + element = { + 'name': widget.name, + 'label': widget.label, + 'value': str(widget.value), + 'min': str(widget.min) if widget.min is not None else None, + 'max': str(widget.max) if widget.max is not None else None, + 'step': str(widget.step) if widget.step is not None else None, + 'format': widget.format if widget.format else None + } + elements[widget.name] = element + + elif prop.getType() == PyIndi.INDI_SWITCH: + switch_prop = PyIndi.PropertySwitch(prop) + # Get the rule from the switch property - this will be used later in property_data + switch_rule = switch_prop.getRule() if hasattr(switch_prop, 'getRule') else None + if switch_rule is not None: + # Convert PyIndi rule constants to strings + if switch_rule == PyIndi.ISR_1OFMANY: + switch_rule = 'OneOfMany' + elif switch_rule == PyIndi.ISR_ATMOST1: + switch_rule = 'AtMostOne' + elif switch_rule == PyIndi.ISR_NOFMANY: + switch_rule = 'AnyOfMany' + else: + switch_rule = 'OneOfMany' # default + + for widget in switch_prop: + elements[widget.name] = { + 'name': widget.name, + 'label': widget.label, + 'value': 'On' if widget.s == PyIndi.ISS_ON else 'Off' + } + + elif prop.getType() == PyIndi.INDI_LIGHT: + light_prop = PyIndi.PropertyLight(prop) + for widget in light_prop: + # Convert light state to string + state_str = 'Idle' + if widget.s == PyIndi.IPS_IDLE: + state_str = 'Idle' + elif widget.s == PyIndi.IPS_OK: + state_str = 'Ok' + elif widget.s == PyIndi.IPS_BUSY: + state_str = 'Busy' + elif widget.s == PyIndi.IPS_ALERT: + state_str = 'Alert' + + elements[widget.name] = { + 'name': widget.name, + 'label': widget.label, + 'value': state_str + } + + elif prop.getType() == PyIndi.INDI_BLOB: + blob_prop = PyIndi.PropertyBlob(prop) + for widget in blob_prop: + size = getattr(widget, 'size', 0) if hasattr(widget, 'size') else 0 + elements[widget.name] = { + 'name': widget.name, + 'label': widget.label, + 'value': f'' + } + + # Set the rule based on property type + rule_value = None + if prop.getType() == PyIndi.INDI_SWITCH: + rule_value = switch_rule + + property_data = { + 'name': prop_name, + 'label': prop_label, + 'group': prop_group, + 'type': prop_type, + 'state': prop_state, + 'perm': prop_perm.lower(), + 'rule': rule_value, + 'elements': elements, + 'device': device_name + } + + # Apply formatting to number properties + property_data = self._apply_formatting_to_property(property_data) + + return property_data + + + + + + def _notify_listeners(self, event_type, device, data): + """Notify all registered listeners of an event""" + for listener in self.listeners: + try: + listener(event_type, device, data) + except Exception as e: + logging.error(f"Error in listener callback: {e}") + + def add_listener(self, callback): + """Add a listener for INDI events""" + self.listeners.append(callback) + + def remove_listener(self, callback): + """Remove a listener""" + if callback in self.listeners: + self.listeners.remove(callback) + + def get_devices(self): + """Get list of available devices""" + if self.connected: + pyindi_devices = self.getDevices() + return [device.getDeviceName() for device in pyindi_devices] + return list(self.properties.keys()) + + def get_device_properties(self, device_name): + """Get all properties for a specific device""" + return self.properties.get(device_name, {}) + + def get_property(self, device_name, property_name): + """Get a specific property""" + device_props = self.properties.get(device_name, {}) + return device_props.get(property_name) + + def wait_for_device(self, device_name, timeout=5): + """Wait for a device to become available""" + start_time = time.time() + while time.time() - start_time < timeout: + if self.connected: + device = self.getDevice(device_name) + if device: + return True + elif device_name in self.properties: + return True + time.sleep(0.1) + return False + + def set_property(self, device_name, property_name, elements): + """ + Set property values and return detailed result information. + + Args: + device_name (str): Name of the device + property_name (str): Name of the property + elements (dict): Dictionary of element names and values + + Returns: + dict: Result with success status, message, and details + """ + prop = self.get_property(device_name, property_name) + if not prop: + return { + 'success': False, + 'error': f'Property {device_name}.{property_name} not found', + 'error_type': 'property_not_found' + } + + # Check if property is writable + perm = prop.get('perm', 'rw') + if perm not in ['rw', 'wo']: + return { + 'success': False, + 'error': f'Property {device_name}.{property_name} is read-only', + 'error_type': 'permission_denied' + } + + # Validate elements exist + for elem_name in elements: + if elem_name not in prop.get('elements', {}): + return { + 'success': False, + 'error': f'Element {elem_name} not found in property {device_name}.{property_name}', + 'error_type': 'element_not_found' + } + + prop_type = prop['type'] + original_prop_type = prop_type + + # Normalize property type (handle both old and new formats) + if prop_type.startswith('indi_'): + prop_type = prop_type[5:] # Remove 'indi_' prefix + + logging.info(f"Setting property {device_name}.{property_name}: original type '{original_prop_type}' -> normalized type '{prop_type}'") + + if prop_type == 'text': + message = f'' + for elem_name, value in elements.items(): + message += f'{value}' + message += '' + + elif prop_type == 'number': + message = f'' + for elem_name, value in elements.items(): + try: + # Validate number format + float(value) + message += f'{value}' + except ValueError: + return { + 'success': False, + 'error': f'Invalid number format for element {elem_name}: {value}', + 'error_type': 'invalid_value' + } + message += '' + + elif prop_type == 'switch': + message = f'' + for elem_name, value in elements.items(): + # Validate switch values + if value not in ['On', 'Off', 'ON', 'OFF']: + return { + 'success': False, + 'error': f'Invalid switch value for element {elem_name}: {value}. Must be On/Off', + 'error_type': 'invalid_value' + } + message += f'{value}' + message += '' + + else: + return { + 'success': False, + 'error': f'Unsupported property type: {original_prop_type} (normalized: {prop_type})', + 'error_type': 'unsupported_type' + } + + # Send using PyIndi methods + device = self.getDevice(device_name) + if not device: + return { + 'success': False, + 'error': f'Device {device_name} not found', + 'error_type': 'device_not_found' + } + + property_obj = device.getProperty(property_name) + if not property_obj: + return { + 'success': False, + 'error': f'Property {device_name}.{property_name} not found', + 'error_type': 'property_not_found' + } + + try: + if prop_type == 'text': + text_prop = PyIndi.PropertyText(property_obj) + for elem_name, value in elements.items(): + widget = text_prop.findWidgetByName(elem_name) + if widget: + widget.setText(str(value)) + self.sendNewProperty(text_prop) + + elif prop_type == 'number': + number_prop = PyIndi.PropertyNumber(property_obj) + for elem_name, value in elements.items(): + widget = number_prop.findWidgetByName(elem_name) + if widget: + widget.setValue(float(value)) + self.sendNewProperty(number_prop) + + elif prop_type == 'switch': + switch_prop = PyIndi.PropertySwitch(property_obj) + for elem_name, value in elements.items(): + widget = switch_prop.findWidgetByName(elem_name) + if widget: + if value in ['On', 'ON']: + widget.setState(PyIndi.ISS_ON) + else: + widget.setState(PyIndi.ISS_OFF) + self.sendNewProperty(switch_prop) + + except Exception as e: + return { + 'success': False, + 'error': f'Failed to send property update: {str(e)}', + 'error_type': 'communication_error' + } + + # All INDI operations are asynchronous - return success immediately after sending + # The actual property updates will be received via PyIndi callbacks and polling + return { + 'success': True, + 'message': f'Property {property_name} command sent successfully', + 'property': property_name, + 'device': device_name, + 'elements': elements + } + + def is_connected(self): + """Check if connected to INDI server""" + return self.connected + + # Removed _cleanup_old_operations - no longer needed with asynchronous operations + + def _auto_connect_device(self, device_name): + """Automatically connect a device when it becomes available""" + def connect_after_delay(): + time.sleep(2) # Wait for device to be fully initialized + logging.info(f"Auto-connecting device: {device_name}") + device = self.getDevice(device_name) + if device and not device.isConnected(): + connection_prop = device.getProperty('CONNECTION') + if connection_prop: + switch_prop = PyIndi.PropertySwitch(connection_prop) + connect_widget = switch_prop.findWidgetByName('CONNECT') + disconnect_widget = switch_prop.findWidgetByName('DISCONNECT') + if connect_widget and disconnect_widget: + connect_widget.setState(PyIndi.ISS_ON) + disconnect_widget.setState(PyIndi.ISS_OFF) + self.sendNewProperty(switch_prop) + + # Run in separate thread to avoid blocking + thread = threading.Thread(target=connect_after_delay) + thread.daemon = True + thread.start() + + def get_device_structure(self, device_name): + """Get the current property structure for a device""" + if device_name not in self.properties: + return None + + device_props = self.properties[device_name] + structure = {} + + # Group properties by group + for prop_name, prop_data in device_props.items(): + group_name = prop_data.get('group', 'Main') + if group_name not in structure: + structure[group_name] = {} + structure[group_name][prop_name] = prop_data + + return structure + + def _format_number_value(self, value, format_str): + """Format a number value according to INDI printf-style format""" + if not format_str or not value: + return value + + try: + # Convert value to float for formatting + num_value = float(value) + + # Handle INDI-specific %m sexagesimal format + if 'm' in format_str and format_str.startswith('%'): + return self._format_sexagesimal(num_value, format_str) + + # Handle INDI-specific format patterns + elif format_str.startswith('%') and ('d' in format_str or 'i' in format_str): + # Integer format + return f"{int(num_value)}" + elif format_str.startswith('%') and ('f' in format_str or 'e' in format_str or 'E' in format_str or 'g' in format_str or 'G' in format_str): + # Float format - use Python's % formatting + return format_str % num_value + elif ':' in format_str: + # Time format (hours:minutes:seconds) + if format_str.count(':') == 2: + # HH:MM:SS format + hours = int(num_value) + minutes = int((num_value - hours) * 60) + seconds = ((num_value - hours) * 60 - minutes) * 60 + return f"{hours:02d}:{minutes:02d}:{seconds:06.3f}" + elif format_str.count(':') == 1: + # MM:SS format + minutes = int(num_value) + seconds = (num_value - minutes) * 60 + return f"{minutes:02d}:{seconds:06.3f}" + else: + # Default float formatting + return f"{num_value:.6g}" + + except (ValueError, TypeError): + # If formatting fails, return original value + return value + + def _format_sexagesimal(self, value, format_str): + """Format a number as sexagesimal using INDI %m format specification + + Format: %.m where: + - = total field width + - = precision specification: + - 9 → :mm:ss.ss (HH:MM:SS.ss) + - 8 → :mm:ss.s (HH:MM:SS.s) + - 6 → :mm:ss (HH:MM:SS) + - 5 → :mm.m (HH:MM.m) + - 3 → :mm (HH:MM) + """ + try: + # Parse format like %10.6m or %9m + import re + match = re.match(r'%(\d+)(?:\.(\d+))?m', format_str) + if not match: + return str(value) + + width = int(match.group(1)) + precision = int(match.group(2)) if match.group(2) else width + + # Handle negative values + is_negative = value < 0 + abs_value = abs(value) + + # Calculate degrees, minutes, seconds + degrees = int(abs_value) + minutes_float = (abs_value - degrees) * 60 + minutes = int(minutes_float) + seconds = (minutes_float - minutes) * 60 + + # Format according to precision specification + if precision == 9: + # :mm:ss.ss format + result = f"{degrees:02d}:{minutes:02d}:{seconds:05.2f}" + elif precision == 8: + # :mm:ss.s format + result = f"{degrees:02d}:{minutes:02d}:{seconds:04.1f}" + elif precision == 6: + # :mm:ss format + result = f"{degrees:02d}:{minutes:02d}:{seconds:02.0f}" + elif precision == 5: + # :mm.m format + minutes_with_decimal = minutes + seconds / 60 + result = f"{degrees:02d}:{minutes_with_decimal:04.1f}" + elif precision == 3: + # :mm format + minutes_rounded = round(minutes + seconds / 60) + result = f"{degrees:02d}:{minutes_rounded:02d}" + else: + # Default to full precision + result = f"{degrees:02d}:{minutes:02d}:{seconds:05.2f}" + + # Add negative sign if needed + if is_negative: + result = '-' + result + + return result + + except (ValueError, TypeError, AttributeError): + # If formatting fails, return original value + return str(value) + + def _apply_formatting_to_property(self, prop_data): + """Apply formatting to number property elements""" + if prop_data['type'] == 'number': + for elem_name, element in prop_data['elements'].items(): + if 'format' in element and element['format']: + formatted_value = self._format_number_value(element['value'], element['format']) + element['formatted_value'] = formatted_value + else: + element['formatted_value'] = element['value'] + return prop_data + + +# Global INDI client instance +indi_client = None + + +def get_indi_client(): + """Get the global INDI client instance""" + global indi_client + if indi_client is None: + indi_client = INDIClient() + return indi_client + + +def start_indi_client(host='localhost', port=7624): + """Start the INDI client connection""" + client = get_indi_client() + if not client.is_connected(): + return client.connect() + return True + + +def stop_indi_client(): + """Stop the INDI client connection""" + global indi_client + if indi_client: + indi_client.disconnect() + indi_client = None \ No newline at end of file diff --git a/indiweb/main.py b/indiweb/main.py index 05493f3..2f68267 100644 --- a/indiweb/main.py +++ b/indiweb/main.py @@ -10,7 +10,7 @@ import platform from importlib_metadata import version -from fastapi import FastAPI, Request, Response, HTTPException +from fastapi import FastAPI, Request, Response, HTTPException, WebSocket, WebSocketDisconnect from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse @@ -20,6 +20,8 @@ from .driver import DeviceDriver, DriverCollection, INDI_DATA_DIR from .database import Database from .device import Device +from .indi_client import get_indi_client, start_indi_client, stop_indi_client +from .evt_indi_client import get_websocket_manager, create_indi_event_listener # default settings WEB_HOST = '0.0.0.0' @@ -52,8 +54,10 @@ parser.add_argument('--logfile', '-l', help='log file name') parser.add_argument('--server', '-s', default='standalone', help='HTTP server [standalone|apache] (default: standalone') -parser.add_argument('--sudo', '-S', action='store_true', +parser.add_argument('--sudo', '-S', action='store_true', help='Run poweroff/reboot commands with sudo') +parser.add_argument('--development', '-d', action='store_true', + help='Show development information in device control panel (property names, element IDs, etc.)') args = parser.parse_args() @@ -90,11 +94,11 @@ # Serve static files app.mount("/static", StaticFiles(directory=views_path), name="static") -app.mount("/favicon.ico", StaticFiles(directory=views_path), name="favicon.ico") saved_profile = None active_profile = "" +evt_listener_initialized = False def start_profile(profile): @@ -375,8 +379,29 @@ async def start_server(profile: str, response: Response): saved_profile = profile global active_profile active_profile = profile + global evt_listener_initialized response.set_cookie(key="indiserver_profile", value=profile, max_age=3600000, path='/') start_profile(profile) + + # Start INDI client connection after a short delay + import asyncio + async def start_client(): + await asyncio.sleep(3) # Wait for server to start + profile_info = db.get_profile(profile) + port = profile_info.get('port', 7624) if profile_info else 7624 + start_indi_client('localhost', port) + + # Initialize event listener for WebSocket updates + global evt_listener_initialized + if not evt_listener_initialized: + indi_client = get_indi_client() + event_loop = asyncio.get_event_loop() + create_indi_event_listener(indi_client, event_loop) + evt_listener_initialized = True + logging.info("INDI event listener initialized for WebSocket updates") + + asyncio.create_task(start_client()) + return {"message": f"INDI server started for profile {profile}"} @@ -386,6 +411,7 @@ async def stop_server(): Stops the INDI server. """ indi_server.stop() + stop_indi_client() # Also stop the INDI client global active_profile active_profile = "" @@ -552,15 +578,466 @@ async def restart_driver(label: str): ############################################################################### +@app.get('/device/{device_name}', response_class=HTMLResponse, tags=["Web Interface"]) +async def device_control_panel(request: Request, device_name: str): + """ + Renders the device control panel page. + + Args: + device_name (str): The name of the device to control. + + Returns: + str: The rendered HTML template. + """ + try: + logging.info(f"Loading device control panel for: {device_name}") + + # Check if template file exists + import os + template_path = os.path.join(views_path, "device_control.tpl") + if not os.path.exists(template_path): + raise HTTPException(status_code=500, detail=f"Template not found: {template_path}") + + return templates.TemplateResponse( + "device_control.tpl", + { + "request": request, + "device_name": device_name, + "development_mode": args.development + } + ) + except Exception as e: + logging.error(f"Error loading device control panel for {device_name}: {e}") + raise HTTPException(status_code=500, detail=f"Error loading device control panel: {str(e)}") + + @app.get('/api/devices', tags=["Devices"]) async def get_devices(): """ - Gets a list of connected INDI devices as JSON. + Retrieve all currently connected INDI devices. + + This endpoint returns a list of all devices currently connected + to the INDI server. + + Returns: + list: Device names as an array of strings. + + Example response: + ```json + [ + "CCD Simulator", + "Telescope Simulator", + "..." + ] + ``` + + Raises: + 503: Service unavailable if INDI server is not running + """ + # Try INDI client first, fallback to old method + client = get_indi_client() + if client.is_connected(): + devices = client.get_devices() + return JSONResponse(content=devices) + else: + return JSONResponse(content=indi_device.get_devices()) + + +@app.get('/api/devices/{device_name}/structure', tags=["Devices"]) +async def get_device_structure(device_name: str): + """ + Retrieve the complete property structure for a specific INDI device. + + This endpoint provides the hierarchical structure of all properties available + on the specified device, organized by property groups. This is essential for + building dynamic user interfaces that can adapt to different device types. + + Args: + device_name (str): The exact name of the INDI device (case-sensitive) + + Returns: + dict: Nested dictionary containing the complete device structure: + - Top level: property group names + - Second level: property names within each group + - Third level: property metadata and element definitions + + Example response: + ```json + { + "Main Control": { + "CONNECTION": { + "type": "Switch", + "perm": "rw", + "state": "Ok", + "elements": { + "CONNECT": {"value": "On", "label": "Connect", ...}, + "DISCONNECT": {"value": "Off", "label": "Disconnect", ...} + }, + ... + } + }, + ... + } + ``` + + Raises: + 404: Device not found or not available + 503: INDI server not running or client not connected + """ + global evt_listener_initialized + client = get_indi_client() + if not client.is_connected(): + # Try to connect to INDI server + if indi_server.is_running(): + port = 7624 # Default INDI port + profiles = db.get_profiles() + for profile in profiles: + if profile.get('name') == active_profile: + port = profile.get('port', 7624) + break + + if not start_indi_client('localhost', port): + raise HTTPException(status_code=503, detail="Cannot connect to INDI server") + + # Initialize event listener for WebSocket updates if not already done + if not evt_listener_initialized: + import asyncio + event_loop = asyncio.get_event_loop() + create_indi_event_listener(client, event_loop) + evt_listener_initialized = True + logging.info("INDI event listener initialized for WebSocket updates (via structure endpoint)") + else: + raise HTTPException(status_code=503, detail="INDI server is not running") + + # Wait for device to be available + if not client.wait_for_device(device_name, timeout=3): + raise HTTPException(status_code=404, detail=f"Device '{device_name}' not found or not available") + + structure = client.get_device_structure(device_name) + if not structure: + raise HTTPException(status_code=404, detail="Device found but no properties available yet. Try refreshing.") + + return JSONResponse(content=structure) + + +@app.get('/api/devices/{device_name}/groups', tags=["Devices"]) +async def get_device_groups(device_name: str): + """ + Retrieve property groups and their associated properties for a device. + + This endpoint provides a simplified view of device properties organized by + functional groups (e.g., "Main Control", "Image Settings", "Cooler"). + Useful for creating tabbed interfaces or organizing device controls. + + Args: + device_name (str): The exact name of the INDI device Returns: - str: A JSON string representing the connected devices. + dict: Property groups with arrays of property names in each group + + Example response: + ```json + { + "Main Control": [ + "CONNECTION", + "ON_COORD_SET", + "EQUATORIAL_EOD_COORD", + ... + ], + "Connection": [ + "DRIVER_INFO", + CONNECTION_MODE", + ... + ], + ... + } + ``` + + Raises: + 404: Device not found + 503: INDI client not connected to server """ - return JSONResponse(content=indi_device.get_devices()) + client = get_indi_client() + if not client.is_connected(): + raise HTTPException(status_code=503, detail="INDI client not connected") + + properties = client.get_device_properties(device_name) + if not properties: + raise HTTPException(status_code=404, detail="Device not found") + + # Group properties by group name + groups = {} + for prop_name, prop_data in properties.items(): + group_name = prop_data.get('group', 'Main') + if group_name not in groups: + groups[group_name] = [] + groups[group_name].append(prop_name) + + return JSONResponse(content=groups) + + +@app.get('/api/devices/{device_name}/properties/{property_name}', tags=["Devices"]) +async def get_device_property(device_name: str, property_name: str): + """ + Retrieve detailed information for a specific device property. + + This endpoint provides metadata and current values for a single + property, including type information, permissions, state, and all elements + with their current values and constraints. + + Args: + device_name (str): The exact name of the INDI device + property_name (str): The exact name of the property to retrieve + + Returns: + dict: Complete property information including metadata and current values + + Example response for a Number property: + ```json + { + "name": "TELESCOPE_TRACK_RATE", + "label": "Track Rates", + "group": "Main Control", + "type": "number", + "state": "idle", + "perm": "rw", + "rule": null, + "elements": { + "TRACK_RATE_RA": { + "name": "TRACK_RATE_RA", + "label": "RA (arcsecs/s)", + "value": "15.041067178670204", + "min": "-16384.0", + "max": "16384.0", + "step": "1e-06", + "format": "%.6f", + "formatted_value": "15.041067" + }, + ... + } + ``` + + Example response for a Switch property: + ```json + { + "name": "CONNECTION", + "label": "Connection", + "group": "Main Control", + "type": "switch", + "state": "ok", + "perm": "rw", + "rule": "OneOfMany", + "elements": { + "CONNECT": { "name": "CONNECT", "label": "Connect", "value": "On" }, + "DISCONNECT": { "name": "DISCONNECT", "label": "Disconnect", "value": "Off" + }, + "device": "Telescope Simulator" + } + ``` + + Raises: + 404: Property not found on the specified device + 503: INDI client not connected to server + """ + client = get_indi_client() + if not client.is_connected(): + raise HTTPException(status_code=503, detail="INDI client not connected") + + property_data = client.get_property(device_name, property_name) + if not property_data: + raise HTTPException(status_code=404, detail="Property not found") + + return JSONResponse(content=property_data) + + +@app.post('/api/devices/{device_name}/properties/{property_name}/set', tags=["Devices"]) +async def set_device_property(device_name: str, property_name: str, request: Request): + """ + Set new values for elements within a specific device property. + + This endpoint allows modification of property element values, such as changing + exposure time, connecting/disconnecting devices, or adjusting temperature setpoints. + The property must be writable (permission 'rw' or 'wo') for the operation to succeed. + + Args: + device_name (str): The exact name of the INDI device + property_name (str): The exact name of the property to modify + request: JSON request body containing element values to set + + Request body schema: + ```json + { + "elements": { + "element_name1": "value1", + "element_name2": "value2" + } + } + ``` + + Returns: + dict: Operation result with success status and details + + Example request (setting exposure time): + ```json + { + "elements": { + "CCD_EXPOSURE_VALUE": 30.0 + } + } + ``` + + Example request (connecting device): + ```json + { + "elements": { + "CONNECT": "On", + "DISCONNECT": "Off" + } + } + ``` + + Example success response: + ```json + { + "success": true, + "message": "Property set successfully", + "device": "CCD Simulator", + "property": "CCD_EXPOSURE", + "elements": { + "CCD_EXPOSURE_VALUE": 30.0 + } + } + ``` + + Example error response: + ```json + { + "success": false, + "error": "Property is read-only", + "error_type": "permission_denied" + } + ``` + + Raises: + 400: No element values provided or invalid values + 404: Property or element not found + 422: Property is read-only or validation failed + 503: INDI client not connected to server + """ + client = get_indi_client() + if not client.is_connected(): + raise HTTPException(status_code=503, detail="INDI client not connected") + + try: + data = await request.json() + elements = data.get('elements', {}) + + if not elements: + raise HTTPException(status_code=400, detail="No element values provided") + + # Log the received values + logging.warning(f"Setting property {device_name}.{property_name} with values: {elements}") + + # Set the property using the INDI client + result = client.set_property(device_name, property_name, elements) + + if result['success']: + # Success - property setting command was sent + logging.info(f"Property {device_name}.{property_name} set successfully") + return JSONResponse(content={ + "success": True, + "message": result['message'], + "device": device_name, + "property": property_name, + "elements": elements + }) + else: + # Error occurred during property setting + error_msg = result['error'] + error_type = result.get('error_type', 'unknown_error') + + logging.error(f"Failed to set property {device_name}.{property_name}: {error_msg}") + + # Map error types to appropriate HTTP status codes + if error_type in ['property_not_found', 'element_not_found']: + status_code = 404 + elif error_type == 'permission_denied': + status_code = 403 + elif error_type in ['invalid_value', 'unsupported_type']: + status_code = 400 + elif error_type == 'communication_error': + status_code = 503 + else: + status_code = 500 + + raise HTTPException(status_code=status_code, detail=error_msg) + + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON in request body") + except HTTPException: + # Re-raise HTTP exceptions (these are our expected errors) + raise + except Exception as e: + logging.error(f"Unexpected error setting property {device_name}.{property_name}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + + +############################################################################### +# WebSocket endpoints +############################################################################### + +@app.websocket('/evt_device/{device_name}') +async def evt_device_websocket(websocket: WebSocket, device_name: str): + """ + WebSocket endpoint for real-time device property updates. + + This endpoint provides a persistent connection for receiving real-time updates + from INDI devices. Events are pushed to clients as soon as properties change, + eliminating the need for polling. + + Args: + websocket: The WebSocket connection + device_name: The exact name of the INDI device to monitor + + Events sent to client: + - property_updated: When a property value changes + - property_defined: When a new property is created + - property_deleted: When a property is removed + - message: When the device sends a message + + Example event: + ```json + { + "event": "property_updated", + "device": "CCD Simulator", + "data": { + "name": "CCD_TEMPERATURE", + "type": "number", + "state": "ok", + "elements": {...} + } + } + ``` + """ + manager = get_websocket_manager() + await manager.connect(websocket, device_name) + + try: + # Keep the connection alive and listen for client messages + while True: + # Wait for any message from client (used for keepalive) + data = await websocket.receive_text() + # Echo back for keepalive confirmation + if data == "ping": + await websocket.send_text("pong") + except WebSocketDisconnect: + logging.info(f"WebSocket disconnected for device: {device_name}") + except Exception as e: + logging.error(f"WebSocket error for device {device_name}: {e}") + finally: + await manager.disconnect(websocket, device_name) + ############################################################################### # System control endpoints diff --git a/indiweb/views/css/device_control.css b/indiweb/views/css/device_control.css new file mode 100644 index 0000000..fa7e993 --- /dev/null +++ b/indiweb/views/css/device_control.css @@ -0,0 +1,1075 @@ +/* Device Control Panel CSS */ + +.property-group { + margin-bottom: 20px; + border: 1px solid #ddd; + border-radius: 4px; + padding: 15px; + background-color: #fafafa; +} + +.property-group h4 { + margin-top: 0; + color: #555; + border-bottom: 1px solid #ddd; + padding-bottom: 5px; +} + +.property-item { + margin-bottom: 15px; + padding: 12px; + border: 1px solid #e7e7e7; + border-radius: 3px; + background-color: white; + display: grid; + grid-template-columns: auto auto auto auto 1fr; + gap: 8px 12px; + align-items: center; +} + +.property-header { + grid-column: 1; + grid-row: 1; + display: flex; + align-items: center; +} + +.property-label { + font-weight: bold; + margin-left: 8px; + color: #333; + white-space: nowrap; +} + +.property-state { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + margin-right: 8px; + border: 1px solid #ccc; +} + +.state-idle { + background-color: #d9d9d9; +} + +.state-ok { + background-color: #5cb85c; +} + +.state-busy { + background-color: #f0ad4e; +} + +.state-alert { + background-color: #d9534f; +} + +.property-elements { + display: contents; +} + +.element-row { + display: contents; +} + +.element-label { + grid-column: 2; + font-weight: normal; + color: #666; + white-space: nowrap; + text-align: right; + padding-right: 8px; +} + +.element-value-container { + grid-column: 3; + display: flex; + align-items: center; + gap: 8px; +} + +.set-button-container { + grid-column: 4; + grid-row: 1 / 1; + align-items: flex-start; +} + +.element-input { + margin-right: 10px; + max-width: 150px; + margin-left: 5px; + display: inline-block; + vertical-align: middle; +} + +.property-buttons { + margin-top: 12px; + padding-top: 8px; + border-top: 1px solid #eee; +} + +/* Property control buttons */ +.copy-value-btn { + margin-left: 5px; + margin-right: 5px; + padding: 3px 8px; + font-size: 11px; + line-height: 1.2; + min-width: 32px; + vertical-align: middle; + border: 1px solid #ccc; + border-radius: 3px; + background: linear-gradient(to bottom, #fff 0%, #f0f0f0 100%); + color: #333 !important; + cursor: pointer; + flex-shrink: 0; +} + +.copy-value-btn:hover { + background: linear-gradient(to bottom, #f8f8f8 0%, #e8e8e8 100%); + border-color: #aaa; + color: #333 !important; +} + +.copy-value-btn:active { + background: linear-gradient(to bottom, #e8e8e8 0%, #f0f0f0 100%); + box-shadow: inset 0 1px 2px rgba(0,0,0,0.1); + color: #333 !important; +} + +.property-set-control { + margin-left: 10px; + flex-shrink: 0; +} + +.set-property-btn { + min-width: 60px; +} + +/* Styling for current value display in writable properties */ +.element-current-value { + margin-right: 5px; + min-width: 80px; + flex-shrink: 0; +} + +/* Better layout for value + controls */ +.element-value-controls { + display: flex; + align-items: center; + gap: 5px; + flex: 1; +} + +.switch-group { + grid-column: 2; + display: flex; + flex-wrap: wrap; + gap: 15px; + margin: 5px 0; + justify-self: start; +} + +.switch-group label { + margin-bottom: 0; + cursor: pointer; +} + +.light-indicator { + width: 20px; + height: 20px; + border-radius: 50%; + display: inline-block; + margin-right: 8px; + border: 2px solid #ccc; + vertical-align: middle; +} + +.connection-controls { + margin-bottom: 25px; + padding: 20px; + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + border-radius: 6px; + border: 1px solid #ddd; +} + +.connection-controls .form-group { + margin-bottom: 0; +} + +.connection-status-connected { + background-color: #5cb85c !important; + color: white; +} + +.connection-status-disconnected { + background-color: #d9534f !important; + color: white; +} + +.connection-status-busy { + background-color: #f0ad4e !important; + color: white; +} + +.device-messages { + height: 200px; + overflow-y: auto; + background-color: #f8f8f8; + padding: 12px; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 12px; + border: 1px solid #ddd; + border-radius: 4px; +} + +.message-timestamp { + color: #999; + margin-right: 8px; +} + +.message-device { + color: #0066cc; + font-weight: bold; + margin-right: 8px; +} + +.message-text { + color: #333; +} + +.message-entry { + padding: 2px 0; + border-bottom: 1px solid #eee; +} + +.message-entry:last-child { + border-bottom: none; +} + +.message-info { + background-color: #f8f9fa; +} + +.message-success { + background-color: #d4edda; + color: #155724; +} + +.message-warning { + background-color: #fff3cd; + color: #856404; +} + +.message-error { + background-color: #f8d7da; + color: #721c24; +} + +.message-source { + color: #0066cc; + font-weight: bold; + margin-right: 8px; +} + +.nav-tabs { + border-bottom: 2px solid #ddd; + margin-bottom: 0; +} + +.nav-tabs > li > a { + border-radius: 4px 4px 0 0; + color: #555; +} + +.nav-tabs > li.active > a { + background-color: #fff; + border-color: #ddd #ddd transparent; + border-width: 2px 1px 1px 1px; + color: #333; + font-weight: bold; +} + +.tab-content { + padding: 20px 0; + background-color: white; + border-left: 1px solid #ddd; + border-right: 1px solid #ddd; + border-bottom: 1px solid #ddd; + border-radius: 0 0 4px 4px; + min-height: 400px; +} + +.property-readonly { + background-color: #f5f5f5; + border-left: 4px solid #bbb; +} + +/* Make text elements in read-only properties more grayish */ +.property-readonly .property-label { + color: #777; +} + +.property-readonly .element-label { + color: #888; +} + +.property-readonly .element-value { + background-color: #e9e9e9; + color: #666; + border-color: #d5d5d5; +} + +/* Make light indicators in read-only properties more grayish */ +.property-readonly .light-indicator { + opacity: 0.7; +} + +/* Make switch elements in read-only properties more grayish */ +.property-readonly .switch-element { + opacity: 0.8; +} + +.property-readonly .switch-on { + background-color: #e0e7e0; + border-color: #c0c8c0; + color: #556755; +} + +.property-readonly .switch-off { + background-color: #ebe5e5; + border-color: #d0c5c5; + color: #6b5555; +} + +.property-writeonly { + border-left: 4px solid #428bca; +} + +.property-readwrite { + border-left: 4px solid #5cb85c; +} + +.btn-property { + font-size: 12px; + padding: 4px 12px; +} + +/* Read-only property styling */ +.element-value { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + background-color: #f8f9fa; + padding: 2px 6px; + border-radius: 3px; + border: 1px solid #e9ecef; + display: inline-block; + min-width: 60px; +} + +.switch-element { + margin-right: 15px; + padding: 3px 8px; + border-radius: 4px; + display: inline-block; +} + +/* Box styling for switch fallback strong elements */ +.switch-element strong { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + background-color: #f8f9fa; + padding: 2px 6px; + border-radius: 3px; + border: 1px solid #e9ecef; + display: inline-block; + min-width: 60px; + font-weight: normal; +} + +.switch-on { + background-color: #d4edda; + border: 1px solid #c3e6cb; + color: #155724; +} + +.switch-off { + background-color: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; +} + +/* Switch rule type styles */ +.switch-rule-info { + margin-bottom: 8px; + font-style: italic; +} + +/* Radio button style switches (OneOfMany, AtMostOne) */ +.switch-radio-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.switch-radio-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; +} + +.switch-radio-item:hover { + background-color: #f8f9fa; +} + +.switch-radio { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid #007bff; + display: inline-block; + margin-right: 8px; + position: relative; + vertical-align: middle; +} + +.switch-radio.radio-selected::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #007bff; +} + +.switch-radio.radio-unselected { + background-color: transparent; +} + +/* Checkbox style switches (AnyOfMany) */ +.switch-checkbox-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.switch-checkbox-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; +} + +.switch-checkbox-item:hover { + background-color: #f8f9fa; +} + +/* Horizontal checkbox style switches (AnyOfMany) */ +.switch-checkbox-group-horizontal { + display: flex; + flex-direction: row; + gap: 15px; + align-items: center; + flex-wrap: wrap; +} + +.switch-checkbox-item-horizontal { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; +} + +.switch-checkbox-item-horizontal:hover { + background-color: #f8f9fa; +} + +.switch-checkbox { + width: 16px; + height: 16px; + border: 2px solid #007bff; + border-radius: 3px; + display: inline-block; + margin-right: 8px; + position: relative; + vertical-align: middle; +} + +.switch-checkbox.checkbox-checked { + background-color: #007bff; +} + +.switch-checkbox.checkbox-checked::before { + content: '✓'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-size: 12px; + font-weight: bold; + line-height: 1; +} + +.switch-checkbox.checkbox-unchecked { + background-color: transparent; +} + +/* Optional button groups (AtMostOne) styling */ +.switch-optional .switch-button.button-active { + background-color: #6c757d; + border-color: #545b62; +} + +.switch-optional .switch-button.button-active:hover { + background-color: #545b62; +} + +/* Switch status indicators */ +.switch-status { + margin-left: auto; + font-size: 12px; + font-weight: bold; + padding: 2px 6px; + border-radius: 3px; + min-width: 30px; + text-align: center; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + background-color: #f8f9fa; + border: 1px solid #e9ecef; + display: inline-block; +} + +.switch-status.status-on { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.switch-status.status-off { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +/* Switch control grouping */ +.switch-control { + display: flex; + align-items: center; + flex: 1; +} + +.switch-control .element-label { + margin-left: 0; + margin-right: 8px; +} + +/* Button group style switches (OneOfMany) */ +.switch-button-group { + display: inline-flex; + flex-wrap: wrap; + gap: 0; + margin: 5px 0; + border: 1px solid #ddd; + border-radius: 4px; + overflow: hidden; +} + +.switch-button { + padding: 8px 16px; + border: none; + border-right: 1px solid #ddd; + background-color: #ffffff; + color: #495057; + font-size: 14px; + font-weight: normal; + transition: all 0.2s ease; + cursor: pointer; + white-space: nowrap; + min-width: auto; +} + +.switch-button:hover { + background-color: #e9ecef; +} + +.switch-button.button-active { + background-color: #28a745; + color: white; + font-weight: bold; + border: 1px solid #1e7e34; + box-shadow: inset 0 1px 3px rgba(0,0,0,0.2); + transform: translateY(1px); +} + +.switch-button.button-active:hover { + background-color: #218838; +} + +.switch-button.button-inactive { + background-color: #f8f9fa; + color: #495057; + border: 1px solid #dee2e6; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + transform: translateY(0px); +} + +/* Blue clicked state for switch buttons */ +.switch-button.button-clicked { + background-color: #007bff !important; + color: white !important; + border-color: #0056b3 !important; + box-shadow: inset 0 1px 3px rgba(0,0,0,0.2); + transform: translateY(1px); +} + +.switch-button:first-child { + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} + +.switch-button:last-child { + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + border-right: none; +} + + +/* Rule type specific styling */ + + + +/* Enhanced visual feedback */ +.switch-radio-item.selected, +.switch-checkbox-item.selected { + background-color: #e7f3ff; + border: 1px solid #b3d9ff; +} + +/* Disabled state for read-only properties */ +.property-readonly .switch-radio, +.property-readonly .switch-checkbox, +.property-readonly .switch-button { + opacity: 0.6; + cursor: not-allowed; +} + +.property-readonly .switch-radio-item, +.property-readonly .switch-checkbox-item { + cursor: not-allowed; +} + +.property-readonly .switch-radio-item:hover, +.property-readonly .switch-checkbox-item:hover { + background-color: transparent; +} + +.property-readonly .switch-button:hover { + background-color: inherit !important; +} + +/* Dark gray styling for read-only button switches */ +.property-readonly .switch-button.button-active { + background-color: #6c757d !important; + color: #f8f9fa !important; + border-color: #495057 !important; +} + +.property-readonly .switch-button.button-inactive { + background-color: #e9ecef !important; + color: #6c757d !important; + border-color: #adb5bd !important; +} + +/* More grayish styling for read-only checkbox switches */ +.property-readonly .switch-checkbox.checkbox-checked { + background-color: #999 !important; + border-color: #777 !important; +} + +.property-readonly .switch-checkbox.checkbox-unchecked { + border-color: #ccc !important; +} + +.property-readonly .switch-status.status-on { + background-color: #e0e7e0 !important; + color: #556755 !important; + border-color: #c0c8c0 !important; +} + +.property-readonly .switch-status.status-off { + background-color: #ebe5e5 !important; + color: #6b5555 !important; + border-color: #d0c5c5 !important; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .property-item { + grid-template-columns: auto 1fr auto; + gap: 4px 8px; + padding: 8px; + margin-bottom: 10px; + } + + .property-header { + grid-column: 1 / -1; + margin-bottom: 6px; + } + + .element-label { + grid-column: 1; + text-align: left; + padding-right: 6px; + font-weight: normal; + font-size: 12px; + min-width: 80px; + } + + .element-value-container { + grid-column: 2; + margin-bottom: 0; + } + + .set-button-container { + grid-column: 2; + grid-row: -1 / -1; + display: flex; + align-items: flex-end; + justify-content: flex-end; + padding-bottom: 0; + } + + .switch-group { + grid-column: 1 / -1; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + margin: 4px 0; + justify-self: start; + } + + .element-input { + max-width: 150px; + width: 150px; + } + + .switch-radio-group, + .switch-checkbox-group { + gap: 6px; + } + + .switch-radio-item, + .switch-checkbox-item { + padding: 4px 6px; + } + + .switch-button-group { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + width: auto; + } + + .switch-button { + padding: 4px 8px; + font-size: 12px; + border-radius: 3px !important; + border-right: 1px solid #ddd !important; + margin: 1px; + } + + .switch-button:last-child { + border-right: none !important; + } +} + +/* Loading animation */ +.loading { + opacity: 0.6; + pointer-events: none; +} + +.spinner { + border: 2px solid #f3f3f3; + border-top: 2px solid #3498db; + border-radius: 50%; + width: 16px; + height: 16px; + animation: spin 1s linear infinite; + display: inline-block; + margin-right: 5px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Disconnected state styling */ +body.disconnected .set-property-btn, +body.disconnected .copy-value-btn, +body.disconnected .switch-button, +body.disconnected .switch-checkbox, +body.disconnected .element-input { + opacity: 0.4 !important; + cursor: not-allowed !important; + pointer-events: none !important; +} + +body.disconnected .property-item { + background-color: #f5f5f5 !important; + border-color: #ddd !important; +} + +body.disconnected .property-readwrite, +body.disconnected .property-writeonly { + border-left-color: #999 !important; +} + +body.disconnected .switch-button.button-active { + background-color: #999 !important; + border-color: #777 !important; + color: #ddd !important; +} + +body.disconnected .switch-button.button-inactive { + background-color: #f0f0f0 !important; + border-color: #ccc !important; + color: #999 !important; +} + +body.disconnected .switch-checkbox.checkbox-checked { + background-color: #999 !important; + border-color: #777 !important; +} + +body.disconnected .switch-checkbox.checkbox-unchecked { + border-color: #ccc !important; +} + +body.disconnected::before { + content: "⚠ DISCONNECTED - Controls Disabled"; + position: fixed; + top: 10px; + right: 10px; + background-color: #d9534f; + color: white; + padding: 8px 16px; + border-radius: 4px; + z-index: 1000; + font-weight: bold; + font-size: 14px; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +/* Legend section styling */ +.legend-section { + margin: 20px 0; + padding: 15px; + background-color: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 4px; +} + +.legend-section h5 { + margin-top: 0; + margin-bottom: 10px; + color: #495057; + font-size: 14px; + font-weight: bold; +} + +.legend-horizontal { + display: flex; + flex-wrap: wrap; + gap: 15px; +} + +.legend-item { + display: flex; + align-items: center; + font-size: 13px; + white-space: nowrap; +} + +.legend-item span { + margin-right: 6px; +} + +.legend-border { + width: 20px; + height: 12px; + border-radius: 2px; + display: inline-block; + margin-right: 8px; +} + +.property-readonly-border { + background-color: #f5f5f5; + border-left: 4px solid #bbb; +} + +.property-readwrite-border { + background-color: #ffffff; + border-left: 4px solid #5cb85c; +} + +.property-writeonly-border { + background-color: #ffffff; + border-left: 4px solid #428bca; +} + +/* Switch info icon styling */ +.switch-info-icon { + display: inline-block; + margin-left: 8px; + cursor: help; + color: #5bc0de; + font-size: 16px; + font-weight: bold; + transition: color 0.2s ease; + position: relative; +} + +.switch-info-icon:hover { + color: #31b0d5; + text-shadow: 0 0 3px rgba(91, 192, 222, 0.5); +} + +/* Custom tooltip */ +.switch-info-tooltip { + visibility: hidden; + opacity: 0; + position: absolute; + z-index: 1000; + background-color: #fff; + color: #000; + text-align: left; + border-radius: 4px; + padding: 10px 12px; + left: 100%; + margin-left: 10px; + top: 50%; + transform: translateY(-50%); + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + border: 1px solid #ccc; + transition: opacity 0.3s ease, visibility 0.3s ease; + font-size: 13px; + font-weight: normal; +} + +.switch-info-tooltip::before { + content: ''; + position: absolute; + top: 50%; + right: 100%; + margin-top: -5px; + border-width: 5px; + border-style: solid; + border-color: transparent #fff transparent transparent; +} + +.switch-info-tooltip::after { + content: ''; + position: absolute; + top: 50%; + right: 100%; + margin-top: -6px; + border-width: 6px; + border-style: solid; + border-color: transparent #ccc transparent transparent; + z-index: -1; +} + +.switch-info-icon:hover .switch-info-tooltip { + visibility: visible; + opacity: 1; +} + +.switch-tooltip-title { + font-weight: bold; + margin-bottom: 6px; + border-bottom: 1px solid #ddd; + padding-bottom: 4px; + color: #333; +} + +.switch-tooltip-item { + padding: 2px 0; + line-height: 1.4; + color: #000; +} + +/* Property type indicators */ +.property-type-text::before { + content: "T"; + background-color: #5bc0de; + color: white; + border-radius: 50%; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: bold; + margin-right: 8px; +} + +.property-type-number::before { + content: "#"; + background-color: #f0ad4e; + color: white; + border-radius: 50%; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: bold; + margin-right: 8px; +} + +.property-type-switch::before { + content: "S"; + background-color: #5cb85c; + color: white; + border-radius: 50%; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: bold; + margin-right: 8px; +} + +.property-type-light::before { + content: "L"; + background-color: #d9534f; + color: white; + border-radius: 50%; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: bold; + margin-right: 8px; +} \ No newline at end of file diff --git a/indiweb/views/device_control.tpl b/indiweb/views/device_control.tpl new file mode 100644 index 0000000..5013cbe --- /dev/null +++ b/indiweb/views/device_control.tpl @@ -0,0 +1,1037 @@ + + + + + + + {{device_name}} - INDI Control Panel + + + + + + +
+
+
+

+ {{device_name}} Control Panel + +

+ + + {% if development_mode %} +
+
+
+
Property Permissions
+
+
+ + Read-only (grayish) +
+
+ + Read-write +
+
+ + Write-only +
+
+
+
+
Property States
+
+
+ + Idle +
+
+ + OK +
+
+ + Busy +
+
+ + Alert +
+
+
+
+
+ {% endif %} + + + + + +
+ +
+ + +
+
+
+
+

Device Messages

+
+
+
+

No messages yet...

+
+
+
+
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/indiweb/views/form.tpl b/indiweb/views/form.tpl index 8c32f03..6e3ce5f 100644 --- a/indiweb/views/form.tpl +++ b/indiweb/views/form.tpl @@ -115,6 +115,14 @@
+
+
+ +
+

No devices connected. Start a profile to see connected devices.

+
+
+
diff --git a/indiweb/views/js/indi.js b/indiweb/views/js/indi.js index 6f47f91..28ba7ec 100644 --- a/indiweb/views/js/indi.js +++ b/indiweb/views/js/indi.js @@ -313,6 +313,7 @@ function getStatus() { $("#server_command").html(" Start"); $("#server_notify").html("

Server is offline.

"); + $("#devices_list").html("

No devices connected. Start a profile to see connected devices.

"); } }); @@ -347,6 +348,53 @@ function getActiveDrivers() } }); + // Also update the devices list + getConnectedDevices(); +} + +function getConnectedDevices() +{ + $.getJSON("api/devices", function (devices) + { + if (devices && devices.length > 0) + { + var devicesList = "
    "; + $.each(devices, function (i, deviceInfo) + { + // Handle both new format (simple string array) and old format (object array) + var deviceName; + if (typeof deviceInfo === 'string') { + deviceName = deviceInfo; + } else if (deviceInfo.device) { + deviceName = deviceInfo.device; + } else { + deviceName = deviceInfo.toString(); + } + + devicesList += "
  • " + + "" + + " " + + deviceName + " Control Panel
  • "; + }); + devicesList += "
"; + $("#devices_list").html(devicesList); + } + else + { + $("#devices_list").html("

No devices connected. Start a profile to see connected devices.

"); + } + }).fail(function() + { + $("#devices_list").html("

No devices connected. Start a profile to see connected devices.

"); + }); +} + +function openDeviceControlPanel(deviceName) +{ + // Open device control panel in a new window/tab + var url = "/device/" + encodeURIComponent(deviceName); + window.open(url, '_blank', 'width=1200,height=800,scrollbars=yes,resizable=yes'); } diff --git a/requirements.txt b/requirements.txt index 732aed5..b9bcff3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ requests==2.32.4 psutil==6.0.0 -bottle==0.12.25 +fastapi==0.104.1 +uvicorn==0.24.0 +jinja2==3.1.2 +python-multipart==0.0.6 importlib_metadata==8.5.0