From 154d3e7ceb490356bd9aa09e1a0bd9079c7eed12 Mon Sep 17 00:00:00 2001 From: Jens Scheidtmann Date: Sat, 20 Sep 2025 22:47:31 +0200 Subject: [PATCH 01/16] Vibe coded property display --- indiweb/indi_client.py | 543 +++++++++++++++++++++ indiweb/main.py | 189 +++++++- indiweb/views/css/device_control.css | 673 +++++++++++++++++++++++++++ indiweb/views/device_control.tpl | 519 +++++++++++++++++++++ indiweb/views/form.tpl | 8 + indiweb/views/js/indi.js | 48 ++ requirements.txt | 5 +- 7 files changed, 1982 insertions(+), 3 deletions(-) create mode 100644 indiweb/indi_client.py create mode 100644 indiweb/views/css/device_control.css create mode 100644 indiweb/views/device_control.tpl diff --git a/indiweb/indi_client.py b/indiweb/indi_client.py new file mode 100644 index 0000000..b9e620d --- /dev/null +++ b/indiweb/indi_client.py @@ -0,0 +1,543 @@ +#!/usr/bin/env python + +import socket +import threading +import xml.etree.ElementTree as ET +import time +import logging +import re +from collections import defaultdict + + +class INDIClient: + """ + A simple 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): + self.host = host + self.port = port + self.socket = None + self.connected = False + self.devices = {} + self.properties = defaultdict(dict) + self.dirty_properties = defaultdict(set) # Track changed properties per device + self.auto_connect_devices = True + self.listeners = [] + self.receive_thread = None + self.running = False + + def connect(self): + """Connect to the INDI server""" + try: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.settimeout(10) # 10 second timeout for connection + self.socket.connect((self.host, self.port)) + self.socket.settimeout(None) # Remove timeout after connection + self.connected = True + self.running = True + + # Start receive thread + self.receive_thread = threading.Thread(target=self._receive_loop) + self.receive_thread.daemon = True + self.receive_thread.start() + + # Request device list + self.send_message('') + logging.info(f"Connected to INDI server at {self.host}:{self.port}") + + # Wait a bit for initial properties to load + time.sleep(1) + return True + + 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""" + self.running = False + if self.socket: + try: + self.socket.close() + except: + pass + self.connected = False + self.socket = None + logging.info("Disconnected from INDI server") + + def send_message(self, message): + """Send a message to the INDI server""" + if not self.connected or not self.socket: + return False + + try: + self.socket.send(message.encode() + b'\n') + return True + except Exception as e: + logging.error(f"Failed to send message: {e}") + return False + + def _receive_loop(self): + """Main receive loop for processing INDI messages""" + buffer = "" + + while self.running and self.connected: + try: + data = self.socket.recv(4096).decode() + if not data: + break + + buffer += data + + # Process complete XML messages + while True: + # Find the start and end of an XML message + start = buffer.find('<') + if start == -1: + break + + # Find the matching closing tag + tag_end = buffer.find('>', start) + if tag_end == -1: + break + + tag_name = buffer[start+1:tag_end].split()[0] + if tag_name.endswith('/'): + # Self-closing tag + message = buffer[start:tag_end+1] + buffer = buffer[tag_end+1:] + self._process_message(message) + else: + # Find closing tag + closing_tag = f"" + end = buffer.find(closing_tag, tag_end) + if end == -1: + break + + message = buffer[start:end + len(closing_tag)] + buffer = buffer[end + len(closing_tag):] + self._process_message(message) + + except Exception as e: + logging.error(f"Error in receive loop: {e}") + break + + self.connected = False + + def _process_message(self, message): + """Process an INDI XML message""" + try: + root = ET.fromstring(message) + + if root.tag == 'defTextVector': + self._process_def_property(root, 'text') + elif root.tag == 'defNumberVector': + self._process_def_property(root, 'number') + elif root.tag == 'defSwitchVector': + self._process_def_property(root, 'switch') + elif root.tag == 'defLightVector': + self._process_def_property(root, 'light') + elif root.tag == 'defBLOBVector': + self._process_def_property(root, 'blob') + elif root.tag in ['setTextVector', 'setNumberVector', 'setSwitchVector', 'setLightVector', 'setBLOBVector']: + self._process_set_property(root) + elif root.tag == 'delProperty': + self._process_del_property(root) + elif root.tag == 'message': + self._process_message_tag(root) + + except ET.ParseError as e: + logging.warning(f"Failed to parse XML message: {e}") + except Exception as e: + logging.error(f"Error processing message: {e}") + + def _process_def_property(self, root, prop_type): + """Process a property definition message""" + device = root.get('device') + name = root.get('name') + label = root.get('label', name) + group = root.get('group', 'Main') + state = root.get('state', 'Idle') + perm = root.get('perm', 'rw') + rule = root.get('rule') if prop_type == 'switch' else None + + if device not in self.devices: + self.devices[device] = {} + + elements = {} + for child in root: + if child.tag.startswith('def'): + elem_name = child.get('name') + elem_label = child.get('label', elem_name) + elem_value = (child.text or '').strip() + + element = { + 'name': elem_name, + 'label': elem_label, + 'value': elem_value + } + + # Add type-specific attributes + if prop_type == 'number': + element.update({ + 'min': child.get('min'), + 'max': child.get('max'), + 'step': child.get('step'), + 'format': child.get('format') + }) + + elements[elem_name] = element + + property_data = { + 'name': name, + 'label': label, + 'group': group, + 'type': prop_type, + 'state': state.lower(), + 'perm': perm, + 'rule': rule, + 'elements': elements, + 'device': device + } + + # Apply formatting to number properties + property_data = self._apply_formatting_to_property(property_data) + + self.properties[device][name] = property_data + self.devices[device][name] = property_data + + # Mark property as dirty (new property) + self.dirty_properties[device].add(name) + + # Auto-connect device if it has a CONNECTION property + if self.auto_connect_devices and name == 'CONNECTION': + self._auto_connect_device(device) + + # Notify listeners + self._notify_listeners('property_defined', device, property_data) + + def _process_set_property(self, root): + """Process a property update message""" + device = root.get('device') + name = root.get('name') + state = root.get('state', 'Idle') + + if device in self.properties and name in self.properties[device]: + prop = self.properties[device][name] + prop['state'] = state.lower() + + # Update element values + for child in root: + if child.tag.startswith('one'): + elem_name = child.get('name') + if elem_name in prop['elements']: + prop['elements'][elem_name]['value'] = (child.text or '').strip() + + # Apply formatting to updated property + prop = self._apply_formatting_to_property(prop) + + # Mark property as dirty (updated property) + self.dirty_properties[device].add(name) + + # Notify listeners + self._notify_listeners('property_updated', device, prop) + + def _process_del_property(self, root): + """Process a property deletion message""" + device = root.get('device') + name = root.get('name') + + if device in self.properties: + if name: + # Delete specific property + if name in self.properties[device]: + del self.properties[device][name] + self._notify_listeners('property_deleted', device, {'name': name}) + else: + # Delete all properties for device + self.properties[device] = {} + if device in self.devices: + del self.devices[device] + self._notify_listeners('device_deleted', device, None) + + def _process_message_tag(self, root): + """Process a message tag""" + device = root.get('device') + message = root.text or '' + timestamp = root.get('timestamp', str(time.time())) + + self._notify_listeners('message', device, { + 'message': message, + 'timestamp': timestamp + }) + + 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""" + return list(self.devices.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 device_name in self.devices: + return True + time.sleep(0.1) + return False + + def set_property(self, device_name, property_name, elements): + """Set property values""" + prop = self.get_property(device_name, property_name) + if not prop: + return False + + prop_type = prop['type'] + message = "" + + 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(): + message += f'{value}' + message += '' + + elif prop_type == 'switch': + message = f'' + for elem_name, value in elements.items(): + message += f'{value}' + message += '' + + return self.send_message(message) + + def is_connected(self): + """Check if connected to INDI server""" + return self.connected + + 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}") + connection_prop = self.get_property(device_name, 'CONNECTION') + if connection_prop and connection_prop['elements'].get('CONNECT'): + # Only connect if not already connected + if connection_prop['elements']['CONNECT']['value'] != 'On': + self.set_property(device_name, 'CONNECTION', {'CONNECT': 'On', 'DISCONNECT': 'Off'}) + + # 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 get_dirty_properties(self, device_name): + """Get list of properties that have changed since last check""" + dirty_props = list(self.dirty_properties[device_name]) + # Clear dirty flags after returning them + self.dirty_properties[device_name].clear() + return dirty_props + + def get_changed_properties(self, device_name, property_names): + """Get current values for specified properties""" + if device_name not in self.properties: + return {} + + result = {} + for prop_name in property_names: + if prop_name in self.properties[device_name]: + result[prop_name] = self.properties[device_name][prop_name] + + return result + + 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..0249f39 100644 --- a/indiweb/main.py +++ b/indiweb/main.py @@ -20,6 +20,7 @@ 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 # default settings WEB_HOST = '0.0.0.0' @@ -90,7 +91,6 @@ # 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 @@ -377,6 +377,17 @@ async def start_server(profile: str, response: Response): active_profile = profile 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) + + asyncio.create_task(start_client()) + return {"message": f"INDI server started for profile {profile}"} @@ -386,6 +397,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 = "" @@ -560,7 +572,180 @@ async def get_devices(): Returns: str: A JSON string representing the connected devices. """ - return JSONResponse(content=indi_device.get_devices()) + # 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('/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} + ) + 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/{device_name}/structure', tags=["Devices"]) +async def get_device_structure(device_name: str): + """ + Gets the property structure for a specific device. + + Args: + device_name (str): The name of the device. + + Returns: + dict: A dictionary containing the device property structure grouped by property groups. + """ + 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") + 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}/dirty', tags=["Devices"]) +async def get_dirty_properties(device_name: str): + """ + Gets list of properties that have changed since last check. + + Args: + device_name (str): The name of the device. + + Returns: + list: A list of property names that have changed. + """ + client = get_indi_client() + if not client.is_connected(): + raise HTTPException(status_code=503, detail="INDI client not connected") + + dirty_props = client.get_dirty_properties(device_name) + return JSONResponse(content=dirty_props) + + +@app.post('/api/devices/{device_name}/properties/batch', tags=["Devices"]) +async def get_changed_properties(device_name: str, request: Request): + """ + Gets current values for specified properties. + + Args: + device_name (str): The name of the device. + request: The request containing the list of property names. + + Returns: + dict: Current values for the specified properties. + """ + client = get_indi_client() + if not client.is_connected(): + raise HTTPException(status_code=503, detail="INDI client not connected") + + data = await request.json() + property_names = data.get('properties', []) + + if not property_names: + raise HTTPException(status_code=400, detail="No property names provided") + + properties = client.get_changed_properties(device_name, property_names) + return JSONResponse(content=properties) + + +@app.get('/api/devices/{device_name}/groups', tags=["Devices"]) +async def get_device_groups(device_name: str): + """ + Gets property groups for a specific device. + + Args: + device_name (str): The name of the device. + + Returns: + dict: A dictionary containing device property groups. + """ + 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): + """ + Gets a specific property for a device. + + Args: + device_name (str): The name of the device. + property_name (str): The name of the property. + + Returns: + dict: The property data. + """ + 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) + + ############################################################################### # 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..9f6b82d --- /dev/null +++ b/indiweb/views/css/device_control.css @@ -0,0 +1,673 @@ +/* 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; +} + +.property-header { + display: flex; + align-items: center; + margin-bottom: 10px; +} + +.property-label { + font-weight: bold; + margin-right: 10px; + min-width: 150px; + color: #333; +} + +.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 { + margin-left: 20px; +} + +.element-row { + display: flex; + align-items: center; + margin-bottom: 8px; + padding: 5px 0; +} + +.element-label { + min-width: 140px; + margin-right: 10px; + font-weight: normal; + color: #666; +} + +.element-input { + margin-right: 10px; + max-width: 200px; +} + +.property-buttons { + margin-top: 12px; + padding-top: 8px; + border-top: 1px solid #eee; +} + +.switch-group { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin: 5px 0; +} + +.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: #f9f9f9; + border-left: 4px solid #ccc; +} + +.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; +} + +.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; +} + +.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; +} + +.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); +} + +.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; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .element-row { + flex-direction: column; + align-items: flex-start; + } + + .element-label { + min-width: auto; + margin-bottom: 5px; + } + + .element-input { + max-width: 100%; + width: 100%; + } + + .switch-group { + flex-direction: column; + gap: 5px; + } + + .switch-radio-group, + .switch-checkbox-group { + gap: 6px; + } + + .switch-radio-item, + .switch-checkbox-item { + padding: 6px 8px; + } + + .switch-button-group { + flex-direction: column; + display: flex; + align-items: flex-start; + width: auto; + } + + .switch-button { + border-radius: 0 !important; + border-right: none !important; + border-bottom: 1px solid #ddd; + text-align: left; + width: auto; + min-width: 120px; + } + + .switch-button:first-child { + border-top-left-radius: 3px !important; + border-top-right-radius: 3px !important; + } + + .switch-button:last-child { + border-bottom-left-radius: 3px !important; + border-bottom-right-radius: 3px !important; + border-bottom: none !important; + } + + .property-header { + flex-direction: column; + align-items: flex-start; + } + + .property-label { + min-width: auto; + margin-bottom: 5px; + } +} + +/* 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); } +} + +/* 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..de5cdcb --- /dev/null +++ b/indiweb/views/device_control.tpl @@ -0,0 +1,519 @@ + + + + + + + {{device_name}} - INDI Control Panel + + + + + + +
+
+
+

{{device_name}} Control Panel

+ + +
+
+
+
+ + Loading... + Properties update automatically +
+
+
+
+ + + + + +
+ +
+ + +
+
+
+
+

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 From 2f24d32740675e30c8c69ad4399eda11d45a71ad Mon Sep 17 00:00:00 2001 From: Jens Scheidtmann Date: Sun, 21 Sep 2025 20:22:06 +0200 Subject: [PATCH 02/16] Add ui elements for entering values. Send these to backend. Backend logs received values. --- indiweb/main.py | 51 +++- indiweb/views/css/device_control.css | 216 ++++++++++++++++- indiweb/views/device_control.tpl | 339 +++++++++++++++++++++++++-- 3 files changed, 583 insertions(+), 23 deletions(-) diff --git a/indiweb/main.py b/indiweb/main.py index 0249f39..f336518 100644 --- a/indiweb/main.py +++ b/indiweb/main.py @@ -648,10 +648,10 @@ async def get_device_structure(device_name: str): return JSONResponse(content=structure) -@app.get('/api/devices/{device_name}/dirty', tags=["Devices"]) -async def get_dirty_properties(device_name: str): +@app.get('/api/devices/{device_name}/poll', tags=["Devices"]) +async def poll_device_properties(device_name: str): """ - Gets list of properties that have changed since last check. + Polls for properties that have changed since last check. Args: device_name (str): The name of the device. @@ -746,6 +746,51 @@ async def get_device_property(device_name: str, property_name: str): 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): + """ + Sets values for a specific property on a device. + + Args: + device_name (str): The name of the device. + property_name (str): The name of the property. + request: The request containing the property element values. + + Returns: + dict: Success response or error details. + """ + 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 for now + logging.warning(f"Setting property {device_name}.{property_name} with values: {elements}") + + # TODO: Implement actual INDI property setting here + # For now, just log and return success + + return JSONResponse(content={ + "success": True, + "message": f"Property {property_name} set request received", + "device": device_name, + "property": property_name, + "elements": elements + }) + + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON in request body") + except Exception as e: + logging.error(f"Error setting property {device_name}.{property_name}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + ############################################################################### # System control endpoints diff --git a/indiweb/views/css/device_control.css b/indiweb/views/css/device_control.css index 9f6b82d..1cecf8e 100644 --- a/indiweb/views/css/device_control.css +++ b/indiweb/views/css/device_control.css @@ -70,6 +70,8 @@ align-items: center; margin-bottom: 8px; padding: 5px 0; + flex-wrap: wrap; + gap: 5px; } .element-label { @@ -77,11 +79,15 @@ margin-right: 10px; font-weight: normal; color: #666; + flex-shrink: 0; } .element-input { margin-right: 10px; - max-width: 200px; + max-width: 150px; + margin-left: 5px; + display: inline-block; + vertical-align: middle; } .property-buttons { @@ -90,6 +96,56 @@ 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%); + cursor: pointer; + flex-shrink: 0; +} + +.copy-value-btn:hover { + background: linear-gradient(to bottom, #f8f8f8 0%, #e8e8e8 100%); + border-color: #aaa; +} + +.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); +} + +.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 { display: flex; flex-wrap: wrap; @@ -228,8 +284,45 @@ } .property-readonly { - background-color: #f9f9f9; - border-left: 4px solid #ccc; + 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 { @@ -263,6 +356,18 @@ 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; @@ -398,6 +503,10 @@ 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 { @@ -474,6 +583,15 @@ 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; @@ -519,6 +637,41 @@ 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) { .element-row { @@ -611,6 +764,63 @@ 100% { transform: rotate(360deg); } } +/* 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; +} + /* Property type indicators */ .property-type-text::before { content: "T"; diff --git a/indiweb/views/device_control.tpl b/indiweb/views/device_control.tpl index de5cdcb..e66b3d6 100644 --- a/indiweb/views/device_control.tpl +++ b/indiweb/views/device_control.tpl @@ -29,6 +29,50 @@
+ +
+
+
+
Property Permissions
+
+
+ + Read-only (grayish) +
+
+ + Read-write +
+
+ + Write-only +
+
+
+
+
Property States
+
+
+ + Idle +
+
+ + OK +
+
+ + Busy +
+
+ + Alert +
+
+
+
+
+