> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Python device client

> The lua-device-client package on PyPI: DeviceClient, DeviceCommandDefinition, events, triggers, and CDN uploads over MQTT with asyncio

`lua-device-client` connects a Python 3.8+ process to an agent as a [device](/concepts/devices) over MQTT, runs async command handlers, sends triggers, and uploads files. Version 1.3.0 on PyPI; it depends on `paho-mqtt` 2.0 or later and `httpx`.

*Verified against lua-device-client 1.3.0.*

```bash theme={null}
pip install lua-device-client
```

```python theme={null}
from lua_device import DeviceClient, DeviceCommandDefinition
```

## Quick example

```python device.py theme={null}
import asyncio
import os

from lua_device import DeviceClient, DeviceCommandDefinition

client = DeviceClient(
    agent_id=os.environ["LUA_AGENT_ID"],
    api_key=os.environ["LUA_DEVICE_CREDENTIAL"],
    device_name="bench-sensor",
    commands=[
        DeviceCommandDefinition(
            name="read_temperature",
            description="Read the bench temperature in celsius",
        ),
    ],
)


async def read_temperature(payload):
    return {"temperature": 22.5}


client.on_command("read_temperature", read_temperature)


async def main():
    await client.connect()
    ack = await client.trigger("sensor_ready", {"firmware": "1.4.2"})
    print("queued as", ack.trigger_id)
    await asyncio.Event().wait()  # keep the loop alive to receive commands


asyncio.run(main())
```

## DeviceClient

`DeviceClient(config=None, **kwargs)` accepts a `DeviceClientConfig` or the same fields as keyword arguments. Construction doesn't connect.

<ParamField path="agent_id" type="str" required>
  The agent the device connects to. Must equal the agent in the credential's binding.
</ParamField>

<ParamField path="api_key" type="str" required>
  The credential: a [device credential](/devices/credentials) or a legacy API key. 1.3.0 sends it as the MQTT password and repeats it in the non-retained status message, where the platform validates it again; a device credential's operations are enforced by the broker.
</ParamField>

<ParamField path="device_name" type="str" required>
  The device name. Must equal the name in the credential's binding.
</ParamField>

<ParamField path="commands" type="list[DeviceCommandDefinition]" default="[]">
  Commands sent at connect time; each becomes a tool.
</ParamField>

<ParamField path="mqtt_url" type="str" default="wss://mqtt.heylua.ai/mqtt">
  Broker URL. `ws`/`wss` use the WebSocket transport (path from the URL, `/mqtt` by default); `mqtts` and `ssl` use TLS over TCP. The port defaults to 443 for `wss`, 8083 for `ws`, 8883 for `mqtts`, and 1883 otherwise.
</ParamField>

<ParamField path="cdn_url" type="str" default="https://cdn.heylua.ai">
  Base URL for `cdn`.
</ParamField>

<ParamField path="group" type="str">
  Group name reported at connect.
</ParamField>

**`DeviceCommandDefinition`**

<ParamField path="name" type="str" required>Command name; must match `^[a-z][a-z0-9_]{0,63}$` or the platform drops it.</ParamField>
<ParamField path="description" type="str" required>The tool description the model reads.</ParamField>
<ParamField path="input_schema" type="dict">JSON Schema for the payload; sent as `inputSchema`. At most 4 KB serialized.</ParamField>
<ParamField path="timeout_ms" type="int" default={30000}>How long the platform waits for the response. Sent only when changed.</ParamField>
<ParamField path="retry" type="dict">`{"max_attempts": int, "backoff_ms": int}`; missing keys default to 3 and 1000. Sent as `retry.maxAttempts` and `retry.backoffMs`.</ParamField>

## Methods

### connect()

Connects, subscribes, publishes the status and command list, and starts the heartbeat. paho-mqtt's network loop runs in a background thread; handlers run on the asyncio loop that called `connect()`.

```python theme={null}
await client.connect() -> None
```

**Errors** — `ConnectionError("MQTT connect failed: rc=<code>")` when the broker refuses the connection, for example a rejected credential.

### on\_command()

Registers the async handler for one command. There is no decorator form.

```python theme={null}
client.on_command(name: str, handler: Callable[[Any], Awaitable[Any]]) -> None
```

**Returns** — nothing. The handler's return value is sent as `data`; an exception is sent as `success: false` with `str(exc)`; an unregistered command answers `Unknown command: <name>`.

### trigger()

Publishes a trigger and waits for the acknowledgment. Concurrent calls are serialized.

```python theme={null}
await client.trigger(name: str, payload: Any = None) -> TriggerAckMessage
```

**Returns** — `TriggerAckMessage(trigger_id, received, error)`.

**Errors** — `RuntimeError("Not connected to MQTT broker")` before `connect()`; `TimeoutError("Trigger '<name>' ACK timeout (10s)")` when no acknowledgment arrives within 10 seconds. The platform publishes a rejected trigger on `trigger_error`, which 1.3.0 doesn't subscribe to, so the call times out instead.

### on\_trigger\_result()

Registers a handler for a `trigger_result` message. The platform doesn't send that message, so the handler never runs.

```python theme={null}
client.on_trigger_result(name: str, handler: Callable[[Any], None]) -> None
```

### on()

Registers a plain function for a connection event.

```python theme={null}
client.on(event: str, handler: Callable) -> None
```

| Event          | Arguments                        | When                                    |
| -------------- | -------------------------------- | --------------------------------------- |
| `connected`    | —                                | The first successful connection         |
| `reconnected`  | —                                | Every later successful connection       |
| `disconnected` | `"connection closed"`            | The connection dropped; paho reconnects |
| `error`        | `dict` with `code` and `message` | The platform published an error         |
| `trigger_ack`  | `TriggerAckMessage`              | A trigger was acknowledged              |

### disconnect()

Publishes a retained `offline` status, stops the heartbeat, and closes the connection without reconnecting.

```python theme={null}
await client.disconnect() -> None
```

### is\_connected()

```python theme={null}
client.is_connected() -> bool
```

## Connection behavior

* Client ID `lua-<agent_id>-<device_name>`, username `<agent_id>:<device_name>`, MQTT 3.1.1, keep-alive 60 seconds, persistent session, retained last-will `offline` status.
* TLS verifies the broker certificate (`CERT_REQUIRED`).
* Heartbeat every 30 seconds.
* Reconnection is paho-mqtt's: a delay that doubles from 1 second to 120 seconds. The status message and command list are published again on every reconnect.
* Command IDs are cached for 5 minutes (1,000 entries); a redelivered command gets the cached response.

## CDN

`client.cdn` is a `CDN` instance authenticated with the credential, which must grant `assets.upload`.

### upload()

```python theme={null}
await client.cdn.upload(data: bytes, filename: str, content_type: str = "application/octet-stream") -> CdnUploadResult
```

**Returns** — `CdnUploadResult(file_id, media_type, extension, url)`; `url` is `<cdn_url>/<file_id>`. Files are limited to 100 MB.

**Errors** — `RuntimeError` with the platform's message, or `CDN upload failed: <status>`.

### download() and get\_url()

```python theme={null}
await client.cdn.download(file_id: str) -> bytes
client.cdn.get_url(file_id: str) -> str
```

## Types

Exported from `lua_device`.

<ResponseField name="DeviceClientConfig" type="dataclass">`agent_id`, `api_key`, `device_name`, `commands`, `mqtt_url`, `cdn_url`, `group`.</ResponseField>
<ResponseField name="DeviceCommandDefinition" type="dataclass">`name`, `description`, `input_schema`, `timeout_ms`, `retry`; `to_dict()` gives the wire form.</ResponseField>
<ResponseField name="CommandMessage" type="dataclass">`command_id`, `command`, `payload`, `timeout`.</ResponseField>
<ResponseField name="ResponseMessage" type="dataclass">`command_id`, `success`, `data`, `error`.</ResponseField>
<ResponseField name="TriggerAckMessage" type="dataclass">`trigger_id`, `received`, `error`.</ResponseField>
<ResponseField name="CDN" type="class">Standalone `CDN(api_key, cdn_url=None)` with the methods above.</ResponseField>

## See also

* [MicroPython device client](/devices/micropython-client) — the single-file client shipped inside this package
* [MQTT protocol](/devices/mqtt-protocol) — topics and payloads
* [Node device client](/devices/node-client)
* [Reliability and limits](/devices/reliability-and-limits)
