Prepare for stable release 2025.12.09

- Remove all DEBUG print statements from AIAssistant plugin
- Update version to 2025.12.09 across all build files
  - src/cthulhu/cthulhuVersion.py
  - meson.build
  - distro-packages/Arch-Linux/PKGBUILD
- Add OCR optional dependencies to meson.build
  - pdf2image: PDF processing for OCR
  - scipy: Scientific computing for OCR analysis
  - webcolors: Color name resolution for OCR
- Add explicit OCR Python packages to PKGBUILD optdepends
  - python-pdf2image
  - python-scipy
  - python-webcolors
- Remove temporary test files from repository root
  - test_atspi_version.py
  - test_axtext_basic.py
  - test_modern_atspi_keystroke.py

All changes validated with successful local build.
Ready for final testing before stable release tag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Storm Dragon
2025-12-09 09:21:52 -05:00
parent 11240bfcbc
commit 53614b13b9
5 changed files with 509 additions and 68 deletions

View File

@@ -1,7 +1,7 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org> # Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=cthulhu pkgname=cthulhu
pkgver=2025.08.14 pkgver=2025.12.09
pkgrel=1 pkgrel=1
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
url="https://git.stormux.org/storm/cthulhu" url="https://git.stormux.org/storm/cthulhu"
@@ -60,6 +60,9 @@ optdepends=(
# OCR plugin dependencies (optional) # OCR plugin dependencies (optional)
'python-pillow: Image processing for OCR and AI Assistant' 'python-pillow: Image processing for OCR and AI Assistant'
'python-pytesseract: Python wrapper for Tesseract OCR engine' 'python-pytesseract: Python wrapper for Tesseract OCR engine'
'python-pdf2image: PDF to image conversion for OCR'
'python-scipy: Scientific computing for OCR color analysis'
'python-webcolors: Color name lookup for OCR text decoration'
'tesseract: OCR engine for text recognition' 'tesseract: OCR engine for text recognition'
'tesseract-data-eng: English language data for Tesseract' 'tesseract-data-eng: English language data for Tesseract'
) )

View File

@@ -1,5 +1,5 @@
project('cthulhu', project('cthulhu',
version: '2025.08.11', version: '2025.12.09',
meson_version: '>= 1.0.0', meson_version: '>= 1.0.0',
) )
@@ -54,6 +54,9 @@ optional_modules = {
'dasbus': 'D-Bus remote controller', 'dasbus': 'D-Bus remote controller',
'psutil': 'system information commands', 'psutil': 'system information commands',
'gi.repository.Wnck': 'mouse review', 'gi.repository.Wnck': 'mouse review',
'pdf2image': 'PDF processing for OCR',
'scipy': 'Scientific computing for OCR analysis',
'webcolors': 'Color name resolution for OCR',
} }
summary = {} summary = {}

View File

@@ -23,5 +23,5 @@
# Fork of Orca Screen Reader (GNOME) # Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca # Original source: https://gitlab.gnome.org/GNOME/orca
version = "2025.08.22" version = "2025.12.09"
codeName = "testing" codeName = "testing"

View File

@@ -46,9 +46,7 @@ class AIAssistant(Plugin):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialize the AI Assistant plugin.""" """Initialize the AI Assistant plugin."""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Use print to ensure we see this message
print("DEBUG: AI ASSISTANT __init__ called")
logger.info("AI ASSISTANT: Plugin __init__ starting") logger.info("AI ASSISTANT: Plugin __init__ starting")
logger.info("AI ASSISTANT: Plugin initialized successfully") logger.info("AI ASSISTANT: Plugin initialized successfully")
@@ -79,51 +77,40 @@ class AIAssistant(Plugin):
# Prevent multiple activations # Prevent multiple activations
if self._enabled: if self._enabled:
logger.info("AI ASSISTANT: Already activated, skipping") logger.info("AI ASSISTANT: Already activated, skipping")
print("DEBUG: AI ASSISTANT already activated, skipping")
return return
try: try:
logger.info("AI ASSISTANT: === Plugin activation starting ===") logger.info("AI ASSISTANT: === Plugin activation starting ===")
print("DEBUG: AI ASSISTANT activation starting")
# Check if AI Assistant is enabled in settings # Check if AI Assistant is enabled in settings
enabled = self._settings_manager.getSetting('aiAssistantEnabled') enabled = self._settings_manager.getSetting('aiAssistantEnabled')
logger.info(f"AI ASSISTANT: Enabled setting: {enabled}") logger.info(f"AI ASSISTANT: Enabled setting: {enabled}")
print(f"DEBUG: AI ASSISTANT enabled setting: {enabled}")
if not enabled: if not enabled:
logger.info("AI Assistant is disabled in settings, skipping activation") logger.info("AI Assistant is disabled in settings, skipping activation")
print("DEBUG: AI Assistant disabled, skipping activation")
return return
# Load AI settings # Load AI settings
self._load_ai_settings() self._load_ai_settings()
print(f"DEBUG: AI settings loaded - provider: {self._provider_type}")
# Check if we have valid configuration # Check if we have valid configuration
config_valid = self._validate_configuration() config_valid = self._validate_configuration()
logger.info(f"AI Assistant configuration valid: {config_valid}") logger.info(f"AI Assistant configuration valid: {config_valid}")
print(f"DEBUG: AI Assistant configuration valid: {config_valid}")
# Initialize AI provider (may fail but we still want menu access) # Initialize AI provider (may fail but we still want menu access)
if config_valid: if config_valid:
provider_init = self._initialize_ai_provider() provider_init = self._initialize_ai_provider()
print(f"DEBUG: AI provider initialization: {provider_init}")
else: else:
logger.warning("AI Assistant configuration invalid, menu will show error messages") logger.warning("AI Assistant configuration invalid, menu will show error messages")
print("DEBUG: AI Assistant configuration invalid, menu will show error messages")
provider_init = False provider_init = False
# Always register keybindings so menu is accessible even with config issues # Always register keybindings so menu is accessible even with config issues
self._register_keybindings() self._register_keybindings()
print("DEBUG: AI keybindings registered")
self._enabled = True self._enabled = True
logger.info("AI Assistant plugin activated successfully") logger.info("AI Assistant plugin activated successfully")
print("DEBUG: AI Assistant plugin activated successfully")
except Exception as e: except Exception as e:
logger.error(f"Error activating AI Assistant plugin: {e}") logger.error(f"Error activating AI Assistant plugin: {e}")
print(f"DEBUG: Error activating AI Assistant plugin: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
@@ -144,44 +131,35 @@ class AIAssistant(Plugin):
"""Refresh plugin settings and reinitialize provider. Called when settings change.""" """Refresh plugin settings and reinitialize provider. Called when settings change."""
try: try:
logger.info("AI Assistant: Refreshing settings") logger.info("AI Assistant: Refreshing settings")
print("DEBUG: AI Assistant refreshing settings")
# Reload settings # Reload settings
self._load_ai_settings() self._load_ai_settings()
# Validate new configuration # Validate new configuration
config_valid = self._validate_configuration() config_valid = self._validate_configuration()
print(f"DEBUG: New configuration valid: {config_valid}")
# Reinitialize provider if configuration is valid # Reinitialize provider if configuration is valid
if config_valid: if config_valid:
old_provider = self._ai_provider old_provider = self._ai_provider
provider_init = self._initialize_ai_provider() provider_init = self._initialize_ai_provider()
print(f"DEBUG: Provider reinitialization: {provider_init}")
if provider_init: if provider_init:
logger.info(f"AI Assistant provider changed to: {self._provider_type}") logger.info(f"AI Assistant provider changed to: {self._provider_type}")
print(f"DEBUG: Provider successfully changed to: {self._provider_type}")
else: else:
logger.warning("Failed to initialize new provider") logger.warning("Failed to initialize new provider")
print("DEBUG: Failed to initialize new provider")
self._ai_provider = None self._ai_provider = None
else: else:
logger.warning("New configuration invalid, clearing provider") logger.warning("New configuration invalid, clearing provider")
print("DEBUG: New configuration invalid, clearing provider")
self._ai_provider = None self._ai_provider = None
except Exception as e: except Exception as e:
logger.error(f"Error refreshing AI Assistant settings: {e}") logger.error(f"Error refreshing AI Assistant settings: {e}")
print(f"DEBUG: Error refreshing settings: {e}")
def _load_ai_settings(self): def _load_ai_settings(self):
"""Load AI Assistant settings from Cthulhu configuration.""" """Load AI Assistant settings from Cthulhu configuration."""
try: try:
# Get provider # Get provider
provider = self._settings_manager.getSetting('aiProvider') provider = self._settings_manager.getSetting('aiProvider')
print(f"DEBUG: Raw provider setting: '{provider}'")
self._provider_type = provider or settings.AI_PROVIDER_CLAUDE_CODE self._provider_type = provider or settings.AI_PROVIDER_CLAUDE_CODE
print(f"DEBUG: Final provider type: '{self._provider_type}'")
# Load API key from file # Load API key from file
api_key_file = self._settings_manager.getSetting('aiApiKeyFile') api_key_file = self._settings_manager.getSetting('aiApiKeyFile')
@@ -308,10 +286,9 @@ class AIAssistant(Plugin):
"Show AI Assistant menu", "Show AI Assistant menu",
'kb:cthulhu+shift+control+a' 'kb:cthulhu+shift+control+a'
) )
logger.info("AI Assistant menu keybinding registered") logger.info("AI Assistant menu keybinding registered")
print(f"DEBUG: AI Assistant menu keybinding registered: {self._kb_binding_menu}")
except Exception as e: except Exception as e:
logger.error(f"Error registering AI menu keybinding: {e}") logger.error(f"Error registering AI menu keybinding: {e}")
@@ -325,22 +302,18 @@ class AIAssistant(Plugin):
"""Show the AI Assistant menu.""" """Show the AI Assistant menu."""
try: try:
logger.info("AI ASSISTANT: _show_ai_menu called!") logger.info("AI ASSISTANT: _show_ai_menu called!")
print("DEBUG: AI ASSISTANT _show_ai_menu called!")
# IMPORTANT: Capture screen data BEFORE showing menu # IMPORTANT: Capture screen data BEFORE showing menu
# This ensures we get the actual screen content, not the menu itself # This ensures we get the actual screen content, not the menu itself
self._pre_menu_screen_data = self._collect_ai_data() self._pre_menu_screen_data = self._collect_ai_data()
logger.info("Pre-captured screen data for menu actions") logger.info("Pre-captured screen data for menu actions")
print("DEBUG: Pre-captured screen data for menu actions")
# Now show the menu # Now show the menu
self._menu_gui = AIAssistantMenu(self._handle_menu_selection) self._menu_gui = AIAssistantMenu(self._handle_menu_selection)
self._menu_gui.show_gui() self._menu_gui.show_gui()
print("DEBUG: AI menu GUI shown")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error showing AI menu: {e}") logger.error(f"Error showing AI menu: {e}")
print(f"DEBUG: Error showing AI menu: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return False return False
@@ -407,22 +380,17 @@ class AIAssistant(Plugin):
"""Handle browsing for an image file to analyze.""" """Handle browsing for an image file to analyze."""
try: try:
logger.info("AI image file browsing requested") logger.info("AI image file browsing requested")
print("DEBUG: _handle_browse_image_file called")
if not self._enabled: if not self._enabled:
print("DEBUG: AI Assistant not enabled")
self._present_message("AI Assistant is not enabled") self._present_message("AI Assistant is not enabled")
return True return True
if not self._ai_provider: if not self._ai_provider:
print("DEBUG: AI provider not available")
self._present_message("AI provider not available. Check configuration.") self._present_message("AI provider not available. Check configuration.")
return True return True
# Show file chooser dialog # Show file chooser dialog
print("DEBUG: About to show file chooser")
image_file = self._show_image_file_chooser() image_file = self._show_image_file_chooser()
print(f"DEBUG: File chooser returned: {image_file}")
if image_file: if image_file:
provider_name = self._provider_type.replace('_', ' ').title() provider_name = self._provider_type.replace('_', ' ').title()
@@ -536,10 +504,8 @@ class AIAssistant(Plugin):
"""Handle main AI Assistant activation - now shows action dialog.""" """Handle main AI Assistant activation - now shows action dialog."""
try: try:
logger.info("AI Assistant activation requested") logger.info("AI Assistant activation requested")
print("DEBUG: AI Assistant activation keybinding triggered!")
if not self._enabled: if not self._enabled:
print("DEBUG: AI Assistant not enabled, presenting message")
self._present_message("AI Assistant is not enabled") self._present_message("AI Assistant is not enabled")
return True return True
@@ -2077,13 +2043,9 @@ class AIAssistantMenu(Gtk.Dialog):
# Connect keyboard events for Enter key handling # Connect keyboard events for Enter key handling
self.connect("key-press-event", self._on_key_press) self.connect("key-press-event", self._on_key_press)
print("DEBUG: AIAssistantMenu dialog created with radio buttons")
def _on_response(self, dialog, response_id): def _on_response(self, dialog, response_id):
"""Handler for dialog response.""" """Handler for dialog response."""
print(f"DEBUG: Dialog response: {response_id}")
if response_id == Gtk.ResponseType.OK: if response_id == Gtk.ResponseType.OK:
# Determine which radio button is selected # Determine which radio button is selected
if self.radio_ask.get_active(): if self.radio_ask.get_active():
@@ -2098,9 +2060,8 @@ class AIAssistantMenu(Gtk.Dialog):
action_id = "browse_image_file" action_id = "browse_image_file"
else: else:
action_id = None action_id = None
if action_id: if action_id:
print(f"DEBUG: Selected action: {action_id}")
self.on_option_selected(action_id) self.on_option_selected(action_id)
self.destroy() self.destroy()
@@ -2116,15 +2077,12 @@ class AIAssistantMenu(Gtk.Dialog):
def show_gui(self): def show_gui(self):
"""Shows the AI Assistant dialog.""" """Shows the AI Assistant dialog."""
try: try:
print("DEBUG: Starting dialog show_gui")
self.show_all() self.show_all()
print("DEBUG: Dialog show_all() called - should be visible and accessible now")
# Present the dialog to ensure it gets focus # Present the dialog to ensure it gets focus
self.present() self.present()
print("DEBUG: Dialog presented")
except Exception as e: except Exception as e:
print(f"DEBUG: Error in show_gui: {e}") logger.error(f"Error in show_gui: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()

View File

@@ -0,0 +1,477 @@
#!/usr/bin/python
# generate_dbus_documentation.py
#
# Generate markdown documentation for Cthulhu's D-Bus remote controller.
# Must be run while Cthulhu is active with the D-Bus service enabled.
#
# Copyright 2025 Igalia, S.L.
# Author: Joanmarie Diggs <jdiggs@igalia.com>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
"""Generate markdown documentation for Cthulhu's D-Bus remote controller."""
import os
import sys
from dasbus.connection import SessionMessageBus
from dasbus.error import DBusError
SERVICE_NAME = "org.stormux.Cthulhu.Service"
SERVICE_PATH = "/org/stormux/Cthulhu/Service"
def get_system_commands(proxy):
"""Get system-level commands from the main service interface."""
try:
commands = proxy.ListCommands()
return sorted(commands, key=lambda x: x[0])
except DBusError as e:
print(f"Error getting system commands: {e}", file=sys.stderr)
return []
def get_modules(proxy):
"""Get list of registered modules."""
try:
modules = proxy.ListModules()
return sorted(modules)
except DBusError as e:
print(f"Error getting modules: {e}", file=sys.stderr)
return []
def get_module_info(bus, module_name):
"""Get all commands, parameterized commands, getters, and setters for a module."""
object_path = f"{SERVICE_PATH}/{module_name}"
try:
module_proxy = bus.get_proxy(SERVICE_NAME, object_path)
commands = module_proxy.ListCommands()
parameterized_commands = module_proxy.ListParameterizedCommands()
getters = module_proxy.ListRuntimeGetters()
setters = module_proxy.ListRuntimeSetters()
return {
"commands": sorted(commands, key=lambda x: x[0]),
"parameterized_commands": sorted(parameterized_commands, key=lambda x: x[0]),
"getters": sorted(getters, key=lambda x: x[0]),
"setters": sorted(setters, key=lambda x: x[0])
}
except DBusError as e:
print(f"Error getting info for module {module_name}: {e}", file=sys.stderr)
return {
"commands": [],
"parameterized_commands": [],
"getters": [],
"setters": []
}
def format_system_commands(commands):
"""Format system-level commands as markdown."""
lines = []
lines.append("## Service-Level Commands")
lines.append("")
lines.append(
"These commands are available directly on the main service object "
"at `/org/stormux/Cthulhu/Service`."
)
lines.append("")
for name, description in commands:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
return "\n".join(lines)
def _group_structural_navigator_commands(commands):
"""Group structural navigator commands by object type."""
groups = {}
other = []
def normalize_obj_type(obj_type): # pylint: disable=too-many-return-statements
"""Convert to a canonical form for grouping."""
# Group all heading commands together. Ditto for link commands.
if obj_type.startswith("Heading"):
return "Heading"
if "Link" in obj_type:
return "Link"
# Common plural patterns
if obj_type.endswith("ies"): # Entries -> Entry
return obj_type[:-3] + "y"
if obj_type.endswith("xes"): # Checkboxes -> Checkbox
return obj_type[:-2]
if obj_type.endswith("shes") or obj_type.endswith("ches"): # Matches -> Match
return obj_type[:-2]
if obj_type.endswith("s") and not obj_type.endswith("ss"):
return obj_type[:-1]
return obj_type
for name, description in commands:
# Extract the object type from command names like NextHeading, ListButtons, etc.
if name.startswith("Next") or name.startswith("Previous"):
prefix = "Next" if name.startswith("Next") else "Previous"
obj_type = name[len(prefix):]
normalized = normalize_obj_type(obj_type)
if normalized not in groups:
if normalized == "Heading":
display = "Headings"
elif not obj_type.endswith("s"):
display = obj_type + "s"
else:
display = obj_type
groups[normalized] = {"commands": [], "display_name": display}
groups[normalized]["commands"].append((name, description))
elif name.startswith("List"):
obj_type = name[4:]
normalized = normalize_obj_type(obj_type)
if normalized not in groups:
display = "Headings" if normalized == "Heading" else obj_type
groups[normalized] = {"commands": [], "display_name": display}
else:
current_display = groups[normalized]["display_name"]
if not current_display.endswith("s") or normalized == "Heading":
new_display = "Headings" if normalized == "Heading" else obj_type
groups[normalized]["display_name"] = new_display
groups[normalized]["commands"].append((name, description))
else:
# Other commands like ContainerStart, CycleMode
other.append((name, description))
return groups, other
# pylint: disable-next=too-many-branches,too-many-statements,too-many-locals
def format_module_commands(module_name, info):
"""Format module-level commands as markdown."""
lines = []
lines.append(f"### {module_name}")
lines.append("")
lines.append(f"**Object Path:** `/org/stormux/Cthulhu/Service/{module_name}`")
lines.append("")
# Commands - special handling for certain modules
if info["commands"]:
lines.append("#### Commands")
lines.append("")
lines.append("**Method:** `org.stormux.Cthulhu.Module.ExecuteCommand`")
lines.append("")
lines.append(
"**Parameters:** `CommandName` (string), "
"[`NotifyUser`](README-REMOTE-CONTROLLER.md#user-notification-applicability) (boolean)"
)
lines.append("")
if module_name == "SpeechAndVerbosityManager":
# Group related increase/decrease commands
def sort_speech_commands(cmd_tuple):
name, _ = cmd_tuple
# Define groups and their order
groups = {
"Rate": 0, "Pitch": 1, "Volume": 2,
}
# Check if it's an Increase/Decrease command
for group_name, group_order in groups.items():
if group_name in name:
if name.startswith("Increase"):
return (group_order, 0, name)
if name.startswith("Decrease"):
return (group_order, 1, name)
# Other commands go at the end, sorted alphabetically
return (100, 0, name)
sorted_commands = sorted(info["commands"], key=sort_speech_commands)
for name, description in sorted_commands:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
elif module_name == "FlatReviewPresenter":
# Group Go commands at the top
def sort_flat_review_commands(cmd_tuple):
name, _ = cmd_tuple
if name.startswith("Go"):
return (0, name)
return (1, name)
sorted_commands = sorted(info["commands"], key=sort_flat_review_commands)
for name, description in sorted_commands:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
elif module_name == "CaretNavigator":
# Group related navigation commands
def sort_caret_commands(cmd_tuple):
name, _ = cmd_tuple
# Define groups for Character, Word, Line, File
if "Character" in name:
group = 0
elif "Word" in name:
group = 1
elif "Line" in name:
group = 2
elif "File" in name:
group = 3
else:
group = 100
# Within each group: Next, Previous, Start, End, Toggle
if name.startswith("Next"):
order = 0
elif name.startswith("Previous"):
order = 1
elif name.startswith("Start"):
order = 0
elif name.startswith("End"):
order = 1
elif name.startswith("Toggle"):
order = 2
else:
order = 99
return (group, order, name)
sorted_commands = sorted(info["commands"], key=sort_caret_commands)
for name, description in sorted_commands:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
elif module_name == "StructuralNavigator":
groups, other = _group_structural_navigator_commands(info["commands"])
# Show grouped commands by object type first
for obj_type in sorted(groups.keys()):
cmds = groups[obj_type]
display_name = cmds["display_name"]
lines.append(f"##### {display_name}")
lines.append("")
# Sort commands in a specific order: Next, Previous, List, grouped by variant
def sort_key(cmd_tuple):
name, _ = cmd_tuple
# Extract base command and any suffix (like Level1, UnvisitedLink, etc.)
if name.startswith("Next"):
prefix_order = 0
suffix = name[4:] # Remove "Next"
elif name.startswith("Previous"):
prefix_order = 1
suffix = name[8:] # Remove "Previous"
elif name.startswith("List"):
prefix_order = 2
suffix = name[4:] # Remove "List"
else:
prefix_order = 3
suffix = name
# Extract level number or variant for proper ordering
level_or_variant = 0
if "Level" in suffix:
# For headings: extract level number
try:
level_or_variant = int(suffix.split("Level")[1])
except (IndexError, ValueError):
pass
elif "Unvisited" in suffix:
# For links: Unvisited comes after base
level_or_variant = 1
elif "Visited" in suffix:
# For links: Visited comes after Unvisited
level_or_variant = 2
return (level_or_variant, prefix_order, name)
sorted_commands = sorted(cmds["commands"], key=sort_key)
for name, desc in sorted_commands:
lines.append(f"- **`{name}`:** {desc}")
lines.append("")
# Show uncategorized commands at the end
if other:
lines.append("##### Other")
lines.append("")
for name, description in other:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
else:
for name, description in info["commands"]:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
# Parameterized Commands
if info["parameterized_commands"]:
lines.append("#### Parameterized Commands")
lines.append("")
lines.append("**Method:** `org.stormux.Cthulhu.Module.ExecuteParameterizedCommand`")
lines.append("")
for name, description, parameters in info["parameterized_commands"]:
param_list = ", ".join([f"`{pname}` ({ptype})" for pname, ptype in parameters])
if param_list:
lines.append(f"- **`{name}`:** {description} Parameters: {param_list}")
else:
lines.append(f"- **`{name}`:** {description}")
lines.append("")
# Runtime Settings (combine getters and setters)
if info["getters"] or info["setters"]:
lines.append("#### Settings")
lines.append("")
lines.append("**Methods:** `org.stormux.Cthulhu.Module.ExecuteRuntimeGetter` / `org.stormux.Cthulhu.Module.ExecuteRuntimeSetter`")
lines.append("")
lines.append(
"**Parameters:** `PropertyName` (string), "
"`Value` (variant, setter only)"
)
lines.append("")
# Build a merged dictionary of properties
# Prefer setter descriptions as they may contain range/default info
properties = {}
for name, description in info["getters"]:
properties[name] = {"description": description, "getter": True, "setter": False}
for name, description in info["setters"]:
if name in properties:
properties[name]["setter"] = True
properties[name]["description"] = description
else:
properties[name] = {"description": description, "getter": False, "setter": True}
# Output sorted properties with annotations
for name in sorted(properties.keys()):
prop = properties[name]
description = prop["description"]
annotation = ""
if prop["getter"] and not prop["setter"]:
annotation = " (getter only)"
elif prop["setter"] and not prop["getter"]:
annotation = " (setter only)"
else:
# Both getter and setter - change "Returns" or "Sets" to "Gets/Sets"
if description.startswith("Returns "):
description = description.replace("Returns ", "Gets/Sets ", 1)
elif description.startswith("Sets "):
description = description.replace("Sets ", "Gets/Sets ", 1)
lines.append(f"- **`{name}`:** {description}{annotation}")
lines.append("")
return "\n".join(lines)
def generate_documentation():
"""Generate the complete documentation."""
try:
bus = SessionMessageBus()
except DBusError as e:
print(f"Error connecting to D-Bus: {e}", file=sys.stderr)
return None
try:
proxy = bus.get_proxy(SERVICE_NAME, SERVICE_PATH)
except DBusError as e:
print(f"Error connecting to Cthulhu service: {e}", file=sys.stderr)
print("Make sure Cthulhu is running with the D-Bus service enabled.", file=sys.stderr)
return None
system_commands = get_system_commands(proxy)
modules = get_modules(proxy)
module_infos = {}
for module_name in modules:
module_infos[module_name] = get_module_info(bus, module_name)
total_commands = len(system_commands)
total_commands += sum(len(info["commands"]) for info in module_infos.values())
total_commands += sum(len(info["parameterized_commands"]) for info in module_infos.values())
total_getters = sum(len(info["getters"]) for info in module_infos.values())
total_setters = sum(len(info["setters"]) for info in module_infos.values())
lines = []
lines.append("# Cthulhu D-Bus Service Commands Reference")
lines.append("")
lines.append(
f"This document lists all commands ({total_commands}), "
f"runtime getters ({total_getters}), and runtime setters ({total_setters}) available"
)
lines.append("via Cthulhu's D-Bus Remote Controller interface.")
lines.append("")
lines.append("The service can be accessed at:")
lines.append("")
lines.append("- **Service Name:** `org.stormux.Cthulhu.Service`")
lines.append("- **Main Object Path:** `/org/stormux/Cthulhu/Service`")
lines.append("- **Module Object Paths:** `/org/stormux/Cthulhu/Service/ModuleName`")
lines.append("")
lines.append(
"Additional information about using the remote controller can be found in "
"[README-REMOTE-CONTROLLER.md](README-REMOTE-CONTROLLER.md)."
)
lines.append("")
lines.append("---")
lines.append("")
# System commands
lines.append(format_system_commands(system_commands))
lines.append("---")
lines.append("")
# Module commands
lines.append("## Modules")
lines.append("")
lines.append(
"Each module exposes commands, getters, and setters on its object "
"at `/org/stormux/Cthulhu/Service/ModuleName`."
)
lines.append("")
for module_name in modules:
lines.append(format_module_commands(module_name, module_infos[module_name]))
lines.append("---")
lines.append("")
return "\n".join(lines)
def main():
"""Main entry point."""
# Write to parent directory since script is in tools/
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(script_dir)
output_file = os.path.join(parent_dir, "REMOTE-CONTROLLER-COMMANDS.md")
print("Generating D-Bus documentation...", file=sys.stderr)
documentation = generate_documentation()
if documentation is None:
print("Failed to generate documentation.", file=sys.stderr)
return 1
try:
with open(output_file, "w", encoding="utf-8") as f:
f.write(documentation)
print(f"Documentation written to {output_file}", file=sys.stderr)
return 0
except IOError as e:
print(f"Error writing to {output_file}: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())