# puda-drivers

Hardware drivers for the PUDA (Physical Unified Device Architecture) platform. This package provides Python interfaces for controlling laboratory automation equipment.

## Features

- **Gantry Control**: Control G-code compatible motion systems (e.g., QuBot)
- **Liquid Handling**: Interface with Sartorius rLINE® pipettes and dispensers
- **Serial Communication**: Robust serial port management with automatic reconnection
- **Logging**: Configurable logging with optional file output to logs folder
- **Cross-platform**: Works on Linux, macOS, and Windows

## Installation

### From PyPI

```bash
pip install puda-drivers
```

### From Source

```bash
git clone https://github.com/zhao-bears/puda-drivers.git
cd puda-drivers
pip install -e .
```

## Quick Start

### Logging Configuration

Configure logging for your application with optional file output:

```python
import logging
from puda_drivers.core.logging import setup_logging

# Configure logging with file output enabled
setup_logging(
    enable_file_logging=True,
    log_level=logging.DEBUG,
    logs_folder="logs", # Optional: default to logs
    log_file_name="my_experiment"  # Optional: custom log file name
)

# Or disable file logging (console only)
setup_logging(
    enable_file_logging=False,
    log_level=logging.INFO
)
```

**Logging Options:**
- `enable_file_logging`: If `True`, logs are written to files in the `logs/` folder. If `False`, logs only go to console (default: `False`)
- `log_level`: Logging level constant (e.g., `logging.DEBUG`, `logging.INFO`, `logging.WARNING`, `logging.ERROR`, `logging.CRITICAL`) (default: `logging.DEBUG`)
- `logs_folder`: Name of the folder to store log files (default: `"logs"`)
- `log_file_name`: Custom name for the log file. If `None` or empty, uses timestamp-based name (e.g., `log_20250101_120000.log`). If provided without `.log` extension, it will be added automatically.

When file logging is enabled, logs are saved to timestamped files (unless a custom name is provided) in the `logs/` folder. The logs folder is created automatically if it doesn't exist.

### Gantry Control (GCode)

```python
from puda_drivers.move import GCodeController

# Initialize and connect to a G-code device
gantry = GCodeController(port_name="/dev/ttyACM0", feed=3000)
gantry.connect()

# Configure axis limits for safety (recommended)
gantry.set_axis_limits("X", 0, 200)
gantry.set_axis_limits("Y", -200, 0)
gantry.set_axis_limits("Z", -100, 0)
gantry.set_axis_limits("A", -180, 180)

# Home the gantry
gantry.home()

# Move to absolute position (validated against limits)
gantry.move_absolute(x=50.0, y=-100.0, z=-10.0)

# Move relative to current position (validated after conversion to absolute)
gantry.move_relative(x=20.0, y=-10.0)

# Query current position
position = gantry.query_position()
print(f"Current position: {position}")

# Disconnect when done
gantry.disconnect()
```

**Axis Limits and Validation**: The `move_absolute()` and `move_relative()` methods automatically validate that target positions are within configured axis limits. If a position is outside the limits, a `ValueError` is raised before any movement is executed. Use `set_axis_limits()` to configure limits for each axis.

### Liquid Handling (Sartorius)

```python
from puda_drivers.transfer.liquid.sartorius import SartoriusController

# Initialize and connect to pipette
pipette = SartoriusController(port_name="/dev/ttyUSB0")
pipette.connect()
pipette.initialize()

# Attach tip
pipette.attach_tip()

# Aspirate liquid
pipette.aspirate(amount=50.0)  # 50 µL

# Dispense liquid
pipette.dispense(amount=50.0)

# Eject tip
pipette.eject_tip()

# Disconnect when done
pipette.disconnect()
```

### Combined Workflow

```python
from puda_drivers.move import GCodeController
from puda_drivers.transfer.liquid.sartorius import SartoriusController

# Initialize both devices
gantry = GCodeController(port_name="/dev/ttyACM0")
pipette = SartoriusController(port_name="/dev/ttyUSB0")

gantry.connect()
pipette.connect()

# Move to source well
gantry.move_absolute(x=50.0, y=-50.0, z=-20.0)
pipette.aspirate(amount=50.0)

# Move to destination well
gantry.move_absolute(x=150.0, y=-150.0, z=-20.0)
pipette.dispense(amount=50.0)

# Cleanup
pipette.eject_tip()
gantry.disconnect()
pipette.disconnect()
```

## Device Support

### Motion Systems

- **QuBot** (GCode) - Multi-axis gantry systems compatible with G-code commands
  - Supports X, Y, Z, and A axes
  - Configurable feed rates
  - Position synchronization and homing
  - Automatic axis limit validation for safe operation

### Liquid Handling

- **Sartorius rLINE®** - Electronic pipettes and robotic dispensers
  - Aspirate and dispense operations
  - Tip attachment and ejection
  - Configurable speeds and volumes

## Error Handling

### Axis Limit Validation

Both `move_absolute()` and `move_relative()` validate positions against configured axis limits before executing any movement. If a position is outside the limits, a `ValueError` is raised:

```python
from puda_drivers.move import GCodeController

gantry = GCodeController(port_name="/dev/ttyACM0")
gantry.connect()

# Set axis limits
gantry.set_axis_limits("X", 0, 200)
gantry.set_axis_limits("Y", -200, 0)

try:
    # This will raise ValueError: Value 250 outside axis limits [0, 200]
    gantry.move_absolute(x=250.0, y=-50.0)
except ValueError as e:
    print(f"Move rejected: {e}")

# Relative moves are also validated after conversion to absolute positions
try:
    # If current X is 150, moving 100 more would exceed the limit
    gantry.move_relative(x=100.0)
except ValueError as e:
    print(f"Move rejected: {e}")
```

Validation errors are automatically logged at the ERROR level before the exception is raised.

### Logging Best Practices

For production applications, configure logging at the start of your script:

```python
import logging
from puda_drivers.core.logging import setup_logging
from puda_drivers.move import GCodeController

# Configure logging first, before initializing devices
setup_logging(
    enable_file_logging=True,
    log_level=logging.INFO,
    log_file_name="gantry_operation"
)

# Now all device operations will be logged
gantry = GCodeController(port_name="/dev/ttyACM0")
# ... rest of your code
```

This ensures all device communication, movements, and errors are captured in log files for debugging and audit purposes.

## Finding Serial Ports

To discover available serial ports on your system:

```python
from puda_drivers.core import list_serial_ports

# List all available ports
ports = list_serial_ports()
for port, desc, hwid in ports:
    print(f"{port}: {desc} [{hwid}]")

# Filter ports by description
sartorius_ports = list_serial_ports(filter_desc="Sartorius")
```

## Requirements

- Python >= 3.14
- pyserial >= 3.5
- See `pyproject.toml` for full dependency list

## Development

### Setup Development Environment

```bash
# Create virtual environment
uv venv

# Activate virtual environment
source .venv/bin/activate  # On Windows: .venv\Scripts\activate

# Install dependencies
uv sync

# Install package in editable mode
pip install -e .
```

### Building and Publishing

```bash
# Build distribution packages
uv build

# Publish to PyPI
uv publish
# Username: __token__
# Password: <your PyPI API token>
```

### Version Management

```bash
# Set version explicitly
uv version 0.0.1

# Bump version (e.g., 1.2.3 -> 1.3.0)
uv bump minor
```

## Documentation

- [PyPI Package](https://pypi.org/project/puda-drivers/)
- [GitHub Repository](https://github.com/zhao-bears/puda-drivers)
- [Issue Tracker](https://github.com/zhao-bears/puda-drivers/issues)

## License

MIT License - see LICENSE file for details.

## Contributing

Contributions are welcome! Please open an issue or submit a pull request on GitHub.
