Skip to main content

unfurled

An async Python library for controlling Unfolded Circle Remote Two and Remote 3 devices.


Features

  • Async-first design built on aiohttp and websockets
  • Full REST API coverage via CoreAPI
  • Real-time WebSocket event stream with auto-reconnect
  • High-level Remote class for common operations (activities, IR, media, docks)
  • Dock class for IR learning, firmware updates, and codeset management
  • mDNS discovery via zeroconf
  • Clean exception hierarchy
  • Typed with mypy

Installation

# Using uv (recommended)
uv add unfurled

# Or pip
pip install unfurled

To install for development:

git clone https://github.com/you/unfurled
cd unfurled
uv venv --python 3.11
uv pip install -e ".[dev]"

Quick Start

import asyncio
from unfurled.remote import Remote


async def main():
    remote = Remote("http://192.168.1.100:80", api_key="your-api-key")
    await remote.init()

    print(remote.name)  # "My Remote Two" (or auto-derived)
    print(remote.info.model_name)  # "Remote Two"
    print(remote.info.hw_revision)  # "Revision 2"
    print(remote.sw_version)  # "2.1.0"

    # List activities
    for act in remote.activities:
        print(act.name, "-", "ON" if act.is_on else "off")

    # Turn on an activity
    await remote.find_activity("my-activity-id").turn_on()


asyncio.run(main())

Discovery

Find remotes on the local network via mDNS:

from unfurled.discovery import discover_remotes


async def main():
    devices = await discover_remotes(timeout=5)
    for d in devices:
        print(d.hostname, d.address, d.port)

Remote

Construction

# API key (preferred)
remote = Remote("http://192.168.1.100:80", api_key="your-api-key")

# PIN
remote = Remote("http://192.168.1.100:80", pin="1234")

Initialisation

await remote.init() fetches the full device state in one call: configuration, activities, entities, docks, IR emitters, update info.

Key Properties

Property Description
name Device display name (falls back to model name)
info.model_name Marketing model name (e.g. "Remote Two")
info.hw_revision Human-readable hardware revision (e.g. "Revision 2")
info.serial_number Device serial number
sw_version Currently running firmware
latest_sw_version Latest available firmware
available_update True when an update is ready
settings.network.wifi_enabled Wi-Fi radio state
settings.network.bt_enabled Bluetooth radio state
settings.display.brightness Display brightness (0-100)
settings.power_saving.standby_sec Display sleep timeout (seconds)
activities list[Activity]
docks list[Dock]

Activities

# List
for act in remote.activities:
    print(act.name, act.state)

# Find and control
act = remote.find_activity("activity-id")
await act.turn_on()
await act.turn_off()

# Correct a drifted runtime state without running an activity sequence
if remote.system.flags.entity_state_update_available:
    await act.set_state("ON")

# All off
await remote.turn_off_all_activities()

Settings

Configuration is grouped under remote.settings:

# Adjust display brightness
await remote.settings.update_display(brightness=80)

# Enable Wi-Fi wake-on-LAN
await remote.settings.update_network(wake_on_wlan=True)

# Change sound volume
await remote.settings.update_sound(volume=60)

IR

# Send a raw HEX or PRONTO code
await remote.ir.send(
    "0000 006C ...",
    emitter_name="Dock IR",  # or emitter_id="device-id"
    repeat=1,
)

# Send a command from a remote's assigned codeset
await remote.ir.send("VOLUME_UP", remote_name="Sony")

# Or resolve a manufacturer codeset
await remote.ir.send("VOLUME_UP", manufacturer="Samsung", codeset="Generic TV 1")

# List available emitters
for e in remote.ir.emitters:
    print(e.name, e.device_id)

# Convert a PRONTO or HEX code to raw timings for inspection or reuse
from unfurled import IRFormat

converted = await remote.ir.convert("0000 006C ...", format=IRFormat.PRONTO, repeat=1)
print(converted["raw"], converted["frequency"])

Integrations / External Systems

# Find a specific integration driver instance
instance = await remote.integrations.get_by_driver("hass")

# Set an API token for an external system (e.g. Home Assistant)
await remote.auth.set_external_token(
    system="hass",
    token_id="primary",
    token="long-lived-token",
    name="Home Assistant",
)

Authentication / API Keys

# Generate an admin API key and make it the active key for this Remote.
# A same-named unfurled key is safely replaced.
api_key = await remote.auth.generate_key()
print(api_key)

# Create another key without replacing an existing one
key = await remote.auth.create_key("Home Assistant")

# Revoke a key by name
await remote.auth.revoke_key("Home Assistant")

Firmware Updates

# Force an update check
result = await remote.api.post_force_update_check()

# Current status
print(remote.update_info.in_progress, remote.update_info.update_percent)

WebSocket Events

from unfurled.websocket import RemoteWebSocketClient


async def on_message(msg: str):
    print("WS event:", msg)


ws = RemoteWebSocketClient(api_url, api_key)
ws.on_message(on_message)
await ws.connect()

Or use the built-in client on Remote:

remote.add_listener(my_callback)  # raw WS message handler
await remote.connect_websocket()

Dock

dock = remote.docks[0]

# Refresh state
await dock.update()

# IR learning
result = await dock.start_ir_learning()
await dock.stop_ir_learning()

# Firmware update
info = await dock.get_update_status()
if info.get("update_available"):
    await dock.update_firmware()

# Custom codesets
codesets = await dock.get_custom_codesets()
await dock.delete_custom_codeset("my-codeset-id")

Exceptions

Exception When raised
AuthenticationError Wrong API key / PIN
HTTPError Non-2xx response
RemoteIsSleeping Device is asleep; wake it first
ExternalSystemNotSupported Unknown external system ID

Interactive Tester

A built-in REPL tester is included:

uv run main.py

It discovers remotes on the local network, prompts for credentials, then offers a numbered menu to inspect state and send commands.


Development

# Run tests
uv run pytest

# Type checking
uv run mypy unfurled/

# Lint / format
uv run ruff check unfurled/
uv run ruff format unfurled/

Licence

MIT

Metadata

Release files for unfurled 0.6.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for unfurled 0.6.2
File Size Uploaded
unfurled-0.6.2.tar.gz 184.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for unfurled 0.6.2
File Interpreter ABI Platform
unfurled-0.6.2-py3-none-any.whl Python 3 none any Details

Total release size: 253.8 kB

Release files / unfurled-0.6.2.tar.gz

Download URL unfurled-0.6.2.tar.gz
Size 184.8 kB
Tags Source
SHA-256 checksum
How to use checksums
c9899f7431c26bc9a5718860bff1a1385761106a0d97d9daf6171cb363eebe78
BLAKE2b-256 checksum
How to use checksums
b787d944e38019c447217d49085d0c6d5760703bd471804986d35767dcb33a2d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.

Transparency log

Release files / unfurled-0.6.2-py3-none-any.whl

Download URL unfurled-0.6.2-py3-none-any.whl
Size 69.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5c69c9f7d3147661f50d4aeb342525490a08d0b6023839f861b7d4538c70c3b2
BLAKE2b-256 checksum
How to use checksums
a9be1f29bbd4d1e4bdee2c78534ccbed59c7ee568ae3846cd97b8dd7a0884ed8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.

Transparency log

Release history Release notifications | RSS feed

0.6.3

2 release files

This release

0.6.2 This release

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page