Use events and commands to control custom onboard leds and buttons. Can be used to communicate with an external Python script via a WebSocket API running in a container alongside LVA.
477 lines
16 KiB
Python
477 lines
16 KiB
Python
import asyncio
|
|
import re
|
|
import shutil
|
|
import time
|
|
from enum import IntFlag, Enum
|
|
|
|
import websockets
|
|
import json
|
|
import hid
|
|
import os
|
|
|
|
from websockets import ClientConnection
|
|
|
|
# --- CONFIGURATION ---
|
|
# Default to localhost if running on host network, or use LVA container name/IP
|
|
LVA_WS_URL = os.getenv("LVA_WS_URL", "ws://0.0.0.0:6055")
|
|
|
|
JABRA_VENDOR = 0x0b0e
|
|
# JABRA_PRODUCT = 0x0412
|
|
|
|
starttime = time.time()
|
|
|
|
pw_sink = os.getenv("PW_SINK", "@DEFAULT_SINK@")
|
|
|
|
vol_ctrl = os.getenv("VOLUME_CONTROL", None)
|
|
|
|
if vol_ctrl not in ["pipewire", "lva"]:
|
|
print("invalid volume control")
|
|
vol_ctrl = None
|
|
|
|
print(hid.enumerate())
|
|
# USAGE_PAGE = 11
|
|
|
|
devices = []
|
|
for device in hid.enumerate(JABRA_VENDOR):
|
|
serial = device['serial_number']
|
|
print(f"Found {device['product_string']} serial number: {serial}")
|
|
devices.append(device['path'])
|
|
|
|
if len(devices) == 0:
|
|
# print("NO JABRA ALERT WAAAAA")
|
|
raise Exception("no jabra speak 410 found!")
|
|
|
|
|
|
class Telephony(IntFlag):
|
|
hook_switch = 1 << 0
|
|
line_busy_tone = 1 << 1
|
|
speaker_phone = 1 << 2
|
|
mute = 1 << 3
|
|
flash = 1 << 4
|
|
redial = 1 << 5
|
|
speed_dial = 1 << 6
|
|
phone_key_bit_0 = 1 << 7
|
|
phone_key_bit_1 = 1 << 8
|
|
phone_key_bit_2 = 1 << 9
|
|
phone_key_bit_3 = 1 << 10
|
|
# no clue
|
|
button_7 = 1 << 11
|
|
|
|
|
|
class LEDs(IntFlag):
|
|
off_hook = 1 << 0
|
|
speaker = 1 << 1
|
|
mute = 1 << 2
|
|
ring = 1 << 3
|
|
hold = 1 << 4
|
|
microphone = 1 << 5
|
|
# Marked telephony and not LED, probably why it's a dupe of ring
|
|
ringer = 1 << 6
|
|
|
|
|
|
class Volume(IntFlag):
|
|
vol_down = 1 << 0
|
|
vol_up = 1 << 1
|
|
# Does this ever trigger? might be a host -> device only thing?
|
|
mute = 1 << 2
|
|
|
|
|
|
class LEDState(IntFlag, Enum):
|
|
default = 0
|
|
three_green = LEDs.off_hook
|
|
all_red = LEDs.mute | LEDs.off_hook
|
|
ringing_and_flashing = LEDs.ring
|
|
flashing = LEDs.ring | LEDs.off_hook
|
|
partial_flash = LEDs.hold
|
|
|
|
|
|
class LVAEvent(str, Enum):
|
|
"""Events broadcast from LVA to peripheral clients."""
|
|
|
|
WAKE_WORD_DETECTED = "wake_word_detected"
|
|
LISTENING = "listening"
|
|
THINKING = "thinking"
|
|
TTS_SPEAKING = "tts_speaking"
|
|
TTS_FINISHED = "tts_finished"
|
|
PIPELINE_ERROR = "pipeline_error"
|
|
IDLE = "idle"
|
|
MUTED = "muted"
|
|
TIMER_TICKING = "timer_ticking"
|
|
TIMER_UPDATED = "timer_updated"
|
|
TIMER_RINGING = "timer_ringing"
|
|
MEDIA_PLAYER_PLAYING = "media_player_playing"
|
|
VOLUME_CHANGED = "volume_changed"
|
|
VOLUME_MUTED = "volume_muted"
|
|
ZEROCONF = "zeroconf"
|
|
DISCONNECTED = "disconnected"
|
|
|
|
|
|
class LVACommand(str, Enum):
|
|
"""Commands accepted from peripheral clients."""
|
|
|
|
START_LISTENING = "start_listening"
|
|
MUTE_MIC = "mute_mic"
|
|
UNMUTE_MIC = "unmute_mic"
|
|
VOLUME_UP = "volume_up"
|
|
VOLUME_DOWN = "volume_down"
|
|
STOP_TIMER_RINGING = "stop_timer_ringing"
|
|
STOP_MEDIA_PLAYER = "stop_media_player"
|
|
STOP_PIPELINE = "stop_pipeline"
|
|
|
|
|
|
class JabraSpeak:
|
|
def __init__(self, path):
|
|
self.path = path
|
|
self.device = hid.device()
|
|
self.device.open_path(self.path)
|
|
# we can do thread fuckery to make this work
|
|
self.device.set_nonblocking(True)
|
|
|
|
async def read(self):
|
|
while True:
|
|
try:
|
|
read_info = self.device.read(8)
|
|
if read_info:
|
|
match read_info[0]:
|
|
case 0x03:
|
|
return Telephony(read_info[1] | read_info[2] << 8)
|
|
case 0x01:
|
|
return Volume(read_info[1])
|
|
case _:
|
|
print(f"{round(time.time() - starttime, 1)}s: Packet {bytelist(read_info)} of unknown type")
|
|
|
|
await asyncio.sleep(0.1)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
print("fatal error in jabra loop: ", e)
|
|
await asyncio.sleep(1)
|
|
|
|
async def write(self, button_state: LEDs):
|
|
return await asyncio.to_thread(self.device.write, [0x03, button_state & 0xff, (button_state & 0xff00) >> 8])
|
|
|
|
async def readloop(self):
|
|
global last_mute, muted
|
|
|
|
while True:
|
|
event = await self.read()
|
|
print(f"from jabra: {event.__class__.__name__} {event.name} 0b{int(event):b}")
|
|
# print(f"last event: {last_jabra_write.name}")
|
|
if isinstance(event, Telephony):
|
|
if (
|
|
# Hangup pressed during TTS playback
|
|
((event & Telephony.flash) and last_jabra_write == LEDState.partial_flash)
|
|
# Hangup pressed during active listening
|
|
or (event is None and last_jabra_write == LEDState.three_green)
|
|
# button_7 (bit 11, undocumented in HID spec) fires when hangup is pressed
|
|
# while LEDs are flashing — covers wake word detected, thinking, and timer
|
|
# ringing states; acts as a catch-all hangup for active pipeline phases
|
|
or (event & Telephony.button_7)
|
|
):
|
|
print("jabra to lva: hangup detected")
|
|
if current_state == LVAEvent.MEDIA_PLAYER_PLAYING:
|
|
await write_to_lva(LVACommand.STOP_MEDIA_PLAYER)
|
|
else:
|
|
await write_to_lva(LVACommand.STOP_TIMER_RINGING)
|
|
await write_to_lva(LVACommand.STOP_PIPELINE)
|
|
# Mute switch
|
|
elif event & Telephony.mute:
|
|
print("jabra to lva: mute toggle detected")
|
|
global muted
|
|
if muted:
|
|
await set_mute(False)
|
|
else:
|
|
await set_mute(True)
|
|
# Call button pressed while LEDs are in default (idle) state.
|
|
# Guard against the device's known hardware quirk: it spuriously fires a
|
|
# hook_switch event immediately after unmuting, so off_mute_cooldown()
|
|
# gates the action on at least 1 second having elapsed since the last
|
|
# mute toggle before treating this as a genuine button press.
|
|
elif event & Telephony.hook_switch and last_jabra_write == LEDState.default:
|
|
if off_mute_cooldown():
|
|
print("jabra to lva: call button detected")
|
|
# If lva is glitched and i dont update the state machine, it will absolutely crap out
|
|
await write_to_jabra(LEDState.flashing)
|
|
|
|
await write_to_lva(LVACommand.START_LISTENING)
|
|
|
|
await asyncio.create_task(listening_bodge())
|
|
elif isinstance(event, Volume):
|
|
if not vol_ctrl:
|
|
print("ignoring volume command, env not set")
|
|
continue
|
|
if event & Volume.vol_up:
|
|
print("jabra to lva: volume up detected")
|
|
match vol_ctrl:
|
|
case "lva":
|
|
await write_to_lva(LVACommand.VOLUME_UP)
|
|
case "pipewire":
|
|
await wpctl_vol("10%+")
|
|
elif event & Volume.vol_down:
|
|
print("jabra to lva: volume down detected")
|
|
match vol_ctrl:
|
|
case "lva":
|
|
await write_to_lva(LVACommand.VOLUME_DOWN)
|
|
case "pipewire":
|
|
await wpctl_vol("10%-")
|
|
elif event & Volume.mute:
|
|
print("jabra to lva: consumer control mute toggle detected")
|
|
if muted:
|
|
await set_mute(False)
|
|
print("unmute cooldown")
|
|
else:
|
|
await set_mute(True)
|
|
|
|
|
|
# Fixes a bug where it can get stuck on wakeword detected
|
|
async def listening_bodge():
|
|
await asyncio.sleep(0.5)
|
|
if current_state == LVAEvent.WAKE_WORD_DETECTED:
|
|
await write_to_lva(LVACommand.STOP_PIPELINE)
|
|
await write_to_jabra(LEDState.three_green)
|
|
|
|
|
|
last_jabra_write: LEDs | LEDState = LEDState.default
|
|
last_lva_write: LVACommand | None = None
|
|
devices = [JabraSpeak(d) for d in devices]
|
|
|
|
last_mute = 0
|
|
|
|
|
|
def bytelist(l: list[int]):
|
|
return "[" + ', '.join(f"0x{b:02x}" for b in l) + "]"
|
|
|
|
|
|
async def write_to_jabra(state: LEDs | LEDState):
|
|
print(f"to jabra: {state.name} {int(state):b}")
|
|
global last_jabra_write
|
|
last_jabra_write = state
|
|
return await asyncio.gather(*[d.write(state) for d in devices])
|
|
|
|
|
|
async def write_to_lva(command: LVACommand, data: dict = None):
|
|
if lva_sock:
|
|
global last_lva_write
|
|
last_lva_write = command
|
|
message = json.dumps({"command": command} | ({"data": data} if data else {}))
|
|
print(f"to lva: {message}")
|
|
await lva_sock.send(message)
|
|
else:
|
|
print("to lva: failed, no websocket")
|
|
|
|
|
|
lva_sock: None | ClientConnection = None
|
|
|
|
|
|
async def set_mute(m: bool, write_lva: bool = True):
|
|
global muted, last_mute
|
|
muted = m
|
|
mute_cooldown()
|
|
if m:
|
|
commands = ([write_to_jabra(LEDState.all_red)] +
|
|
([write_to_lva(LVACommand.MUTE_MIC)] if write_lva else []))
|
|
|
|
await asyncio.gather(*commands)
|
|
|
|
else:
|
|
commands = ([write_to_jabra(LEDState.default)] +
|
|
([write_to_lva(LVACommand.UNMUTE_MIC)] if write_lva else []))
|
|
|
|
await asyncio.gather(*commands)
|
|
|
|
|
|
async def pipeline_error():
|
|
for _ in range(4):
|
|
await write_to_jabra(LEDState.all_red)
|
|
await asyncio.sleep(0.1)
|
|
await write_to_jabra(LEDState.default)
|
|
await asyncio.sleep(0.1)
|
|
|
|
async def ha_disconnected():
|
|
"""All surround leds blink repeatedly with red color to indicate disconnected state from LVA."""
|
|
while True:
|
|
await write_to_jabra(LEDState.all_red)
|
|
await asyncio.sleep(0.5)
|
|
await write_to_jabra(LEDState.default)
|
|
await asyncio.sleep(0.5)
|
|
|
|
current_state: None | LVAEvent = None
|
|
|
|
muted: bool = False
|
|
|
|
|
|
async def run_cmd(cmd: list[str]):
|
|
print("run_cmd ", cmd)
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
stdin=asyncio.subprocess.DEVNULL,
|
|
)
|
|
|
|
stdout, stderr = await proc.communicate()
|
|
|
|
try:
|
|
stdout = stdout.decode().strip()
|
|
stderr = stderr.decode().strip()
|
|
except UnicodeDecodeError:
|
|
stdout = stdout.decode("ascii", 'ignore').strip()
|
|
stderr = stderr.decode("ascii", 'ignore').strip()
|
|
|
|
if proc.returncode != 0:
|
|
raise Exception("wpctl failed ", cmd, stdout, stderr)
|
|
else:
|
|
return stdout, stderr
|
|
|
|
|
|
async def wpctl_vol(vol_mod: str):
|
|
await run_cmd(["wpctl", "set-volume", "-l", "1.0", pw_sink, vol_mod])
|
|
|
|
vol = await pw_vol()
|
|
if vol <= 0:
|
|
await run_cmd(["wpctl", "set-mute", pw_sink, "1"])
|
|
else:
|
|
await run_cmd(["wpctl", "set-mute", pw_sink, "0"])
|
|
|
|
|
|
async def wsloop():
|
|
global lva_sock
|
|
while True:
|
|
try:
|
|
async with websockets.connect(LVA_WS_URL) as websocket:
|
|
print(f"Connected to LVA at {LVA_WS_URL}")
|
|
lva_sock = websocket
|
|
while True:
|
|
data = await websocket.recv()
|
|
print(f"from lva: {data}")
|
|
json_data = json.loads(data)
|
|
if json_data["event"] == "snapshot":
|
|
await set_mute(json_data["data"]["muted"], write_lva=False)
|
|
global current_state
|
|
try:
|
|
current_state = LVAEvent(json_data["event"])
|
|
except ValueError:
|
|
print(f"current state is not a valid event: {json_data['event']}")
|
|
match current_state:
|
|
case LVAEvent.WAKE_WORD_DETECTED:
|
|
await write_to_jabra(LEDState.flashing)
|
|
case LVAEvent.LISTENING:
|
|
await write_to_jabra(LEDState.three_green)
|
|
case LVAEvent.THINKING:
|
|
await write_to_jabra(LEDState.flashing)
|
|
case LVAEvent.TTS_SPEAKING:
|
|
await write_to_jabra(LEDState.partial_flash)
|
|
case LVAEvent.TTS_FINISHED:
|
|
await write_to_jabra(LEDState.default)
|
|
case LVAEvent.PIPELINE_ERROR:
|
|
asyncio.create_task(pipeline_error())
|
|
case LVAEvent.IDLE:
|
|
await set_mute(False, write_lva=False)
|
|
case LVAEvent.MUTED:
|
|
await set_mute(True, write_lva=False)
|
|
case LVAEvent.TIMER_TICKING:
|
|
pass
|
|
case LVAEvent.TIMER_UPDATED:
|
|
pass
|
|
case LVAEvent.TIMER_RINGING:
|
|
await write_to_jabra(LEDState.flashing)
|
|
case LVAEvent.MEDIA_PLAYER_PLAYING:
|
|
pass
|
|
case LVAEvent.VOLUME_CHANGED:
|
|
pass
|
|
case LVAEvent.VOLUME_MUTED:
|
|
pass
|
|
case LVAEvent.ZEROCONF:
|
|
pass
|
|
case LVAEvent.DISCONNECTED:
|
|
await write_to_jabra(ha_disconnected())
|
|
case event:
|
|
print(f"Unknown event: {event}")
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
print("fatal error in lva loop: ", e)
|
|
lva_sock = None
|
|
await asyncio.sleep(1)
|
|
|
|
|
|
async def pw_vol():
|
|
stdout, stderr = await run_cmd(["wpctl", "get-volume", pw_sink])
|
|
|
|
# Extract the float value
|
|
match = re.search(r'Volume:\s*([0-9.]+)', stdout)
|
|
return float(match.group(1)) # Returns exactly 0.5
|
|
|
|
|
|
def off_mute_cooldown():
|
|
global last_mute
|
|
ret = time.perf_counter() - last_mute >= 1
|
|
print("off mute cooldown: ", ret)
|
|
return ret
|
|
|
|
|
|
def mute_cooldown():
|
|
global last_mute
|
|
print("set mute cooldown")
|
|
last_mute = time.perf_counter()
|
|
|
|
|
|
async def mute_detect_bodge():
|
|
if shutil.which("pw-record") is None:
|
|
print("pw-record not found. make sure wireplumber is installed.")
|
|
return
|
|
while True:
|
|
proc = None
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"pw-record", "--rate", "16000", "--channels", "1", "--format", "s16", "-",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
stdin=asyncio.subprocess.DEVNULL
|
|
)
|
|
while True:
|
|
rate = 16_000
|
|
read_dur = 0.2
|
|
chunk = await proc.stdout.readexactly(int(rate * read_dur))
|
|
global muted, last_mute
|
|
# all zeroes, mic has been muted
|
|
if not any(chunk) and not muted and off_mute_cooldown():
|
|
await set_mute(True)
|
|
if any(chunk) and muted and off_mute_cooldown():
|
|
await set_mute(False)
|
|
|
|
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except asyncio.IncompleteReadError as e:
|
|
# This happens when pw-record is killed while waiting for data. Just exit the loop and respawn it.
|
|
print("pw-record process ended ", e)
|
|
print(await proc.communicate())
|
|
print(proc.returncode)
|
|
except Exception as e:
|
|
if proc:
|
|
try:
|
|
proc.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
print("fatal error in mute_detect_bodge: ", e)
|
|
await asyncio.sleep(1)
|
|
|
|
|
|
async def main():
|
|
async with asyncio.TaskGroup() as tg:
|
|
# Spawn your infinite loops here
|
|
tg.create_task(wsloop())
|
|
tg.create_task(mute_detect_bodge())
|
|
|
|
# tg.create_task(block_test())
|
|
for d in devices:
|
|
tg.create_task(d.readloop())
|
|
|
|
# await asyncio.sleep(0.5)
|
|
# await write_to_jabra(LEDs.microphone | LEDs.speaker)
|
|
|
|
|
|
asyncio.run(main())
|