diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..b96f885 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,78 @@ +name: Build and Deploy Documentation + +on: + push: + branches: + - main + - master + paths: + - 'docs/**' + - 'src/pi_pianoteq/**/*.py' + - '.github/workflows/docs.yml' + pull_request: + branches: + - main + - master + paths: + - 'docs/**' + - 'src/pi_pianoteq/**/*.py' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install documentation dependencies + run: | + pip install -r docs/requirements.txt + + - name: Build documentation + run: | + sphinx-build -b html docs/source docs/build/html --keep-going + + # Upload as regular artifact for PRs (downloadable) + - name: Upload documentation artifact + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: documentation-preview + path: docs/build/html + retention-days: 7 + + # Upload as pages artifact for deployment (pushes to main/master only) + - name: Upload Pages artifact + if: github.event_name == 'push' + uses: actions/upload-pages-artifact@v3 + with: + path: 'docs/build/html' + + deploy: + # Only deploy on pushes to main/master, not on PRs + if: github.event_name == 'push' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index e3fc46c..05a8eec 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ build/ pi_pianoteq.service pi-pianoteq.service +# Sphinx documentation build output +docs/build/ + pi-pianoteq/* backup-*/ htmlcov/ diff --git a/README.md b/README.md index ce440be..16a56cb 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Pi-Pianoteq is a Python/MIDI remote control for Pianoteq on Raspberry Pi. +**📚 [Developer Documentation](https://tlsim.github.io/pi-pianoteq/)** - Complete API docs for creating custom clients + ## About Pi-Pianoteq provides a simplified hardware interface for controlling Pianoteq on a Raspberry Pi 4B using the [Pimoroni GFX HAT](https://github.com/pimoroni/gfx-hat) - a HAT with 128x64 LCD display, 6 touch buttons and RGB backlight. After configuration, you can run Pianoteq without needing a monitor, using the GFX HAT as your interface. @@ -141,6 +143,21 @@ For developers who want to build and deploy to a remote Pi, see [docs/developmen ## Documentation +### For Developers + +Want to create a custom client for pi-pianoteq? (e.g., web interface, OLED display, rotary encoders, touchscreen, mobile app) + +**📚 [Complete Developer Documentation](https://tlsim.github.io/pi-pianoteq/)** + +The online documentation includes: +- **Client Development Guide** - Step-by-step guide to creating custom clients +- **Architecture Overview** - System design and patterns +- **API Reference** - Complete ClientApi documentation with examples +- **Minimal Example Client** - Working reference implementation +- **Testing Guide** - How to test your custom client + +### For Users + - [docs/systemd.md](docs/systemd.md) - Running as a systemd service - [docs/development.md](docs/development.md) - Development and deployment workflow - [docs/pianoteq-api.md](docs/pianoteq-api.md) - Pianoteq JSON-RPC API reference diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..3fbe96b --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +sphinx>=8.0 +sphinx-rtd-theme>=3.0 +myst-parser>=4.0 +toml>=0.10 diff --git a/docs/source/_static/.gitkeep b/docs/source/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/_templates/.gitkeep b/docs/source/_templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000..4bcdb0e --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,304 @@ +============= +API Reference +============= + +This page documents the Client and ClientApi interfaces you'll use when creating a custom client. + +Client Interface +================ + +Implement these four methods in your client: + +__init__(api) +------------- + +.. code-block:: python + + def __init__(self, api: Optional[ClientApi]): + """Initialize your client. API will be None during startup.""" + +**When called**: At application start, before Pianoteq launches + +**Parameters**: ``api`` - Will be ``None`` initially + +**What to do**: Initialize your display hardware or UI components + +set_api(api) +------------ + +.. code-block:: python + + def set_api(self, api: ClientApi): + """Receive the API once Pianoteq is ready.""" + +**When called**: After the JSON-RPC API initializes and instruments are loaded + +**Parameters**: ``api`` - The ClientApi instance to use + +**What to do**: Store the API reference, optionally cache instrument data + +**Note**: Initialization typically takes 6-8 seconds on a Raspberry Pi, but may be faster on other hardware + +show_loading_message(message) +------------------------------ + +.. code-block:: python + + def show_loading_message(self, message: str): + """Display a loading message during startup.""" + +**When called**: During startup, may be called multiple times + +**Parameters**: ``message`` - Usually "Starting..." or "Loading..." + +**What to do**: Display the message on your UI + +start() +------- + +.. code-block:: python + + def start(self): + """Begin normal operation.""" + +**When called**: After ``set_api()``, when system is ready + +**What to do**: Start your main event loop. This method typically blocks until exit. + +ClientApi Interface +=================== + +These methods are available on the API object you receive in ``set_api()``. + +Preset Navigation +----------------- + +set_preset_next() +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + api.set_preset_next() + +Navigate to the next preset in the current instrument. Wraps around to the first preset. + +set_preset_prev() +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + api.set_preset_prev() + +Navigate to the previous preset in the current instrument. Wraps around to the last preset. + +get_current_preset() +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + preset = api.get_current_preset() + +**Returns**: The current :class:`Preset` object + +**Example**: + +.. code-block:: python + + preset = api.get_current_preset() + print(preset.display_name) # "Classical" + +set_preset(instrument_name, preset_name) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + api.set_preset(instrument_name, preset_name) + +Load a specific preset. Switches to the instrument if needed. + +**Parameters**: +- ``instrument_name`` - Full instrument name (e.g., "D4 Grand Piano") +- ``preset_name`` - Full preset name (use ``preset.name``, not ``preset.display_name``) + +**Example**: + +.. code-block:: python + + api.set_preset("D4 Grand Piano", "D4 Grand Piano Classical") + +Instrument Navigation +--------------------- + +set_instrument_next() +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + api.set_instrument_next() + +Navigate to the next instrument. Wraps around to the first instrument. Also loads the first preset of that instrument. + +set_instrument_prev() +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + api.set_instrument_prev() + +Navigate to the previous instrument. Wraps around to the last instrument. Also loads the first preset of that instrument. + +get_current_instrument() +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + instrument = api.get_current_instrument() + +**Returns**: The current :class:`Instrument` object + +**Example**: + +.. code-block:: python + + instrument = api.get_current_instrument() + print(instrument.name) # "D4 Grand Piano" + +set_instrument(name) +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + api.set_instrument(name) + +Switch to a specific instrument by name. Also loads the first preset. + +**Parameters**: ``name`` - Full instrument name + +**Example**: + +.. code-block:: python + + api.set_instrument("D4 Grand Piano") + +get_instruments() +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + instruments = api.get_instruments() + +**Returns**: List of all available :class:`Instrument` objects + +**Example**: + +.. code-block:: python + + instruments = api.get_instruments() + for inst in instruments: + print(f"{inst.name} - {len(inst.presets)} presets") + +get_presets(instrument_name) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + presets = api.get_presets(instrument_name) + +Get all presets for a specific instrument. + +**Parameters**: ``instrument_name`` - Full instrument name + +**Returns**: List of :class:`Preset` objects, or empty list if instrument not found + +**Example**: + +.. code-block:: python + + presets = api.get_presets("D4 Grand Piano") + for preset in presets: + print(preset.display_name) + +Utility Methods +--------------- + +shutdown_device() +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + api.shutdown_device() + +Shut down the system (Raspberry Pi). Use for hardware shutdown buttons. + +⚠️ **Warning**: This will power off the system! + +set_on_exit(callback) +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + api.set_on_exit(callback) + +Register a cleanup function to run before shutdown. + +**Parameters**: ``callback`` - Function to call (takes no arguments) + +**Example**: + +.. code-block:: python + + def cleanup(): + self.display.clear() + self.stop_threads() + + api.set_on_exit(cleanup) + +version() +~~~~~~~~~ + +.. code-block:: python + + version = ClientApi.version() + +**Returns**: API version string (currently "1.0.0") + +Complete Example +================ + +.. code-block:: python + + from pi_pianoteq.client import Client, ClientApi + from typing import Optional + + class MyClient(Client): + def __init__(self, api: Optional[ClientApi]): + self.api = api + + def set_api(self, api: ClientApi): + self.api = api + # Cache data if needed + self.instruments = api.get_instruments() + + def show_loading_message(self, message: str): + print(f"Loading: {message}") + + def start(self): + while True: + # Display current state + inst = self.api.get_current_instrument() + preset = self.api.get_current_preset() + print(f"{inst.name} - {preset.display_name}") + + # Handle input + cmd = input("Command (n/p): ") + if cmd == 'n': + self.api.set_preset_next() + elif cmd == 'p': + self.api.set_preset_prev() + +See Also +======== + +- :doc:`guide` - Complete development guide +- :doc:`data-models` - Instrument and Preset details +- :doc:`example` - Full minimal client example diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..17d9ce3 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,97 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +import os +import sys + +# Add the project root to sys.path so Sphinx can find the package +sys.path.insert(0, os.path.abspath('../../src')) + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'pi-pianoteq' +copyright = '2024, Tom Simmons' +author = 'Tom Simmons' + +# The version info for the project you're documenting +# Read from pyproject.toml +import toml +with open('../../pyproject.toml', 'r') as f: + pyproject = toml.load(f) + release = pyproject['project']['version'] + version = '.'.join(release.split('.')[:2]) # e.g., "1.6" from "1.6.0" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.napoleon', + 'sphinx.ext.viewcode', + 'sphinx.ext.intersphinx', + 'myst_parser', +] + +templates_path = ['_templates'] +exclude_patterns = [] + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'sphinx_rtd_theme' +html_static_path = ['_static'] + +# -- Extension configuration ------------------------------------------------- + +# Napoleon settings for Google/NumPy style docstrings +napoleon_google_style = True +napoleon_numpy_style = False +napoleon_include_init_with_doc = True +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = True +napoleon_use_admonition_for_examples = False +napoleon_use_admonition_for_notes = False +napoleon_use_admonition_for_references = False +napoleon_use_ivar = False +napoleon_use_param = True +napoleon_use_rtype = True +napoleon_preprocess_types = False +napoleon_type_aliases = None +napoleon_attr_annotations = True + +# Autodoc settings +autodoc_default_options = { + 'members': True, + 'member-order': 'bysource', + 'special-members': '__init__', + 'undoc-members': True, + 'exclude-members': '__weakref__' +} + +# Mock imports for dependencies not available during doc build +autodoc_mock_imports = [ + 'rtmidi', + 'gfxhat', + 'prompt_toolkit', + 'PIL', + 'pillow', +] + +# Intersphinx mapping for cross-references to other documentation +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), +} + +# MyST parser settings (for markdown support) +myst_enable_extensions = [ + "colon_fence", + "deflist", +] + +# Suppress warnings for known issues +suppress_warnings = [ + 'myst.xref_missing', # Missing cross-references in existing markdown +] diff --git a/docs/source/data-models.rst b/docs/source/data-models.rst new file mode 100644 index 0000000..ee4542a --- /dev/null +++ b/docs/source/data-models.rst @@ -0,0 +1,182 @@ +=========== +Data Models +=========== + +This page documents the ``Instrument`` and ``Preset`` objects you'll work with when creating a client. + +Instrument +========== + +Represents a Pianoteq instrument with its presets and UI colors. + +Attributes +---------- + +name +~~~~ + +.. code-block:: python + + instrument.name # "D4 Grand Piano" + +The full display name of the instrument. + +preset_prefix +~~~~~~~~~~~~~ + +.. code-block:: python + + instrument.preset_prefix # "D4" + +Short prefix used to group presets (usually the first part of the name). + +background_primary +~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + instrument.background_primary # "#1e3a5f" + +Hex color for primary UI elements (e.g., LED backlights, backgrounds). + +background_secondary +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + instrument.background_secondary # "#0f1d2f" + +Hex color for secondary UI elements (e.g., gradients). + +presets +~~~~~~~ + +.. code-block:: python + + instrument.presets # List of Preset objects + +List of all presets available for this instrument. + +Example Usage +------------- + +.. code-block:: python + + instrument = api.get_current_instrument() + + print(f"Name: {instrument.name}") + print(f"Presets: {len(instrument.presets)}") + + # Use colors for RGB LED + color = instrument.background_primary # "#1e3a5f" + hex_color = color.lstrip('#') + r = int(hex_color[0:2], 16) + g = int(hex_color[2:4], 16) + b = int(hex_color[4:6], 16) + set_led_rgb(r, g, b) + + # List all presets + for preset in instrument.presets: + print(f" - {preset.display_name}") + +Preset +====== + +Represents an instrument preset. + +Attributes +---------- + +name +~~~~ + +.. code-block:: python + + preset.name # "D4 Grand Piano Classical" + +The full preset name as Pianoteq knows it. Use this when calling ``api.set_preset()``. + +display_name +~~~~~~~~~~~~ + +.. code-block:: python + + preset.display_name # "Classical" + +Shortened name for display purposes (common prefix removed). Use this in your UI. + +Example Usage +------------- + +.. code-block:: python + + preset = api.get_current_preset() + + # Display the short name + print(f"Playing: {preset.display_name}") + + # Load this preset explicitly + api.set_preset(instrument.name, preset.name) + +Working with Both +================= + +Getting All Presets +------------------- + +.. code-block:: python + + # Get presets for current instrument + instrument = api.get_current_instrument() + presets = instrument.presets + + # Get presets for specific instrument + presets = api.get_presets("D4 Grand Piano") + +Building a Menu +--------------- + +.. code-block:: python + + def show_preset_menu(): + instrument = api.get_current_instrument() + current_preset = api.get_current_preset() + + print(f"Presets for {instrument.name}:") + for i, preset in enumerate(instrument.presets, 1): + marker = "→" if preset == current_preset else " " + print(f"{marker} {i}. {preset.display_name}") + +Comparing Objects +----------------- + +You can use ``==`` to compare instruments and presets: + +.. code-block:: python + + current_instrument = api.get_current_instrument() + all_instruments = api.get_instruments() + + for inst in all_instruments: + if inst == current_instrument: + print(f"→ {inst.name} (current)") + else: + print(f" {inst.name}") + +Important Notes +=============== + +**Use display_name for UI**: Always show ``preset.display_name`` to users, not ``preset.name``. + +**Use name for API calls**: When calling ``api.set_preset()``, use the full ``preset.name``. + +**Objects are read-only**: Don't modify instrument or preset attributes directly. Use the API methods to change state. + +**Objects are cached**: Instruments and presets are created at startup and don't change during runtime. + +See Also +======== + +- :doc:`api` - API methods that return these objects +- :doc:`guide` - Using these models in your client +- :doc:`example` - Complete working example diff --git a/docs/source/example.rst b/docs/source/example.rst new file mode 100644 index 0000000..f74c9d4 --- /dev/null +++ b/docs/source/example.rst @@ -0,0 +1,185 @@ +=============== +Minimal Example +=============== + +This is a complete, working console client that demonstrates the essentials of creating a pi-pianoteq client. + +Complete Source +=============== + +.. code-block:: python + + """ + minimal_client.py - Simple console client for pi-pianoteq + + Demonstrates: + - Two-phase initialization + - Using the ClientApi + - Navigation and display + """ + + from pi_pianoteq.client import Client, ClientApi + from typing import Optional + + class MinimalClient(Client): + def __init__(self, api: Optional[ClientApi]): + self.api = api + self.running = False + + def set_api(self, api: ClientApi): + """Store the API when it becomes available.""" + self.api = api + + def show_loading_message(self, message: str): + """Display loading messages during startup.""" + print(f"\n{'='*50}") + print(f" {message}") + print(f"{'='*50}\n") + + def start(self): + """Main client loop.""" + self.running = True + self.show_help() + + while self.running: + self.display_current_state() + self.handle_input() + + def display_current_state(self): + """Show current instrument and preset.""" + if self.api is None: + return + + instrument = self.api.get_current_instrument() + preset = self.api.get_current_preset() + + print(f"\n{'-'*50}") + print(f"Instrument: {instrument.name}") + print(f"Preset: {preset.display_name}") + print(f"{'-'*50}") + + def handle_input(self): + """Process user commands.""" + try: + cmd = input("\nCommand: ").lower().strip() + + if cmd == 'n': + self.api.set_preset_next() + elif cmd == 'p': + self.api.set_preset_prev() + elif cmd == 'i': + self.show_instruments() + elif cmd == 'q': + self.running = False + else: + print(f"Unknown command: {cmd}") + self.show_help() + + except (KeyboardInterrupt, EOFError): + print("\nExiting...") + self.running = False + + def show_instruments(self): + """Display all available instruments.""" + instruments = self.api.get_instruments() + current = self.api.get_current_instrument() + + print(f"\n{'='*50}") + print("Available Instruments") + print(f"{'='*50}") + + for inst in instruments: + marker = "→" if inst == current else " " + print(f"{marker} {inst.name} ({len(inst.presets)} presets)") + + def show_help(self): + """Show available commands.""" + print("\nCommands:") + print(" n - Next preset") + print(" p - Previous preset") + print(" i - Show instruments") + print(" q - Quit") + +How It Works +============ + +Phase 1: Loading Mode +---------------------- + +.. code-block:: python + + client = MinimalClient(api=None) + client.show_loading_message("Starting Pianoteq...") + +The client is created without an API. Only ``show_loading_message()`` can be called. + +Phase 2: API Ready +------------------ + +.. code-block:: python + + client.set_api(client_lib) + +Once Pianoteq is ready, the API is provided via ``set_api()``. + +Phase 3: Normal Operation +-------------------------- + +.. code-block:: python + + client.start() + +The ``start()`` method begins the main loop, using the API to display state and handle input. + +Key Points +========== + +1. **Always check for None**: During loading, ``api`` is ``None`` +2. **Use display_name**: For presets, use ``preset.display_name`` (not ``preset.name``) +3. **Handle errors gracefully**: Catch ``KeyboardInterrupt`` and ``EOFError`` +4. **Keep it simple**: The core loop is just display + input + update + +Extending This Example +====================== + +Add Preset Selection +-------------------- + +.. code-block:: python + + def select_preset(self): + instrument = self.api.get_current_instrument() + print(f"\nPresets for {instrument.name}:") + + for i, preset in enumerate(instrument.presets, 1): + print(f" {i}. {preset.display_name}") + + choice = int(input("\nSelect preset number: ")) + if 1 <= choice <= len(instrument.presets): + preset = instrument.presets[choice - 1] + self.api.set_preset(instrument.name, preset.name) + +Add Instrument Colors +--------------------- + +.. code-block:: python + + def display_with_color(self): + instrument = self.api.get_current_instrument() + color = instrument.background_primary + + # For terminals that support ANSI colors + hex_color = color.lstrip('#') + r, g, b = [int(hex_color[i:i+2], 16) for i in (0, 2, 4)] + + print(f"\033[38;2;{r};{g};{b}m{instrument.name}\033[0m") + +Next Steps +========== + +- Read the :doc:`guide` for more patterns and best practices +- Check the :doc:`api` for all available methods +- Look at the real clients: + + - `GfxhatClient `_ - Hardware display with buttons + - `CliClient `_ - Full-featured terminal interface diff --git a/docs/source/guide.rst b/docs/source/guide.rst new file mode 100644 index 0000000..1aee5d8 --- /dev/null +++ b/docs/source/guide.rst @@ -0,0 +1,298 @@ +================== +Client Development +================== + +This guide shows you how to create custom clients for pi-pianoteq. + +Creating a Client +================= + +Implement the ``Client`` Interface +----------------------------------- + +Your client must implement four methods: + +.. code-block:: python + + from pi_pianoteq.client import Client, ClientApi + from typing import Optional + + class MyClient(Client): + def __init__(self, api: Optional[ClientApi]): + """Initialize your client. API will be None during startup.""" + self.api = api + + def set_api(self, api: ClientApi): + """Called when the API is ready. Store it for later use.""" + self.api = api + + def show_loading_message(self, message: str): + """Display loading messages like 'Starting...' and 'Loading...'""" + print(f"Loading: {message}") + + def start(self): + """Main entry point. Start your event loop here.""" + while True: + self.update_display() + self.handle_input() + +Two-Phase Initialization +========================= + +Why Two Phases? +--------------- + +Pianoteq's JSON-RPC API takes time to initialize and load instruments (typically 6-8 seconds on a Raspberry Pi, faster on other hardware). During this startup: + +1. Client created with ``api=None`` +2. Loading messages shown via ``show_loading_message()`` +3. Once the API is ready and instruments are loaded, ``set_api()`` called with the real API +4. Finally, ``start()`` called to begin normal operation + +**Phase 1 - Loading Mode** (api=None): + +.. code-block:: python + + client = MyClient(api=None) + client.show_loading_message("Starting Pianoteq...") + # Can't use API yet - just show loading screen + +**Phase 2 - API Ready**: + +.. code-block:: python + + client.set_api(client_lib) + # API available - cache data if needed + +**Phase 3 - Normal Operation**: + +.. code-block:: python + + client.start() + # Main loop - use API freely + +Using the ClientApi +=================== + +The ``ClientApi`` provides all control methods you need. + +Get Current State +----------------- + +.. code-block:: python + + # Get current instrument + instrument = self.api.get_current_instrument() + print(instrument.name) # e.g., "D4 Grand Piano" + + # Get current preset + preset = self.api.get_current_preset() + print(preset.display_name) # e.g., "Classical" + +Navigate Presets +---------------- + +.. code-block:: python + + # Next/previous preset + self.api.set_preset_next() + self.api.set_preset_prev() + + # Load specific preset + self.api.set_preset("D4 Grand Piano", "D4 Grand Piano Classical") + +Navigate Instruments +-------------------- + +.. code-block:: python + + # Next/previous instrument + self.api.set_instrument_next() + self.api.set_instrument_prev() + + # Switch to specific instrument + self.api.set_instrument("D4 Grand Piano") + + # Get all instruments + instruments = self.api.get_instruments() + for inst in instruments: + print(f"{inst.name} has {len(inst.presets)} presets") + +Get Presets for an Instrument +------------------------------ + +.. code-block:: python + + # Get presets for current instrument + instrument = self.api.get_current_instrument() + presets = instrument.presets + + # Get presets for any instrument + presets = self.api.get_presets("D4 Grand Piano") + +Data Models +=========== + +Instrument +---------- + +.. code-block:: python + + instrument.name # "D4 Grand Piano" + instrument.preset_prefix # "D4" + instrument.background_primary # "#1e3a5f" (for UI theming) + instrument.background_secondary # "#0f1d2f" + instrument.presets # List of Preset objects + +Preset +------ + +.. code-block:: python + + preset.name # "D4 Grand Piano Classical" (full name) + preset.display_name # "Classical" (short name for display) + +See :doc:`data-models` for complete details. + +Complete Example +================ + +Here's a simple console client: + +.. code-block:: python + + from pi_pianoteq.client import Client, ClientApi + from typing import Optional + + class ConsoleClient(Client): + def __init__(self, api: Optional[ClientApi]): + self.api = api + self.running = False + + def set_api(self, api: ClientApi): + self.api = api + + def show_loading_message(self, message: str): + print(f"\n{'='*40}") + print(f" {message}") + print(f"{'='*40}\n") + + def start(self): + self.running = True + self.show_help() + + while self.running: + self.display_current() + command = input("\nCommand (n/p/q): ").lower() + + if command == 'n': + self.api.set_preset_next() + elif command == 'p': + self.api.set_preset_prev() + elif command == 'q': + self.running = False + + def display_current(self): + if self.api is None: + return + + instrument = self.api.get_current_instrument() + preset = self.api.get_current_preset() + + print(f"\nInstrument: {instrument.name}") + print(f"Preset: {preset.display_name}") + + def show_help(self): + print("\nCommands:") + print(" n - Next preset") + print(" p - Previous preset") + print(" q - Quit") + +For a more complete example, see :doc:`example`. + +Common Patterns +=============== + +Building a Menu +--------------- + +.. code-block:: python + + def show_instrument_menu(self): + instruments = self.api.get_instruments() + current = self.api.get_current_instrument() + + for i, inst in enumerate(instruments): + marker = "→" if inst == current else " " + print(f"{marker} {inst.name}") + +Handling User Input +------------------- + +.. code-block:: python + + def handle_button_press(self, button): + if button == 'next': + self.api.set_preset_next() + self.update_display() + elif button == 'prev': + self.api.set_preset_prev() + self.update_display() + +Using Instrument Colors +----------------------- + +For hardware with RGB LEDs: + +.. code-block:: python + + instrument = self.api.get_current_instrument() + color = instrument.background_primary # e.g., "#1e3a5f" + + # Convert hex to RGB + hex_color = color.lstrip('#') + r = int(hex_color[0:2], 16) + g = int(hex_color[2:4], 16) + b = int(hex_color[4:6], 16) + + set_led_color(r, g, b) + +Thread Safety +============= + +The ClientApi is **not thread-safe**. If you use multiple threads: + +**Option 1 (Recommended)**: Only call the API from one thread + +.. code-block:: python + + def start(self): + # All API calls from main thread + while self.running: + self.handle_input() # Calls API + self.update_display() # Calls API + +**Option 2**: Use locks + +.. code-block:: python + + import threading + + def __init__(self, api): + self.api = api + self.api_lock = threading.Lock() + + def safe_next_preset(self): + with self.api_lock: + self.api.set_preset_next() + +Next Steps +========== + +- Study the :doc:`example` - Complete minimal client implementation +- Check the :doc:`api` - Full API reference +- Read the :doc:`data-models` - Instrument and Preset details +- Look at the source: + + - `GfxhatClient `_ - Hardware client + - `CliClient `_ - Terminal client diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..cb75f69 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,126 @@ +.. pi-pianoteq documentation master file + +Creating Custom Clients for pi-pianoteq +======================================== + +**Want to create your own interface for pi-pianoteq?** This guide shows you how to build custom clients for different hardware or platforms. + +Pi-pianoteq provides a simple client API that lets you create interfaces for: + +- **Hardware displays**: OLED screens, E-ink displays, LED matrices, touchscreens +- **Input devices**: Rotary encoders, physical buttons, MIDI controllers +- **Software interfaces**: Web apps, mobile apps, desktop GUIs, voice control + +Why Use This Instead of Direct JSON-RPC? +----------------------------------------- + +**You could** use Pianoteq's JSON-RPC API directly, but the client API simplifies development: + +1. **Process management** - Automatically starts and stops Pianoteq +2. **State synchronization** - Handles initialization and syncs with Pianoteq's current state +3. **Demo filtering** - Automatically filters out demo instruments (only shows licensed ones) +4. **Pure Python API** - Simple method calls instead of JSON-RPC requests +5. **Typed data models** - Work with ``Instrument`` and ``Preset`` objects instead of raw JSON dictionaries +6. **Shortened preset names** - Removes instrument prefix for cleaner display on small screens (e.g., "Classical" instead of "D4 Grand Piano Classical") +7. **Preset grouping** - Presets organized by instrument, not a flat list +8. **UI theming** - Built-in instrument colors for display customization + +**Example comparison:** + +.. code-block:: python + + # Direct JSON-RPC (complex) + response = requests.post('http://localhost:8081/jsonrpc', json={ + 'jsonrpc': '2.0', + 'method': 'getListOfPresets', + 'id': 1 + }) + presets = response.json()['result'] # Raw list of dicts + # Now send MIDI Program Change... + # Now update selection state... + # Now filter demos... + + # Client API (simple) + api.set_preset_next() # That's it! + +Quick Start +----------- + +**Three steps to create a client:** + +1. Read the :doc:`guide` - Understand the client architecture +2. Study the :doc:`example` - See a complete working implementation +3. Use the :doc:`api` - Reference for all available methods + +.. toctree:: + :maxdepth: 2 + :hidden: + :caption: Client Development + + guide + example + api + data-models + +.. toctree:: + :maxdepth: 1 + :hidden: + :caption: Reference + + reference/development + reference/systemd + +How It Works +------------ + +Your client implements two simple interfaces: + +.. code-block:: python + + from pi_pianoteq.client import Client, ClientApi + + class MyClient(Client): + def __init__(self, api): + self.api = api + + def set_api(self, api): + self.api = api # Receive API when ready + + def show_loading_message(self, message): + # Show "Starting..." and "Loading..." messages + + def start(self): + # Your main loop - display info, handle input + +The ``ClientApi`` gives you control: + +.. code-block:: python + + # Navigate presets + api.set_preset_next() + api.set_preset_prev() + + # Get current state + instrument = api.get_current_instrument() + preset = api.get_current_preset() + + # Navigate instruments + api.set_instrument_next() + instruments = api.get_instruments() + +That's it! See the guide for details. + +Examples +-------- + +**Hardware Clients:** + +- `GfxhatClient `_ - Pimoroni GFX HAT (128x64 LCD, 6 buttons) +- `CliClient `_ - Terminal interface with menus + +**Your client here!** - Submit a PR to add your implementation + +User Documentation +------------------ + +Looking for installation and usage docs? See the `main README `_. diff --git a/docs/source/reference/development.md b/docs/source/reference/development.md new file mode 100644 index 0000000..4a8d3dc --- /dev/null +++ b/docs/source/reference/development.md @@ -0,0 +1,2 @@ +```{include} ../../development.md +``` diff --git a/docs/source/reference/pianoteq-api.md b/docs/source/reference/pianoteq-api.md new file mode 100644 index 0000000..917c5de --- /dev/null +++ b/docs/source/reference/pianoteq-api.md @@ -0,0 +1,2 @@ +```{include} ../../pianoteq-api.md +``` diff --git a/docs/source/reference/systemd.md b/docs/source/reference/systemd.md new file mode 100644 index 0000000..12e7023 --- /dev/null +++ b/docs/source/reference/systemd.md @@ -0,0 +1,2 @@ +```{include} ../../systemd.md +``` diff --git a/src/pi_pianoteq/client/client_api.py b/src/pi_pianoteq/client/client_api.py index 65d32a0..091659a 100644 --- a/src/pi_pianoteq/client/client_api.py +++ b/src/pi_pianoteq/client/client_api.py @@ -2,58 +2,161 @@ class ClientApi(ABC): + """ + Abstract interface for controlling Pianoteq instruments and presets. + + This is the primary API that clients use to interact with the backend. + All methods are thread-safe when called from a single thread. For + multi-threaded clients, use external locking. + """ + @classmethod def version(cls) -> str: + """ + Get the ClientApi version. + + Returns: + str: API version string (currently "1.0.0") + """ return '1.0.0' @abstractmethod def set_preset_next(self): + """ + Switch to the next preset in the current instrument. + + Wraps around to the first preset when reaching the end. + Sends MIDI Program Change to Pianoteq. + """ raise NotImplemented @abstractmethod def set_preset_prev(self): + """ + Switch to the previous preset in the current instrument. + + Wraps around to the last preset when reaching the start. + Sends MIDI Program Change to Pianoteq. + """ raise NotImplemented @abstractmethod def get_current_preset(self): - """Get the current Preset object.""" + """ + Get the currently loaded preset. + + Returns: + Preset: The current preset object + """ raise NotImplemented @abstractmethod def set_instrument_next(self): + """ + Switch to the next instrument. + + Wraps around to the first instrument when reaching the end. + Also loads the first preset of the new instrument and sends + MIDI Program Change to Pianoteq. + """ raise NotImplemented @abstractmethod def set_instrument_prev(self): + """ + Switch to the previous instrument. + + Wraps around to the last instrument when reaching the start. + Also loads the first preset of the new instrument and sends + MIDI Program Change to Pianoteq. + """ raise NotImplemented @abstractmethod def set_instrument(self, name): + """ + Switch to a specific instrument by name. + + Args: + name (str): The full instrument name (e.g., "D4 Grand Piano") + + Also loads the first preset of the new instrument and sends + MIDI Program Change to Pianoteq. + """ raise NotImplemented @abstractmethod def get_instruments(self) -> list: - """Get list of all Instrument objects.""" + """ + Get list of all available instruments. + + Returns: + list[Instrument]: All instruments discovered during startup + """ raise NotImplemented @abstractmethod def get_current_instrument(self): - """Get the current Instrument object.""" + """ + Get the currently selected instrument. + + Returns: + Instrument: The current instrument object + """ raise NotImplemented @abstractmethod def shutdown_device(self): + """ + Trigger system shutdown. + + Calls registered exit callbacks, then executes the system shutdown + command. This will shut down the entire device (e.g., Raspberry Pi). + + Warning: + Only use this for hardware shutdown buttons. This will power + off the system! + """ raise NotImplemented @abstractmethod def set_on_exit(self, on_exit) -> None: + """ + Register a callback to run during shutdown. + + Args: + on_exit (Callable[[], None]): Function to call before shutdown + + The callback will be invoked when shutdown_device() is called, + before the actual system shutdown command executes. + """ raise NotImplemented @abstractmethod def get_presets(self, instrument_name: str) -> list: - """Get list of Preset objects for a specific instrument.""" + """ + Get list of presets for a specific instrument. + + Args: + instrument_name (str): The full instrument name + + Returns: + list[Preset]: List of preset objects, or empty list if + instrument not found + """ raise NotImplemented @abstractmethod def set_preset(self, instrument_name: str, preset_name: str): + """ + Load a specific preset for a specific instrument. + + If the instrument is not currently selected, switches to it first. + Then loads the preset and sends MIDI Program Change to Pianoteq. + + Args: + instrument_name (str): The full instrument name + preset_name (str): The full preset name (use preset.name, not + preset.display_name) + """ raise NotImplemented diff --git a/src/pi_pianoteq/instrument/instrument.py b/src/pi_pianoteq/instrument/instrument.py index 3bf12be..0c6a5d1 100644 --- a/src/pi_pianoteq/instrument/instrument.py +++ b/src/pi_pianoteq/instrument/instrument.py @@ -4,8 +4,30 @@ class Instrument: + """ + Represents a Pianoteq instrument with its presets and UI colors. + + An instrument is a collection of related presets (e.g., "D4 Grand Piano" + contains presets like "Close Mic", "Classical", etc.). + + Attributes: + name (str): Full display name (e.g., "D4 Grand Piano") + preset_prefix (str): Short prefix for grouping (e.g., "D4") + background_primary (str): Hex color for UI elements (e.g., "#1e3a5f") + background_secondary (str): Hex color for gradients (e.g., "#0f1d2f") + presets (List[Preset]): List of available presets for this instrument + """ def __init__(self, name: str, preset_prefix: str, bg_primary: str, bg_secondary: str): + """ + Create an Instrument instance. + + Args: + name (str): Full display name of the instrument + preset_prefix (str): Short prefix used to group presets + bg_primary (str): Primary background color (hex format) + bg_secondary (str): Secondary background color (hex format) + """ self.name = name self.preset_prefix = preset_prefix self.background_primary = bg_primary @@ -13,4 +35,14 @@ def __init__(self, name: str, preset_prefix: str, bg_primary: str, bg_secondary: self.presets: List[Preset] = [] def add_preset(self, preset: Preset): + """ + Add a preset to this instrument. + + Args: + preset (Preset): The preset to add + + Note: + This is typically called during instrument discovery, not by + client code. + """ self.presets.append(preset)