# LovensePy

[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)

Python client for the **Lovense API**. Supports Standard API (LAN & Server), Standard Socket API, and Toy Events API.

## Table of Contents

- [Features](#features)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [API Variants](#api-variants)
  - [API Architecture](#api-architecture)
- [Step-by-Step Tutorials](#step-by-step-tutorials)
  - [LAN Game Mode](#lan-game-mode-tutorial)
  - [Server API + QR Pairing](#server-api--qr-pairing-tutorial)
  - [Socket API](#socket-api-tutorial)
  - [Toy Events](#toy-events-tutorial)
- [API Reference](#api-reference)
  - [LANClient](#lanclient)
  - [ServerClient](#serverclient)
  - [SocketAPIClient](#socketapiclient)
  - [ToyEventsClient](#toyeventsclient)
  - [Pattern Players](#pattern-players)
  - [Utilities](#utilities)
- [Appendix](#appendix)
  - [Actions and Presets](#actions-and-presets)
  - [Toy Events Event Types](#toy-events-event-types)
  - [Lovense Flow Diagrams](#lovense-flow-diagrams)
  - [Architecture](#architecture)
  - [HTTPS Certificate](#https-certificate)
  - [Examples](#examples)
  - [Tests](#tests)
- [Links](#links)
- [License](#license)

---

## Features

- **Standard API LAN (Game Mode)**: GetToys, GetToyName, Function, Stop, Pattern, Preset, Position, PatternV2
- **Standard API Server**: Function, Pattern, Preset via Lovense cloud; `get_qr_code` for QR pairing
- **Standard Socket API**: getToken, getSocketUrl, WebSocket client for QR flow and remote control
- **Toy Events API**: Real-time events (toy-list, button-down, function-strength-changed, etc.)

---

## Prerequisites

Before using LovensePy, ensure you have:

- **Lovense Remote** or **Lovense Connect** app installed on your phone or PC
- **Lovense toy** paired with the app
- **Same Wi-Fi network** as the device (required for LAN/Game Mode)
- **Developer token** (for Server/Socket API) — obtain from [Lovense Developer Dashboard](https://developer.lovense.com)
- **Callback URL** (for Server API QR pairing) — e.g. ngrok tunnel or similar to receive pairing callbacks

---

## Installation

```bash
pip install lovensepy
```

Note: the GitHub repository is named `pylove`, but the published package and import name is `lovensepy`.

Dependencies: `httpx`, `pydantic`, `websockets`

---

## Quick Start

Get your first command running in 4 steps:

**Step 1:** Install the package (see [Installation](#installation)).

**Step 2:** Enable Game Mode in Lovense Remote:
- Open Lovense Remote > Discover > Game Mode > Enable LAN
- Note the **IP address** (e.g. `192.168.1.100`) and **port** (20011 for Remote, 34567 for Connect)

**Step 3:** Create a Python script:

```python
from lovensepy import LANClient, Actions

client = LANClient("MyApp", "192.168.1.100", port=20011)
client.function_request({Actions.VIBRATE: 10}, time=3)
```

**Step 4:** Run the script. Your toy should vibrate at level 10 for 3 seconds.

> **Note:** The `time` parameter is in **seconds**. The device holds the level until the next command or until you call `client.stop()`.

---

## API Variants

| API | Client | Auth | Notes |
|-----|--------|------|-------|
| Standard / local | `LANClient` | Game Mode (IP + port) | Lovense Remote: 20011/30011. Connect: 34567 |
| Standard / server | `ServerClient` | token + uid | uid from QR callback. Use `get_qr_code` for pairing |
| Socket / server | `SocketAPIClient` | getToken, getSocketUrl | QR scan, commands via WebSocket |
| Socket / local | `SocketAPIClient(use_local_commands=True)` | same + LAN | Commands via HTTPS to device |
| Socket / local only | `LANClient` | IP + port only | No token, no WebSocket |
| Events API | `ToyEventsClient` | access (appName) | Port 20010. Lovense Remote only |

**Flow:** Standard local → HTTP/HTTPS to device. Standard server → HTTPS to Lovense cloud. Socket → WebSocket to cloud (or HTTPS to device when `use_local_commands=True`). Events → WebSocket to device.

### API Architecture

```mermaid
flowchart TB
    subgraph YourApp [Your App - lovensepy]
        LANClient
        ServerClient
        SocketAPIClient
        ToyEventsClient
    end

    subgraph Local [Local Network]
        RemoteApp[Lovense Remote App]
        Toy[Lovense Toy]
    end

    subgraph Cloud [Lovense Cloud]
        LovenseServer[Lovense Server]
    end

    LANClient -->|"HTTP/HTTPS"| RemoteApp
    RemoteApp --> Toy

    ServerClient -->|"HTTPS"| LovenseServer
    LovenseServer --> RemoteApp

    SocketAPIClient -->|"WebSocket"| LovenseServer
    SocketAPIClient -->|"HTTPS when use_local_commands"| RemoteApp
    LovenseServer --> RemoteApp

    ToyEventsClient -->|"WebSocket"| RemoteApp
```

---

## Step-by-Step Tutorials

### LAN Game Mode Tutorial

**Step 1:** Enable Game Mode in Lovense Remote
- Open Lovense Remote > Discover > Game Mode > Enable LAN
- Or Lovense Connect > Game Mode > Enable LAN

**Step 2:** Note the IP and port
- Lovense Remote: typically port **20011** (HTTP) or **30011** (HTTPS)
- Lovense Connect: typically port **34567**

**Step 3:** Create the client

```python
from lovensepy import LANClient

client = LANClient("MyApp", "192.168.1.100", port=20011)
```

**Step 4:** Get connected toys

```python
response = client.get_toys()
toys = {toy.id: toy.model_dump() for toy in response.data.toys} if response.data else {}
# toys is {toy_id: toy_info}
```

**Step 5:** Send a Function command (auto-stop)

```python
import time
from lovensepy import Actions

# Vibrate at level 10 for 5 seconds; toy is auto-stopped on context exit.
with client.play({Actions.VIBRATE: 10}, time=5):
    time.sleep(5)
```

**Step 8:** Optional — use Presets or Patterns

```python
from lovensepy import Presets

client.preset_request(Presets.PULSE, time=5)
time.sleep(5)

# Custom pattern: list of strength levels (0-20)
client.pattern_request([5, 10, 15, 20], time=4)
time.sleep(4)

client.stop()
```

**Full example:**

```python
import time
from lovensepy import LANClient, Actions, Presets

client = LANClient("MyApp", "192.168.1.100", port=20011, logging=True)

# Get toys
toys_response = client.get_toys()
toys = {toy.id: toy.model_dump() for toy in toys_response.data.toys} if toys_response.data else {}
print("Toys:", toys)

# Preset
client.preset_request(Presets.PULSE, time=5)
time.sleep(5)

# Function
client.function_request({Actions.ALL: 5}, time=3)
time.sleep(3)

# Pattern
client.pattern_request([5, 10, 15, 20], time=4)
time.sleep(4)

# Stop
client.stop()
```

---

### Server API + QR Pairing Tutorial

**Step 1:** Get your developer token from the [Lovense Developer Dashboard](https://developer.lovense.com).

**Step 2:** Set up a callback URL (e.g. ngrok) and configure it in the Dashboard. Lovense will POST to this URL when a user scans the QR code.

**Step 3:** Call `get_qr_code` to get the QR image URL and 6-character code

```python
from lovensepy import get_qr_code

qr_data = get_qr_code(developer_token="YOUR_TOKEN", uid="user_123")
qr_url = qr_data["qr"]   # Image URL for user to scan
code = qr_data["code"]   # 6-char code for PC Remote
print(f"Scan QR: {qr_url}")
```

**Step 4:** User scans the QR code in Lovense Remote.

**Step 5:** Lovense POSTs to your callback URL with `uid` and `toys`. Your server receives this and stores the uid.

**Step 6:** Create the ServerClient with the uid from the callback

```python
from lovensepy import ServerClient, Actions

client = ServerClient(developer_token="YOUR_TOKEN", uid="user_123")
```

**Step 7:** Send commands (same as LAN)

```python
import time

client.function_request({Actions.VIBRATE: 10}, time=5)
time.sleep(5)
client.stop()
```

---

### Socket API Tutorial

The Socket API is **async only**. Use `asyncio.run()` to run your async code.

**Step 1:** Get an auth token

```python
from lovensepy import get_token

auth_token = get_token(
    developer_token="YOUR_TOKEN",
    uid="user_123",
    uname="User"
)
```

**Step 2:** Get socket URL info. The `platform` must match the **Website Name** from your Lovense Developer Dashboard exactly.

```python
from lovensepy import get_socket_url

socket_info = get_socket_url(auth_token, platform="Your App")
```

**Step 3:** Build the WebSocket URL

```python
from lovensepy import build_websocket_url

ws_url = build_websocket_url(socket_info, auth_token)
```

**Step 4:** Create the client and connect

```python
import asyncio
from lovensepy import SocketAPIClient

async def main():
    client = SocketAPIClient(ws_url, on_event=lambda e, p: print(e, p))
    connect_task = asyncio.create_task(client.connect())
```

**Step 5:** Request QR code (when `on_socket_io_connected` fires)

```python
    client_ref = []

    def on_connected():
        client_ref[0].send_event("basicapi_get_qrcode_ts", {"ackId": "1"})

    client = SocketAPIClient(ws_url, on_socket_io_connected=on_connected, on_event=...)
    client_ref.append(client)
```

**Step 6:** User scans QR. You receive `basicapi_update_device_info_tc` with the toy list in the payload.

**Step 7:** Send commands when `client.is_socket_io_connected` is True

```python
    if client.is_socket_io_connected:
        client.send_command("Function", "Vibrate:10", time_sec=5, toy="toy_id")
```

**Step 8:** Use `send_command_await` for critical stops (awaits delivery)

```python
    await client.send_command_await("Function", "Stop", time_sec=0, toy="toy_id")
```

**By local (same LAN):** Pass `use_local_commands=True` — after QR scan, commands go via HTTPS to the device instead of WebSocket.

---

### Toy Events Tutorial

Toy Events is **Lovense Remote only** (port 20010). Lovense Connect does not support Toy Events.

**Step 1:** Ensure you use Lovense Remote with Game Mode enabled. Port is typically 20010.

**Step 2:** Create the client with an event callback

```python
import asyncio
from lovensepy import ToyEventsClient

def on_event(event_type, payload):
    print(event_type, payload)

client = ToyEventsClient(
    "192.168.1.100",
    port=20010,
    app_name="My App",
    on_event=on_event
)
```

**Step 3:** Connect (async)

```python
async def main():
    await client.connect()

asyncio.run(main())
```

**Step 4:** User grants access when Lovense Remote prompts "Allow [My App] to access?"

**Step 5:** Receive events: `toy-list`, `button-down`, `function-strength-changed`, `shake`, etc.

---

## API Reference

### LANClient

Standard API LAN (Game Mode) client. Sends commands via HTTP/HTTPS to the Lovense app on the same network.

#### Constructor

```python
LANClient(
    app_name: str,
    local_ip: str | None = None,
    *,
    domain: str | None = None,
    port: int = 20010,
    ssl_port: int = 30010,
    use_https: bool = False,
    verify_ssl: bool = True,
    timeout: float = 10.0,
    logging: bool = False,
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `app_name` | str | — | Application name (e.g. "MyApp") |
| `local_ip` | str | None | Device IP (e.g. "192.168.1.100"). Use with `domain=None`. |
| `domain` | str | None | Pre-built domain (e.g. "192-168-1-100.lovense.club"). Use when you have domain from Socket API. |
| `port` | int | 20010 | HTTP port (Lovense Remote: 20011, Connect: 34567) |
| `ssl_port` | int | 30010 | HTTPS port |
| `use_https` | bool | False | Use HTTPS instead of HTTP |
| `verify_ssl` | bool | True | Verify SSL cert. If False, uses fingerprint pinning. |
| `timeout` | float | 10.0 | Request timeout in seconds |
| `logging` | bool | False | Print requests/responses |

**Example:**

```python
client = LANClient("MyApp", "192.168.1.100", port=20011)
```

**Class method:** `LANClient.from_device_info(app_name, domain, https_port=30010, **kwargs)` — Create from Socket API device info (e.g. `basicapi_update_device_info_tc` payload).

#### Methods

| Method | Parameters | Returns | Description |
|--------|------------|---------|-------------|
| `get_toys()` | — | `GetToysResponse` | Get connected toys. Uses a typed `data.toys[]` list. |
| `get_toys_name()` | — | `GetToyNameResponse` | Get connected toy names. |
| `function_request(actions, time=0, loop_on_time=None, loop_off_time=None, toy_id=None, stop_previous=None)` | `actions`: dict like `{Actions.VIBRATE: 10}`; `time`: seconds | `CommandResponse` | Send Function command. `time` in seconds. |
| `stop(toy_id=None)` | `toy_id`: str or list | `CommandResponse` | Stop all motors. |
| `preset_request(name, time=0, toy_id=None)` | `name`: Presets enum or str | `CommandResponse` | Send Preset (pulse, wave, etc.). |
| `pattern_request(pattern, actions=None, interval=100, time=0, toy_id=None)` | `pattern`: list of 0–20; `interval`: ms | `CommandResponse` | Custom pattern. |
| `pattern_request_raw(strength, rule="V:1;F:;S:100#", time=0, toy_id=None)` | Raw rule/strength strings | `CommandResponse` | Advanced pattern. |
| `position_request(value, toy_id=None)` | `value`: 0–100 | `CommandResponse` | Position for Solace Pro. |
| `pattern_v2_setup(actions)` | `actions`: list of `{ts, pos}` | `CommandResponse` | PatternV2 Setup. |
| `pattern_v2_play(toy_id=None, start_time=None, offset_time=None, time_ms=None)` | — | `CommandResponse` | PatternV2 Play. |
| `pattern_v2_init_play(actions, toy_id=None, ...)` | — | `CommandResponse` | PatternV2 Setup + Play. |
| `pattern_v2_stop(toy_id=None)` | — | `CommandResponse` | PatternV2 Stop. |
| `pattern_v2_sync_time()` | — | `CommandResponse` | PatternV2 SyncTime. |
| `send_command(command_data, timeout=None)` | Raw command dict | `dict` | Low-level; returns raw dict. Raises `LovenseError` on failures. |
| `decode_response(response)` | Response dict | str | Human-readable response string. |

**Example:**

```python
import time

with client.play({Actions.VIBRATE: 10}, time=5, toy_id="T123"):
    time.sleep(5)
```

---

### ServerClient

Standard API Server client. Sends commands via Lovense cloud. Requires developer token and uid from QR pairing.

#### Constructor

```python
ServerClient(
    developer_token: str,
    uid: str,
    timeout: float = 10.0,
    logging: bool = False,
)
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `developer_token` | str | From Lovense Developer Dashboard |
| `uid` | str | User ID from QR pairing callback |
| `timeout` | float | Request timeout |
| `logging` | bool | Print requests/responses |

#### Methods

Same command methods as LANClient: `function_request`, `stop`, `pattern_request`, `preset_request`, `send_command`, `decode_response`. Note: Server `pattern_request` uses `(rule, strength, time, toy_id)` — different signature from LAN.

---

### SocketAPIClient

Async WebSocket client for Socket API. Commands via WebSocket (or LAN HTTPS when `use_local_commands=True`).

#### Constructor

```python
SocketAPIClient(
    ws_url: str,
    *,
    use_local_commands: bool = False,
    app_name: str = "lovensepy",
    raise_on_disconnect: bool = False,
    on_socket_open: Callable | None = None,
    on_socket_close: Callable | None = None,
    on_socket_error: Callable[[Exception], ...] | None = None,
    on_socket_io_connected: Callable | None = None,
    on_event: Callable[[str, Any], ...] | None = None,
)
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `ws_url` | str | WebSocket URL from `build_websocket_url` |
| `use_local_commands` | bool | Send commands via LAN HTTPS when device on same network |
| `app_name` | str | App name for local commands |
| `raise_on_disconnect` | bool | Raise `ConnectionError` when sending while disconnected |
| `on_socket_open`, `on_socket_close`, `on_socket_error` | Callable | Connection lifecycle callbacks |
| `on_socket_io_connected` | Callable | Fired when Socket.IO handshake complete |
| `on_event` | Callable | Fired for each Socket.IO event `(event_name, payload)` |

#### Methods

| Method | Description |
|--------|-------------|
| `connect()` | Async. Connect and run until disconnected. |
| `disconnect()` | Close connection. |
| `send_command(command, action, time_sec=0, toy=None, ...)` | Send command (non-blocking). |
| `send_command_await(command, action, ...)` | Send command and await delivery. Use for stops. |
| `send_event(event, payload=None)` | Send raw Socket.IO event. |

#### Properties

| Property | Type | Description |
|----------|------|-------------|
| `is_socket_io_connected` | bool | True when Socket.IO handshake done and ready for commands |
| `is_using_local_commands` | bool | True when commands go via LAN HTTPS |

---

### ToyEventsClient

Async WebSocket client for Toy Events API. Receives real-time events from toys. Lovense Remote only, port 20010.

#### Constructor

```python
ToyEventsClient(
    ip: str,
    port: int = 20010,
    use_https: bool = False,
    https_port: int = 30010,
    app_name: str = "lovensepy",
    *,
    on_open: Callable | None = None,
    on_close: Callable | None = None,
    on_error: Callable[[Exception], ...] | None = None,
    on_event: Callable[[str, Any], ...] | None = None,
)
```

#### Methods and Properties

| Method/Property | Description |
|-----------------|-------------|
| `connect()` | Async. Connect, request access, receive events until disconnected. |
| `disconnect()` | Close connection. |
| `is_connected` | True if WebSocket connected. |
| `is_access_granted` | True when user granted access in Lovense Remote. |

---

### Pattern Players

High-level API for sine waves and combo patterns.

#### SyncPatternPlayer

For use with `LANClient`. Synchronous.

```python
SyncPatternPlayer(client: LANClient, toys: dict[str, dict] | GetToysResponse)
```

| Method | Parameters | Description |
|--------|------------|-------------|
| `play_sine_wave(toy_id, feature, duration_sec=5, num_steps=100, stop_prev_first=True)` | `feature`: e.g. "Vibrate1" | Play sine wave on one feature. |
| `play_combo(targets, duration_sec=4, num_steps=100)` | `targets`: `[(toy_id, feature), ...]` | Play combo with random phases. |
| `stop(toy_id)` | — | Stop toy. |
| `features(toy_id)` | — | Get features for toy. |

**Example:**

```python
player = SyncPatternPlayer(client, toys)
player.play_sine_wave("T123", "Vibrate1", duration_sec=5)
player.play_combo([("T1", "Vibrate1"), ("T2", "Vibrate")], duration_sec=4)
player.stop("T123")
```

#### AsyncPatternPlayer

For use with `SocketAPIClient`. Same methods, async (use `await`).

```python
player = AsyncPatternPlayer(client, toys)
await player.play_sine_wave("T123", "Vibrate1", duration_sec=5)
await player.stop("T123")
```

---

### Utilities

| Function | Parameters | Returns | Description |
|----------|------------|---------|-------------|
| `get_token(developer_token, uid, uname=None, utoken=None, timeout=10)` | — | str | Get auth token for Socket API. Raises on error. |
| `get_socket_url(auth_token, platform, timeout=10)` | `platform`: Website Name from Dashboard | dict | Get socket info dict. |
| `build_websocket_url(socket_info, auth_token)` | — | str | Build full wss:// URL. |
| `get_qr_code(developer_token, uid, uname=None, utoken=None, timeout=10)` | — | dict | Get QR for Server API. Returns `{qr, code}`. See security note in docstring. |
| `features_for_toy(toy)` | `toy`: dict from GetToys | list[str] | Get features (e.g. `["Vibrate1", "Rotate"]`). |
| `stop_actions(toy)` | `toy`: dict | dict | Build `{Vibrate1: 0, ...}` to stop. |

---

## Appendix

### Actions and Presets

#### Actions (function types)

| Action | Range | Toys |
|--------|-------|------|
| `Actions.VIBRATE` | 0–20 | Most |
| `Actions.VIBRATE1`, `VIBRATE2`, `VIBRATE3` | 0–20 | Edge, Diamo, multi-motor |
| `Actions.ROTATE` | 0–20 | Nora, Max, etc. |
| `Actions.PUMP` | 0–3 | Max 2 |
| `Actions.THRUSTING` | 0–20 | Solace, Mission |
| `Actions.FINGERING` | 0–20 | Solace |
| `Actions.SUCTION` | 0–20 | Max 2 |
| `Actions.DEPTH` | 0–3 | Solace Pro |
| `Actions.STROKE` | 0–100 | Solace Pro |
| `Actions.OSCILLATE` | 0–20 | Some toys |
| `Actions.ALL` | 0–20 | All motors at once |
| `Actions.STOP` | — | Stop |

**Usage:**

```python
client.function_request({Actions.VIBRATE: 10}, time=5)
client.function_request({Actions.VIBRATE1: 5, Actions.VIBRATE2: 10}, time=3)
```

#### Presets (built-in patterns)

| Preset | Description |
|--------|-------------|
| `Presets.PULSE` | Pulse pattern |
| `Presets.WAVE` | Wave pattern |
| `Presets.FIREWORKS` | Fireworks pattern |
| `Presets.EARTHQUAKE` | Earthquake pattern |

**Usage:**

```python
client.preset_request(Presets.PULSE, time=5)
```

---

### Toy Events Event Types

| Event | When |
|-------|------|
| `toy-list` | Toys added/removed/enabled |
| `toy-status` | Toy connected/disconnected |
| `button-down`, `button-up`, `button-pressed` | User pressed toy button |
| `function-strength-changed` | User changed level in app |
| `shake`, `shake-frequency-changed` | Shake sensor |
| `battery-changed`, `depth-changed`, `motion-changed` | Sensor updates |
| `event-closed` | Game mode disabled |
| `access-granted` | User granted access (internal) |
| `pong` | Ping response (internal) |

---

### Lovense Flow Diagrams

The following sequence diagrams illustrate the flows described in the Lovense developer documentation.

**Server API — QR pairing flow:**

```mermaid
sequenceDiagram
    participant User as Your User
    participant App as Your App
    participant Server as Your Server
    participant Lovense as Lovense Server
    participant Remote as Lovense Remote App
    participant Toy as Lovense Toy

    User->>Remote: Open Lovense Remote
    User->>Toy: Turn on the toy
    User->>App: User logs in to your app
    App->>Server: Request to bind with Lovense Toy
    Server->>Lovense: Request QR code from Lovense
    Lovense-->>Server: Return a QR code URL
    Server->>App: Display the QR code
    User->>Remote: User scans the QR code with Lovense Remote App
    Remote->>Server: Lovense Remote app POSTs to your server
    App->>Remote: Control the toy by instructing the App
    Remote->>Toy: Trigger vibration
```

**Socket API — authorization and connection flow:**

```mermaid
sequenceDiagram
    participant User as User (Lovense App)
    participant Interface as Developer Interface
    participant DevServer as Developer Server
    participant Lovense as Lovense Server

    Interface->>Lovense: 1) Application for authorization token
    Lovense-->>Interface: Response authorization token
    Interface->>DevServer: Forward authorization token
    DevServer->>Lovense: 2) Validate authorization token
    Lovense-->>DevServer: Verification success, response socket connection info
    DevServer->>Lovense: Establishing socket connection
    DevServer->>Lovense: Get QR code information by socket
    User->>Interface: Start Lovense App, connect toys and scan the QR code
    Lovense->>User: 3) Report device information periodically
    Note over Lovense,User: Device info contains toy list, domain and port of local HTTP service
    Lovense->>DevServer: Synchronizing device information
    Interface->>User: Show toys and send command
```

---

### Architecture

- **Clients**: LANClient, ServerClient, SocketAPIClient, ToyEventsClient — command building, protocols
- **Transport**: HttpTransport (POST JSON), WsTransport (WebSocket)
- **Security**: Certificate fingerprint verification for HTTPS (port 30010/30011) when `verify_ssl=False`

---

### HTTPS Certificate

For local HTTPS (ports 30010/30011), lovensepy verifies the Lovense certificate fingerprint instead of disabling SSL. Fingerprint in `lovensepy.security.LOVENSE_HTTPS_FINGERPRINT`.

---

### Examples

| File | Description |
|------|-------------|
| `examples/lan_game_mode.py` | LAN Game Mode — get toys, presets, functions, patterns |
| `examples/patterns_demo.py` | Sine waves and combos with SyncPatternPlayer |
| `examples/server_api.py` | Server API with token and uid |
| `examples/socket_api_full.py` | Socket API with QR flow and command sending |
| `examples/toy_events_full.py` | Toy Events — receive real-time events |

Run with env vars, e.g. `LOVENSE_LAN_IP=192.168.1.100 python examples/lan_game_mode.py`

---

### Tests

#### Install

```bash
pip install -e ".[dev]"
```

#### Unit tests (no devices)

```bash
pytest tests/test_unit.py -v
```

#### Integration tests

Integration tests require Lovense hardware and/or a developer token. Set environment variables for the test mode you use, then run the corresponding test file.

**Test modes and required env vars:**

| Test file | Mode | Required env vars |
|-----------|------|-------------------|
| `test_standard_local.py` | Standard / local | `LOVENSE_LAN_IP`, `LOVENSE_LAN_PORT` (20011 Remote, 34567 Connect) |
| `test_standard_server.py` | Standard / server | `LOVENSE_DEV_TOKEN`, `LOVENSE_UID` — or `LOVENSE_QR_PAIRING=1` + ngrok |
| `test_socket_server.py` | Socket / server | `LOVENSE_DEV_TOKEN`, `LOVENSE_UID`, `LOVENSE_PLATFORM` |
| `test_socket_server.py::test_by_local` | Socket / local | Same as server + device on same LAN |
| `test_socket_local.py` | Socket / local only | `LOVENSE_LAN_IP`, `LOVENSE_LAN_PORT` |
| `test_toy_events.py` | Toy Events | `LOVENSE_LAN_IP`, `LOVENSE_TOY_EVENTS_PORT` (20010) |

**Example env setup:**

```bash
export LOVENSE_LAN_IP=192.168.1.100
export LOVENSE_LAN_PORT=34567          # Lovense Connect
export LOVENSE_DEV_TOKEN=your_token
export LOVENSE_UID=your_uid
export LOVENSE_PLATFORM="Your App"
export LOVENSE_TOY_EVENTS_PORT=20010   # Toy Events (Lovense Remote only)
export LOVENSE_QR_PAIRING=1
export LOVENSE_CALLBACK_PORT=8765      # ngrok or cloudflared
```

**Run integration tests:**

```bash
pytest tests/test_standard_local.py -v -s
pytest tests/test_standard_server.py -v -s
pytest tests/test_socket_server.py -v -s
pytest tests/test_socket_local.py -v -s
pytest tests/test_toy_events.py -v -s
```

---

## Links

- [Lovense Standard API](https://developer.lovense.com/docs/standard-solutions/standard-api.html)
- [Lovense Socket API](https://developer.lovense.com/docs/standard-solutions/socket-api.html)
- [Toy Events API](https://developer.lovense.com/docs/standard-solutions/toy-events-api.html)

---

## License

**Apache License 2.0** — see [LICENSE](LICENSE) for full text.
