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())