Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions extras/friend-lite-sdk/README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,64 @@
# friend-lite-sdk

Python SDK for OMI / Friend Lite BLE devices — audio streaming, button events, and transcription.
Python SDK for OMI / Friend Lite BLE wearable devices — audio streaming, button events, device control, and transcription.

Derived from the [OMI Python SDK](https://github.com/BasedHardware/omi/tree/main/sdks/python) (MIT license, Based Hardware Contributors). See `NOTICE` for attribution.

## Installation

```bash
pip install -e extras/friend-lite-sdk
pip install friend-lite-sdk
```

With optional transcription support:

```bash
pip install -e "extras/friend-lite-sdk[deepgram,wyoming]"
pip install "friend-lite-sdk[deepgram]" # Deepgram cloud transcription
pip install "friend-lite-sdk[wyoming]" # Local ASR via Wyoming protocol
pip install "friend-lite-sdk[deepgram,wyoming]" # Both
```

## Usage
## Features

- **BLE Audio Streaming** — Connect to OMI/Friend Lite devices and stream Opus-encoded audio
- **Button Events** — Subscribe to single tap, double tap, long press events
- **Haptic Control** — Trigger haptic feedback patterns on supported devices
- **WiFi Sync** — Configure and trigger WiFi-based audio sync
- **Storage Access** — Read stored audio from device storage
- **Neo1 Support** — Sleep/wake control for Neo1 devices
- **Transcription** — Built-in Deepgram and Wyoming ASR integration

## Quick Start

```python
import asyncio
from friend_lite import OmiConnection, ButtonState, parse_button_event

async def main():
async with OmiConnection("AA:BB:CC:DD:EE:FF") as conn:
# Stream audio
await conn.subscribe_audio(lambda _handle, data: print(len(data), "bytes"))

# Listen for button events
await conn.subscribe_button(
lambda _handle, data: print("Button:", parse_button_event(data))
)

await conn.wait_until_disconnected()

asyncio.run(main())
```

## Device Discovery

```python
import asyncio
from friend_lite import print_devices

asyncio.run(print_devices())
```

## Links

- [Chronicle Project](https://github.com/SimpleOpenSoftware/chronicle)
- [Original OMI Project](https://github.com/BasedHardware/omi)
22 changes: 22 additions & 0 deletions extras/friend-lite-sdk/friend_lite/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,45 @@
from .uuids import (
BATTERY_LEVEL_CHAR_UUID,
BATTERY_SERVICE_UUID,
FEATURE_HAPTIC,
FEATURE_WIFI,
FEATURES_CHAR_UUID,
FEATURES_SERVICE_UUID,
HAPTIC_CHAR_UUID,
HAPTIC_SERVICE_UUID,
NEO1_CTRL_CHAR_UUID,
OMI_AUDIO_CHAR_UUID,
OMI_BUTTON_CHAR_UUID,
OMI_BUTTON_SERVICE_UUID,
STORAGE_DATA_STREAM_CHAR_UUID,
STORAGE_READ_CONTROL_CHAR_UUID,
STORAGE_SERVICE_UUID,
STORAGE_WIFI_CHAR_UUID,
)
from .wifi import WifiErrorCode

__all__ = [
"BATTERY_LEVEL_CHAR_UUID",
"BATTERY_SERVICE_UUID",
"ButtonState",
"FEATURE_HAPTIC",
"FEATURE_WIFI",
"FEATURES_CHAR_UUID",
"FEATURES_SERVICE_UUID",
"HAPTIC_CHAR_UUID",
"HAPTIC_SERVICE_UUID",
"NEO1_CTRL_CHAR_UUID",
"Neo1Connection",
"OMI_AUDIO_CHAR_UUID",
"OMI_BUTTON_CHAR_UUID",
"OMI_BUTTON_SERVICE_UUID",
"OmiConnection",
"STORAGE_DATA_STREAM_CHAR_UUID",
"STORAGE_READ_CONTROL_CHAR_UUID",
"STORAGE_SERVICE_UUID",
"STORAGE_WIFI_CHAR_UUID",
"WearableConnection",
"WifiErrorCode",
"listen_to_omi",
"parse_button_event",
"print_devices",
Expand Down
118 changes: 116 additions & 2 deletions extras/friend-lite-sdk/friend_lite/bluetooth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@

from bleak import BleakClient, BleakScanner

from .uuids import BATTERY_LEVEL_CHAR_UUID, OMI_AUDIO_CHAR_UUID, OMI_BUTTON_CHAR_UUID
from .uuids import (
BATTERY_LEVEL_CHAR_UUID,
FEATURE_HAPTIC,
FEATURE_WIFI,
FEATURES_CHAR_UUID,
HAPTIC_CHAR_UUID,
OMI_AUDIO_CHAR_UUID,
OMI_BUTTON_CHAR_UUID,
STORAGE_DATA_STREAM_CHAR_UUID,
STORAGE_READ_CONTROL_CHAR_UUID,
STORAGE_WIFI_CHAR_UUID,
)


def print_devices() -> None:
Expand Down Expand Up @@ -93,11 +104,114 @@ async def wait_until_disconnected(self, timeout: float | None = None) -> None:


class OmiConnection(WearableConnection):
"""OMI device with button support."""
"""OMI device with button and WiFi sync support."""

async def subscribe_button(self, callback: Callable[[int, bytearray], None]) -> None:
await self.subscribe(OMI_BUTTON_CHAR_UUID, callback)

# -- Haptic ------------------------------------------------------------

async def play_haptic(self, pattern: int = 1) -> None:
"""Trigger the haptic motor.

*pattern*: 1 = short (100ms), 2 = medium (300ms), 3 = long (500ms).
"""
if self._client is None:
raise RuntimeError("Not connected to device")
if pattern not in (1, 2, 3):
raise ValueError("pattern must be 1 (100ms), 2 (300ms), or 3 (500ms)")
await self._client.write_gatt_char(HAPTIC_CHAR_UUID, bytes([pattern]), response=True)

async def is_haptic_supported(self) -> bool:
"""Check whether the device has a haptic motor."""
features = await self.read_features()
return bool(features & FEATURE_HAPTIC)

# -- Features ----------------------------------------------------------

async def read_features(self) -> int:
"""Read device feature bitmask."""
if self._client is None:
raise RuntimeError("Not connected to device")
data = await self._client.read_gatt_char(FEATURES_CHAR_UUID)
return int.from_bytes(data, byteorder="little")

async def is_wifi_supported(self) -> bool:
"""Check whether the device supports WiFi sync."""
features = await self.read_features()
return bool(features & FEATURE_WIFI)

# -- WiFi sync ---------------------------------------------------------

async def _wifi_command(self, payload: bytes, timeout: float = 5.0) -> int:
"""Send a command to the WiFi characteristic and wait for notify response.

Returns the response byte (0 = success).
"""
if self._client is None:
raise RuntimeError("Not connected to device")

response_event = asyncio.Event()
response_value: list[int] = []

def _on_notify(_sender: int, data: bytearray) -> None:
if data:
response_value.append(data[0])
response_event.set()

await self._client.start_notify(STORAGE_WIFI_CHAR_UUID, _on_notify)
try:
await self._client.write_gatt_char(STORAGE_WIFI_CHAR_UUID, payload, response=True)
await asyncio.wait_for(response_event.wait(), timeout=timeout)
finally:
await self._client.stop_notify(STORAGE_WIFI_CHAR_UUID)

return response_value[0] if response_value else -1

async def setup_wifi(self, ssid: str, password: str) -> int:
"""Send WiFi AP credentials to device. Returns response code (0=success)."""
ssid_bytes = ssid.encode("utf-8")
pwd_bytes = password.encode("utf-8")
payload = bytes([0x01, len(ssid_bytes)]) + ssid_bytes + bytes([len(pwd_bytes)]) + pwd_bytes
return await self._wifi_command(payload)

async def start_wifi(self) -> int:
"""Send WIFI_START command. Returns response code (0=success)."""
return await self._wifi_command(bytes([0x02]))

async def stop_wifi(self) -> int:
"""Send WIFI_SHUTDOWN command. Returns response code (0=success)."""
return await self._wifi_command(bytes([0x03]))

# -- Storage -----------------------------------------------------------

async def get_storage_info(self) -> tuple[int, int]:
"""Read (file_size, offset) from storage read control characteristic."""
if self._client is None:
raise RuntimeError("Not connected to device")
data = await self._client.read_gatt_char(STORAGE_READ_CONTROL_CHAR_UUID)
file_size = int.from_bytes(data[0:4], byteorder="little")
offset = int.from_bytes(data[4:8], byteorder="little")
return (file_size, offset)

async def start_storage_read(self, file_num: int = 0, offset: int = 0) -> None:
"""Send READ command to trigger data transfer.

Written to the data stream characteristic (0x30295781) which is
both the command write target and the data notification source.
Firmware expects: [command=0, file_num, offset(4 bytes big-endian)]
"""
if self._client is None:
raise RuntimeError("Not connected to device")
payload = bytes([0x00, file_num]) + offset.to_bytes(4, byteorder="big")
await self._client.write_gatt_char(STORAGE_DATA_STREAM_CHAR_UUID, payload, response=True)

async def subscribe_storage_data(self, callback: Callable[[int, bytearray], None]) -> None:
"""Subscribe to storage data stream notifications (for BLE storage reads)."""
if self._client is None:
raise RuntimeError("Not connected to device")
await self._client.start_notify(STORAGE_DATA_STREAM_CHAR_UUID, callback)


async def listen_to_omi(mac_address: str, char_uuid: str, data_handler) -> None:
"""Backward-compatible wrapper for older consumers."""
Expand Down
18 changes: 18 additions & 0 deletions extras/friend-lite-sdk/friend_lite/uuids.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,21 @@

# Neo1 control characteristic UUID (sleep/wake)
NEO1_CTRL_CHAR_UUID = "19B10003-E8F2-537E-4F6C-D104768A1214"

# Features service
FEATURES_SERVICE_UUID = "19B10020-E8F2-537E-4F6C-D104768A1214"
FEATURES_CHAR_UUID = "19B10021-E8F2-537E-4F6C-D104768A1214"

# Storage / WiFi sync service
STORAGE_SERVICE_UUID = "30295780-4301-EABD-2904-2849ADFEAE43"
STORAGE_DATA_STREAM_CHAR_UUID = "30295781-4301-EABD-2904-2849ADFEAE43"
STORAGE_READ_CONTROL_CHAR_UUID = "30295782-4301-EABD-2904-2849ADFEAE43"
STORAGE_WIFI_CHAR_UUID = "30295783-4301-EABD-2904-2849ADFEAE43"

# Haptic service
HAPTIC_SERVICE_UUID = "CAB1AB95-2EA5-4F4D-BB56-874B72CFC984"
HAPTIC_CHAR_UUID = "CAB1AB96-2EA5-4F4D-BB56-874B72CFC984"

# Feature flags (bitmask values from firmware features.h)
FEATURE_HAPTIC = 1 << 5
FEATURE_WIFI = 1 << 9
14 changes: 14 additions & 0 deletions extras/friend-lite-sdk/friend_lite/wifi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""WiFi sync error codes matching firmware protocol."""

from enum import IntEnum


class WifiErrorCode(IntEnum):
SUCCESS = 0x00
INVALID_PACKET_LENGTH = 0x01
INVALID_SETUP_LENGTH = 0x02
SSID_LENGTH_INVALID = 0x03
PASSWORD_LENGTH_INVALID = 0x04
SESSION_ALREADY_RUNNING = 0x05
HARDWARE_NOT_AVAILABLE = 0xFE
UNKNOWN_COMMAND = 0xFF
16 changes: 15 additions & 1 deletion extras/friend-lite-sdk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,21 @@
name = "friend-lite-sdk"
version = "0.3.0"
description = "Python SDK for OMI/Neo1 BLE wearable devices — audio streaming, button events, and device control"
readme = "README.md"
requires-python = ">= 3.10"
license = "MIT"
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries",
"Topic :: System :: Hardware",
]
keywords = ["ble", "bluetooth", "wearable", "omi", "audio", "streaming"]
dependencies = [
"bleak>=0.22.3",
"numpy>=1.26",
Expand All @@ -12,7 +25,8 @@ dependencies = [
]

[project.urls]
Homepage = "https://github.com/AnkushMalaker/chronicle"
Homepage = "https://github.com/SimpleOpenSoftware/chronicle"
Repository = "https://github.com/SimpleOpenSoftware/chronicle"
"Original Project" = "https://github.com/BasedHardware/omi"

[project.optional-dependencies]
Expand Down
Loading