Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Breaking Changes

- `--cli` flag removed: Use `--client cli` instead
- Old: `pi-pianoteq --cli`
- New: `pi-pianoteq --client cli`
- Update any scripts or systemd service configurations that use `--cli`

### Added

- Client discovery system for flexible client selection
- `--client` flag to specify which client to use (e.g., `--client cli`, `--client gfxhat`, or `--client mypackage:MyClient`)
- `--list-clients` flag to display available built-in clients
- Config-based default client selection via `[Client]` section in `pi_pianoteq.conf`
- Set default client: `CLIENT = cli` or `CLIENT = mypackage:MyClient`
- Support for external custom clients using module:class specification

### Changed

- Client selection now uses discoverable system instead of hardcoded imports
- Logging configuration simplified to use client-provided handlers

## [2.3.0] - 2025-11-21

### Added
Expand Down
49 changes: 47 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,55 @@ You can test commands without deploying:
```bash
pipenv run pi-pianoteq --show-config
pipenv run pi-pianoteq --init-config
pipenv run pi-pianoteq --cli # Run CLI client for local testing
pipenv run pi-pianoteq --client cli # Run CLI client for local testing
```

**Note:** Full testing requires hardware (MIDI, gfxhat) which may not be available on your dev machine. Use `--cli` flag to run the CLI client for testing without the GFX HAT.
**Note:** Full testing requires hardware (MIDI, gfxhat) which may not be available on your dev machine. Use `--client cli` to run the CLI client for testing without the GFX HAT.

## Using Custom Clients

Pi-Pianoteq supports both built-in clients (gfxhat, cli) and external custom clients.

### Command Line Override

Run with a specific client:

```bash
pipenv run pi-pianoteq --client cli
pipenv run pi-pianoteq --client gfxhat
pipenv run pi-pianoteq --client mypackage.myclient:MyClient
```

### Set Default Client

Edit your config file to set a default client:

```bash
pipenv run pi-pianoteq --init-config # If you haven't already
nano ~/.config/pi_pianoteq/pi_pianoteq.conf
```

Add or update the `[Client]` section:

```ini
[Client]
CLIENT = cli
```

Or use an external client:

```ini
[Client]
CLIENT = mypackage.myclient:MyClient
```

### List Available Clients

To see all built-in clients:

```bash
pipenv run pi-pianoteq --list-clients
```

## What deploy.sh Does

Expand Down
62 changes: 62 additions & 0 deletions example_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""
Example minimal external client for Pi-Pianoteq.

This demonstrates the minimum required implementation for a custom client.
You can test it with: pi-pianoteq --client example_client:ExampleClient
"""
from typing import Optional
import logging

from pi_pianoteq.client.client import Client
from pi_pianoteq.client.client_api import ClientApi


class ExampleClient(Client):
"""
Minimal example client that prints to console.

Demonstrates the basic structure for a custom client.
"""

def __init__(self, api: Optional[ClientApi]):
"""Initialize the client in loading mode (api=None)"""
super().__init__(api)
print("ExampleClient initialized")

def set_api(self, api: ClientApi):
"""Called when the API becomes available"""
self.api = api
print(f"ExampleClient: API ready with {len(api.get_instruments())} instruments")

def show_loading_message(self, message: str):
"""Display loading messages during startup"""
print(f"[LOADING] {message}")

def start(self):
"""Main client loop - called after set_api()"""
print("\nExampleClient started!")
print("=" * 60)

# Show current state
instrument = self.api.get_current_instrument()
preset = self.api.get_current_preset()
print(f"Current: {instrument.name} - {preset.name}")

# List available instruments
print(f"\nAvailable instruments ({len(self.api.get_instruments())}):")
for i, instr in enumerate(self.api.get_instruments(), 1):
print(f" {i}. {instr.name}")

print("\nPress Ctrl+C to exit")

# Simple loop - just wait for interrupt
try:
import time
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nExampleClient shutting down...")

def get_logging_handler(self) -> Optional[logging.Handler]:
"""Return None to use default stdout/stderr logging"""
return None
78 changes: 63 additions & 15 deletions src/pi_pianoteq/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,40 @@
logger = logging.getLogger(__name__)


def list_clients():
"""Display available built-in clients"""
from pi_pianoteq.client.discovery import discover_builtin_clients_with_errors, get_client_info

available, unavailable = discover_builtin_clients_with_errors()

print("Built-in clients:")
print()

# Show available clients
if available:
for name, client_class in sorted(available.items()):
info = get_client_info(client_class)
print(f" {name:12} - {info.get('description', 'No description')}")

# Show unavailable clients with reasons
if unavailable:
if available:
print()
for name, error in sorted(unavailable.items()):
print(f" {name:12} - [unavailable: {error}]")

if not available and not unavailable:
print(" No clients found")

print()
print("Usage:")
print(f" pi-pianoteq --client <name>")
print(f" pi-pianoteq --client mypackage.module:ClassName")
print()
print("To set a default client, edit the config file:")
print(f" {USER_CONFIG_PATH}")


def show_config():
"""Display current configuration and sources"""
print("Pi-Pianoteq Configuration")
Expand All @@ -39,6 +73,7 @@ def show_config():
("PIANOTEQ_DIR", Config.PIANOTEQ_DIR),
("PIANOTEQ_BIN", Config.PIANOTEQ_BIN),
("PIANOTEQ_HEADLESS", Config.PIANOTEQ_HEADLESS),
("CLIENT", Config.CLIENT),
("SHUTDOWN_COMMAND", Config.SHUTDOWN_COMMAND),
]

Expand Down Expand Up @@ -77,9 +112,14 @@ def main():
help='Initialize user config file at ~/.config/pi_pianoteq/ and exit'
)
parser.add_argument(
'--cli',
'--client',
type=str,
help='Specify client to use (e.g., "cli", "gfxhat", or "mypackage:MyClient")'
)
parser.add_argument(
'--list-clients',
action='store_true',
help='Use CLI client instead of GFX HAT client (for development/testing)'
help='List available built-in clients and exit'
)
parser.add_argument(
'--include-demo',
Expand All @@ -97,28 +137,36 @@ def main():
if args.init_config:
return init_config()

if args.list_clients:
list_clients()
return 0

# Normal startup - import hardware dependencies only when needed
from pi_pianoteq.instrument.library import Library
from pi_pianoteq.instrument.selector import Selector
from pi_pianoteq.rpc.jsonrpc_client import PianoteqJsonRpc, PianoteqJsonRpcError
from pi_pianoteq.lib.client_lib import ClientLib
from pi_pianoteq.process.pianoteq import Pianoteq

# Import appropriate client based on mode
if args.cli:
from pi_pianoteq.client.cli.cli_client import CliClient
else:
from pi_pianoteq.client.gfxhat.gfxhat_client import GfxhatClient

# Instantiate client early (in loading mode, api=None)
if args.cli:
client = CliClient(api=None)
# Determine which client to use
if args.client:
client_spec = args.client
else:
client = GfxhatClient(api=None)
client_spec = Config.CLIENT

# Load and instantiate client
try:
from pi_pianoteq.client.discovery import load_client
ClientClass = load_client(client_spec)
client = ClientClass(api=None)
except (ImportError, AttributeError, ValueError) as e:
logger.error(f"Failed to load client '{client_spec}': {e}")
print(f"ERROR: Could not load client '{client_spec}'")
print("Use --list-clients to see available built-in clients")
return 1

# Setup logging - use buffered handler for CLI mode
log_buffer = client.log_buffer if args.cli else None
setup_logging(cli_mode=args.cli, log_buffer=log_buffer)
# Setup logging with client's handler
setup_logging(handler=client.get_logging_handler())

pianoteq = Pianoteq()

Expand Down
5 changes: 5 additions & 0 deletions src/pi_pianoteq/client/cli/cli_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import Optional
import threading
import os
import logging

from pi_pianoteq.client.client import Client
from pi_pianoteq.client.client_api import ClientApi
Expand Down Expand Up @@ -485,3 +486,7 @@ def start(self):
# App is already running in background thread, just wait for it
if self.app_thread:
self.app_thread.join()

def get_logging_handler(self) -> Optional[logging.Handler]:
"""Return BufferedLoggingHandler for UI display"""
return self.log_buffer
12 changes: 12 additions & 0 deletions src/pi_pianoteq/client/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from typing import Optional
import logging

from pi_pianoteq.client.client_api import ClientApi

Expand Down Expand Up @@ -41,3 +42,14 @@ def start(self):
Called after set_api().
"""
raise NotImplemented

@abstractmethod
def get_logging_handler(self) -> Optional[logging.Handler]:
"""
Return logging handler to use for this client.

Returns None to use default stdout/stderr handlers.
CLI client returns BufferedLoggingHandler for UI display.
GFX HAT client returns None for default behavior.
"""
raise NotImplemented
Loading