Skip to content

Instantly share code, notes, and snippets.

@lumpenspace
Created November 6, 2025 22:46
Show Gist options
  • Select an option

  • Save lumpenspace/fa371d44498d2668b1794bc3d520c072 to your computer and use it in GitHub Desktop.

Select an option

Save lumpenspace/fa371d44498d2668b1794bc3d520c072 to your computer and use it in GitHub Desktop.
lovense bluetooth protocol

lovense ble protocol — command catalog (ble-only)

ascii commands over a “uart-like” gatt service. write to tx, subscribe/read from rx. strings end with ;. success replies are usually OK;. device replies that carry data are documented with parsers below.

ble discovery (quick ref)

  • names typically LVS-<model><fw> (e.g., LVS-Edge36).

  • common services/characteristics by generation:

    • gen-1: service 0000fff0-0000-1000-8000-00805f9b34fb, rx …fff1…, tx …fff2…
    • gen-2 (nordic uart): service 6e400001-b5a3-f393-e0a9-e50e24dcca9e, tx …0002…, rx …0003…
    • newer “XY30…” family: service 5?300001-…, tx …002…, rx …003… (enumerate variants if you can’t wildcard)

conventions

  • command → literal ascii to write (for no-param calls).
  • builder → python function returning the bytes to write (for parametric calls).
  • parser → python function to turn a device reply string into a python object.

1) device info & power

DeviceType;

  • supported: all ble toys
  • description: returns device model letter, firmware, bt addr (compact hex)
  • spec: DeviceType; → e.g. C:11:0082059AD3BD;
  • parser
def parse_device_type(resp: str):
    model, fw, bt = resp.rstrip(';').split(':')
    return {"model_letter": model, "fw": fw, "bt_addr_hex": bt}

Battery;

  • supported: all
  • description: battery percent 0..100
  • spec: Battery; → e.g. 85;
  • parser
def parse_battery(resp: str) -> int:
    return int(resp.rstrip(';'))

PowerOff;

  • supported: all
  • description: powers down the device
  • spec: PowerOff;OK;

Status:1;

  • supported: all
  • description: status code (2 = normal on most fw)
  • spec: Status:1; → e.g. 2;
  • parser
def parse_status(resp: str) -> int:
    return int(resp.rstrip(';'))

2) generic actuation

Vibrate

  • supported: all vibrating toys (lush, hush, domi, edge, osci, ambi, etc.)
  • description: set vibration level (0–20 ≈ 0–100%)
  • spec: Vibrate:x;OK;
  • builder
def build_vibrate(level: int) -> bytes:
    level = max(0, min(20, level))
    return f"Vibrate:{level};".encode("ascii")

3) nora (rotation)

RotateChange;

  • supported: nora
  • description: toggles rotation direction
  • spec: RotateChange;OK;

Rotate

  • supported: nora
  • description: set rotation speed (0–20)
  • spec: Rotate:x;OK;
  • builder
def build_rotate(level: int) -> bytes:
    level = max(0, min(20, level))
    return f"Rotate:{level};".encode("ascii")

4) max (air pump)

absolute level

  • supported: max series
  • description: set absolute inflation level (0–5)
  • spec: Air:Level:x;OK;
  • builder
def build_air_level(level: int) -> bytes:
    level = max(0, min(5, level))
    return f"Air:Level:{level};".encode("ascii")

relative inflate / deflate

  • supported: max series
  • description: adjust inflation relatively by x steps
  • spec: Air:In:x; / Air:Out:x;OK;
  • builder
def build_air_delta(direction: str, delta: int) -> bytes:
    assert direction in ("In","Out")
    delta = max(0, min(5, delta))
    return f"Air:{direction}:{delta};".encode("ascii")

5) sensors (legacy accel stream)

start / stop stream

  • supported: max, nora (legacy accel)

  • description: start/stop accelerometer notifications; payload lines start with G followed by hex words (little-endian xyz)

  • spec:

    • StartMove:1; → stream like GEF008312ED00; (until stopped)
    • StopMove:1;OK;
  • parser

def parse_accel(resp: str):
    # resp example: 'GEF008312ED00;'
    payload = resp.rstrip(';')[1:]  # strip leading 'G'
    # split into 16-bit little-endian words represented as hex
    vals = [int(payload[i:i+4], 16) for i in range(0, len(payload), 4)]
    return {"x": vals[0], "y": vals[1], "z": vals[2]}

6) device settings (as seen in lovense app)

availability varies by model/firmware; treat as feature-detected at runtime.

GetAS; / Auto-switch write

  • label: auto-switch behaviour (turn off on disconnect, restore last level)

  • supported: many newer toys (domi/hush/lush2/…)

  • spec:

    • read: GetAS;AutoSwith:<off_on_disc>:<restore_last>; (each 0/1)
    • write: AutoSwith:On|Off:On|Off;OK;
  • parser (read) / builder (write)

def parse_autoswith(resp: str):
    # e.g., "AutoSwith:1:0;"
    _, a, b = resp.rstrip(';').split(':')
    return {"turn_off_on_disconnect": a == "1",
            "restore_last_level_on_reconnect": b == "1"}

def build_set_autoswith(off_on_disc: bool, restore_last: bool) -> bytes:
    f = lambda x: "On" if x else "Off"
    return f"AutoSwith:{f(off_on_disc)}:{f(restore_last)};".encode("ascii")

GetLight; / Light write

  • label: front/status led enable

  • supported: many newer toys

  • spec:

    • read: GetLight;Light:0|1;
    • write: Light:on|off;OK;
  • parser (read) / builder (write)

def parse_light(resp: str) -> bool:
    return resp.rstrip(';').endswith(':1')

def build_set_light(enabled: bool) -> bytes:
    return f"Light:{'on' if enabled else 'off'};".encode("ascii")

GetAlight; / ALight write (domi ring leds)

  • label: domi ring led toggle (note capitalisation: ALight for write)

  • supported: domi

  • spec:

    • read: GetAlight;Alight:0|1;
    • write: ALight:On|Off;OK;
  • parser (read) / builder (write)

def parse_alight(resp: str) -> bool:
    return resp.rstrip(';').endswith(':1')

def build_set_alight(enabled: bool) -> bytes:
    return f"ALight:{'On' if enabled else 'Off'};".encode("ascii")

GetLevel; / SetLevel (domi presets)

  • label: domi button preset intensities (low/med/high)

  • supported: domi

  • spec:

    • read: GetLevel;low,med,high; (each 0–20)
    • write: SetLevel:<slot 1..3>:<level 0..20>;OK;
  • parser (read) / builder (write)

def parse_levels(resp: str):
    low, med, high = map(int, resp.rstrip(';').split(','))
    return {"low": low, "med": med, "high": high}

def build_set_level(slot: int, level: int) -> bytes:
    slot = max(1, min(3, slot))
    level = max(0, min(20, level))
    return f"SetLevel:{slot}:{level};".encode("ascii")

device support matrix (quick view)

group commands devices
core DeviceType;, Battery;, PowerOff;, Status:1;, Vibrate:x; all ble toys
nora RotateChange;, Rotate:x;, StartMove:1;/StopMove:1; nora
max Air:Level:x;, Air:In:x;, Air:Out:x;, StartMove:1;/StopMove:1; max
domi GetLevel;/SetLevel, GetAlight;/ALight, GetAS;/AutoSwith, GetLight;/Light domi (plus some on others)

minimal ble i/o scaffolding (pseudo)

# replace with your ble stack (bleak/bluepy/etc)
class LovenseBLE:
    def __init__(self, client, tx_uuid, rx_uuid):
        self.client = client
        self.tx_uuid = tx_uuid
        self.rx_uuid = rx_uuid

    async def send(self, data: bytes):
        await self.client.write_gatt_char(self.tx_uuid, data)

    async def read_once(self) -> str:
        return (await self.client.read_gatt_char(self.rx_uuid)).decode("ascii")

    async def request(self, data: bytes) -> str:
        await self.send(data)
        return await self.read_once()

    def start_notifications(self, handler):
        self.client.start_notify(self.rx_uuid,
            lambda _uuid, b: handler(b.decode("ascii")))

notes

  • same string protocol appears over classic serial too; this doc is ble-only (rx/tx gatt).
  • uuids vary by model/firmware; enumerate common candidates or discover dynamically.
  • treat feature availability as “probe and adapt” at runtime; many settings are model/firmware gated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment