unfurled
An async Python library for controlling Unfolded Circle Remote Two and Remote 3 devices.
Features
- Async-first design built on
aiohttpandwebsockets - Full REST API coverage via
CoreAPI - Real-time WebSocket event stream with auto-reconnect
- High-level
Remoteclass for common operations (activities, IR, media, docks) Dockclass 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()
# 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(
code="0000 006C ...",
format="PRONTO",
emitter_name="Dock IR", # or emitter_id="device-id"
repeat=1,
)
# Send from a loaded codeset
await remote.ir.send_from_codeset("Samsung TV", "VOLUME_UP")
# 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.4.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| unfurled-0.4.1.tar.gz | 180.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| unfurled-0.4.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 248.3 kB
Release files / unfurled-0.4.1.tar.gz
| Download URL | unfurled-0.4.1.tar.gz |
|---|---|
| Size | 180.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2dc6f720db26143d9c536400f1609a07aee7db3104b78663b6034f12bfbcae7c
|
|
BLAKE2b-256 checksum How to use checksums |
bf10f2eb9eb5ecad04a5d547e68e426e5aa1d16262c84ad97275985e742c9755
|
| 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 Aug 26, 2026.
Transparency logRelease files / unfurled-0.4.1-py3-none-any.whl
| Download URL | unfurled-0.4.1-py3-none-any.whl |
|---|---|
| Size | 67.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
88ee0ba85fa5a9b6e49955f97de29a9010783d4d11f97c8ff2af4efdd52f0884
|
|
BLAKE2b-256 checksum How to use checksums |
f3454d441c8b5ff8ee24e229acf0466d4573d6743deea62396614e75e4dd2832
|
| 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 Aug 26, 2026.
Transparency log