Python library for communicating with Flic smart buttons over Bluetooth Low Energy (BLE). Supports the full Flic 2 protocol including pairing, authentication, button events, rotation tracking, firmware updates, and device name management.
| Device | Button(s) | Rotation | Gestures | Selector Modes |
|---|---|---|---|---|
| Flic 2 | 1 | — | — | — |
| Flic Duo | 2 | Dial (per-button) | Swipe (left/right/up/down) | — |
| Flic Twist | 1 | Full rotation | — | 12 slots + free mode |
pip install pyflic-bleRequires Python 3.12+.
- bleak — Cross-platform BLE communication (macOS, Linux, Windows)
- bleak-retry-connector — Reliable BLE connections with retry logic
- cryptography — Ed25519 signature verification and X25519 key exchange
import asyncio
from bleak import BleakScanner
from pyflic_ble import FlicClient, DeviceType
async def main():
# Discover a Flic button (they advertise a specific service UUID)
device = await BleakScanner.find_device_by_filter(
lambda d, ad: "00420000-8f59-4420-870d-84f3b617e493" in (ad.service_uuids or [])
or "00c90000-2cbd-4f2a-a725-5ccd960ffb7d" in (ad.service_uuids or [])
)
if not device:
print("No Flic button found")
return
# Create client
client = FlicClient(
address=device.address,
ble_device=device,
)
# Set up event handler
def on_button_event(event_type, event_data):
print(f"Button event: {event_type} — {event_data}")
client.on_button_event = on_button_event
# Connect and pair
await client.connect()
pairing_id, pairing_key, serial, battery, sig_bits, uuid, fw = (
await client.full_verify_pairing()
)
print(f"Paired with {serial} (battery: {battery}, firmware: {fw})")
# Start receiving button events
await client.init_button_events()
# Keep running to receive events
await asyncio.sleep(60)
await client.disconnect()
asyncio.run(main())from pyflic_ble import FlicClient, DeviceType, PushTwistMode
# New device (will pair)
client = FlicClient(
address="AA:BB:CC:DD:EE:FF",
ble_device=ble_device,
)
# Known device (will reconnect with stored credentials)
client = FlicClient(
address="AA:BB:CC:DD:EE:FF",
ble_device=ble_device,
pairing_id=12345,
pairing_key=b"\x00" * 16,
serial_number="B123-A45678",
device_type=DeviceType.FLIC2,
)
# Flic Twist with a specific rotation mode
client = FlicClient(
address="AA:BB:CC:DD:EE:FF",
ble_device=ble_device,
device_type=DeviceType.TWIST,
push_twist_mode=PushTwistMode.CONTINUOUS,
)The device type is auto-detected from the serial number prefix (T = Twist, D = Duo, anything else = Flic 2), or can be specified explicitly.
Pairing performs an ECDH key exchange authenticated with Ed25519 signatures. The button must be in pairing mode (hold for 7 seconds until LED flashes).
await client.connect()
# Returns credentials to store for future reconnections
pairing_id, pairing_key, serial, battery, sig_bits, button_uuid, firmware_version = (
await client.full_verify_pairing()
)
await client.init_button_events()Quick verify uses stored pairing credentials for fast reconnection without user interaction.
client = FlicClient(
address="AA:BB:CC:DD:EE:FF",
ble_device=ble_device,
pairing_id=saved_pairing_id,
pairing_key=saved_pairing_key,
serial_number=saved_serial,
sig_bits=saved_sig_bits,
)
await client.connect()
await client.quick_verify()
await client.init_button_events()def on_button_event(event_type: str, event_data: dict):
print(f"{event_type}: {event_data}")
client.on_button_event = on_button_eventEvent types (all devices):
down— Button pressedup— Button releasedclick— Single click completed (fired after double-click timeout)double_click— Double click detectedhold— Button held down
Duo-only gesture events:
swipe_left,swipe_right,swipe_up,swipe_down
Twist-only events:
selector_changed— Twist rotated to a different selector slot (extra data includesselector_index0–11)
For Duo buttons, event_data["button_index"] indicates which button (0 = big, 1 = small).
def on_rotate_event(event_type: str, event_data: dict):
print(f"{event_type}: {event_data}")
client.on_rotate_event = on_rotate_eventEvent types:
rotate_clockwiserotate_counter_clockwise
Event data fields:
angle_degrees— Current rotational angle (0–360)detent_crossings— Number of 1% increments crossed in this eventtotal_turns— Accumulated full revolutions (+/−)total_detent_crossings— Running total of percentage-boundary crossingsacceleration_multiplier— 1.0–100.0, increases with faster rotationrpm— Revolutions per minute (positive = CW, negative = CCW)
Duo rotation events also include dial_percentage (0–100%, clamped) and is_first_event.
Twist rotation events also include twist_mode_index (0–12) and mode_percentage (0–100%).
def on_selector_change(selector_index: int, extra_data: dict):
print(f"Selector moved to slot {selector_index}")
client.on_selector_change = on_selector_changeThe PushTwistMode setting controls how the Twist's rotation behaves:
| Mode | Behavior | LED Mode | Position |
|---|---|---|---|
DEFAULT |
Rotation with click events, 12 independent selector slots + free rotation mode (index 12). Position bounded 0–100% per mode. | Fill | Bounded |
CONTINUOUS |
Same as default but position wraps continuously instead of clamping. | Wrapping | Wrapping |
SELECTOR |
Emphasizes slot selection (modes 0–11). Click and double-click events on all modes. | Slot indicator | Bounded |
For Twist devices, you can programmatically set the rotation position for any mode:
# Set mode 12 (free rotation) to 50%
await client.async_send_update_twist_position(mode_index=12, percentage=50.0)
# Set selector slot 3 to 75%
await client.async_send_update_twist_position(mode_index=3, percentage=75.0)# Battery level (millivolts)
battery = await client.get_battery_level()
# Firmware version (integer)
firmware = await client.get_firmware_version()
# Device name (returns name and timestamp)
name, timestamp = await client.get_name()
# Set device name (max 23 UTF-8 bytes, auto-truncated)
new_name, timestamp = await client.set_name("My Flic")OTA firmware updates with flow-controlled data transfer and progress reporting:
def on_progress(bytes_sent: int, total_bytes: int):
print(f"Firmware update: {bytes_sent}/{total_bytes} bytes ({100*bytes_sent//total_bytes}%)")
with open("firmware.bin", "rb") as f:
firmware_binary = f.read()
success = await client.async_firmware_update(
firmware_binary=firmware_binary,
progress_callback=on_progress,
)The firmware update process:
- Sends the firmware header to the device
- Transfers compressed firmware data with flow control (device acknowledges chunks)
- Device verifies the firmware signature
- Sends force-disconnect to trigger device reboot with new firmware
During a firmware update, all other FlicClient instances are blocked from connecting to the same BLE address to prevent interference.
def on_disconnect():
print("Connection lost — will need to reconnect")
client.on_disconnect = on_disconnectQuery what a device supports without checking its type:
caps = client.capabilities
caps.button_count # 1 for Flic 2/Twist, 2 for Duo
caps.has_rotation # True for Duo and Twist
caps.has_selector # True for Twist only (12 selector modes)
caps.has_gestures # True for Duo only (swipe gestures)
caps.has_frame_header # True for Flic 2/Duo, False for TwistThe Flic protocol uses multiple cryptographic layers:
- Ed25519 — Factory-signed device identity verification during pairing. Each device type has its own public key. The library tries all 4 signature twist variants to find the correct one.
- X25519 ECDH — Ephemeral key exchange during pairing to establish a shared secret.
- HMAC-SHA256 — Key derivation for session keys and pairing credentials from the shared secret.
- Chaskey-LTS — Lightweight MAC algorithm for authenticating every packet in an established session. Uses a 5-byte truncated MAC with direction bit and packet counter to prevent replay.
The Flic 2 and Duo share one protocol variant, while the Twist uses a structurally different one:
| Feature | Flic 2 / Duo | Twist |
|---|---|---|
| BLE Service UUID | 00420000-... |
00c90000-... |
| Packet format | [frame_header][opcode][payload][mac] |
[opcode][payload][mac] |
| Frame header | Yes (connection ID, fragment flags) | No |
| Packet fragmentation | Supported | Not needed |
| Opcode namespace | Integer constants | Hex constants (TWIST_OPCODE_*) |
| Pairing flags | supportsDuo=true |
clientVariant=0x00 |
| Quick verify | Uses stored sig_bits |
Always signature_variant=0 |
DISCONNECTED → connect() → CONNECTED
→ full_verify_pairing() → SESSION_ESTABLISHED (new device)
→ quick_verify() → SESSION_ESTABLISHED (known device)
→ init_button_events() (start receiving events)
→ disconnect() → DISCONNECTED
# Install in editable mode
pip install -e .
# Run tests
python -m pytest tests/
# Run a specific test
python -m pytest tests/test_rotate_tracker.py::test_multi_mode_tracker_mode_12_boundedMIT