Add files via upload
What’s in it:
Xvf3800RuntimeConfig – simple dataclass for VID/PID, timeouts, etc.
Xvf3800Controller(EventHandler) – structured very similar to your other controllers:
On init:
Checks enabled and hid availability.
Uses hid.enumerate() to find the XVF3800 by VID/PID.
Opens the first HID interface (non-blocking).
Starts a background thread _poll_loop().
_poll_loop():
Reads HID reports (read(64, timeout_ms=...)).
For each non-empty report:
Calls _parse_hw_mute_from_report(report) → Optional[bool].
If it returns True/False and the value changed, it publishes:
self.loop.call_soon_threadsafe(
self.event_bus.publish,
"set_mic_mute",
{"state": new_state},
)
Exits cleanly when self._running is false or state.shutdown is true.
@subscribe def set_mic_mute(self, data: dict):
Listens to the same set_mic_mute event MQTT and ButtonController use.
Updates _last_hw_mute_state.
Calls _send_hw_mute_state(desired) to push mute+LED down to hardware.
Placeholders:
def _parse_hw_mute_from_report(self, report: bytes) -> Optional[bool]:
# TODO: implement once we know the report layout
_LOGGER.debug("XVF3800 HID report (len=%d): %s", len(report), report.hex())
return None
def _send_hw_mute_state(self, muted: bool) -> None:
# TODO: implement once we know output report format
_LOGGER.debug("(stub) Would send HID command to set XVF3800 mute=%s", muted)
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
"""XVF3800 USB/HID controller scaffold.
|
||||
|
||||
This module is intended to integrate the ReSpeaker XVF3800 mute button and
|
||||
mute LED with the Linux Voice Assistant (LVA) mute state.
|
||||
|
||||
Design goals:
|
||||
- HW mute button -> LVA:
|
||||
* When the user presses the XVF3800 mute button, the board mutes the
|
||||
mic and lights the red LED.
|
||||
* This controller detects the change over USB HID and publishes the
|
||||
standard "set_mic_mute" event so the existing MicMuteHandler can:
|
||||
- update ServerState.mic_muted
|
||||
- update ServerState.mic_muted_event
|
||||
- publish MQTT mute state
|
||||
- notify the tray, etc.
|
||||
|
||||
- LVA -> HW:
|
||||
* When LVA toggles mute (via HA MQTT, ReSpeaker 2-Mic GPIO button, etc.),
|
||||
this controller receives the same "set_mic_mute" event and sends a
|
||||
USB HID command to synchronize the XVF3800's internal mute state and
|
||||
red LED.
|
||||
|
||||
This file is a scaffold:
|
||||
- The USB/HID read/write paths are structured, but the actual report
|
||||
format is not yet known.
|
||||
- TODOs are marked where the mute bit must be decoded or set.
|
||||
- Once the test script (xvf3800_hid_mute_probe.py) has revealed the
|
||||
HID report layout, implement _parse_hw_mute_from_report() and
|
||||
_send_hw_mute_state().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from .event_bus import EventBus, EventHandler, subscribe
|
||||
from .models import ServerState
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
try: # pragma: no cover - depends on platform / install
|
||||
import hid # type: ignore[import]
|
||||
except Exception: # ImportError, RuntimeError, etc.
|
||||
hid = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Xvf3800RuntimeConfig:
|
||||
"""Runtime configuration for the XVF3800 controller.
|
||||
|
||||
This is intentionally simple; if we later add more XVF-specific options,
|
||||
they can be added here and wired from config.json.
|
||||
""" # noqa: D401
|
||||
|
||||
enabled: bool = True
|
||||
vendor_id: int = 0x2886
|
||||
product_id: int = 0x001A
|
||||
read_timeout_ms: int = 200
|
||||
poll_interval_seconds: float = 0.02
|
||||
|
||||
|
||||
class Xvf3800Controller(EventHandler):
|
||||
"""Controller for the ReSpeaker XVF3800 USB/HID mute integration.
|
||||
|
||||
Responsibilities:
|
||||
- Open the XVF3800 HID interface (if available and enabled).
|
||||
- Run a background thread that reads HID reports and detects mute
|
||||
state changes, publishing "set_mic_mute" events back into the LVA.
|
||||
- Listen for "set_mic_mute" events and push desired mute state down
|
||||
to the XVF3800 (muting the mic and controlling the red LED).
|
||||
|
||||
Safe failure:
|
||||
- If the 'hid' module is not available, or no matching XVF3800 device
|
||||
is found, the controller logs an info message and stays inactive.
|
||||
- All errors in the polling thread are logged but do not crash LVA.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
loop,
|
||||
event_bus: EventBus,
|
||||
state: ServerState,
|
||||
config: Optional[Xvf3800RuntimeConfig] = None,
|
||||
) -> None:
|
||||
super().__init__(event_bus)
|
||||
|
||||
self.loop = loop
|
||||
self.state = state
|
||||
self._cfg = config or Xvf3800RuntimeConfig()
|
||||
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._hid_dev: Optional["hid.device"] = None # type: ignore[name-defined]
|
||||
self._last_hw_mute_state: Optional[bool] = None
|
||||
self._running = False
|
||||
|
||||
if not self._cfg.enabled:
|
||||
_LOGGER.info("Xvf3800Controller disabled in config; not initializing HID")
|
||||
return
|
||||
|
||||
if hid is None:
|
||||
_LOGGER.info(
|
||||
"hidapi not available; XVF3800 mute integration disabled. "
|
||||
"Install it with 'pip install hidapi' if you want this feature.",
|
||||
)
|
||||
return
|
||||
|
||||
# Attempt to open the XVF3800 HID interface.
|
||||
try:
|
||||
self._hid_dev = self._open_device(
|
||||
self._cfg.vendor_id,
|
||||
self._cfg.product_id,
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Failed to open XVF3800 HID device (VID=0x%04x PID=0x%04x); "
|
||||
"mute integration disabled",
|
||||
self._cfg.vendor_id,
|
||||
self._cfg.product_id,
|
||||
)
|
||||
self._hid_dev = None
|
||||
|
||||
if self._hid_dev is None:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._poll_loop,
|
||||
name="Xvf3800ControllerThread",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
# Subscribe to events AFTER init
|
||||
self._subscribe_all_methods()
|
||||
|
||||
_LOGGER.info(
|
||||
"Xvf3800Controller initialized (VID=0x%04x PID=0x%04x)",
|
||||
self._cfg.vendor_id,
|
||||
self._cfg.product_id,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the polling thread and close HID device (if any)."""
|
||||
self._running = False
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
self._thread.join(timeout=1.0)
|
||||
if self._hid_dev is not None:
|
||||
try:
|
||||
self._hid_dev.close()
|
||||
except Exception:
|
||||
_LOGGER.exception("Error closing XVF3800 HID device")
|
||||
_LOGGER.debug("Xvf3800Controller stopped")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HID setup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _open_device(self, vendor_id: int, product_id: int):
|
||||
"""Open the first HID interface that matches the XVF3800 VID/PID.
|
||||
|
||||
If multiple HID interfaces are present, we currently pick the first
|
||||
one returned by hid.enumerate(). If this proves unreliable, we can
|
||||
refine selection using interface_number/product_string.
|
||||
"""
|
||||
devices = [
|
||||
dev
|
||||
for dev in hid.enumerate() # type: ignore[attr-defined]
|
||||
if dev.get("vendor_id") == vendor_id
|
||||
and dev.get("product_id") == product_id
|
||||
]
|
||||
|
||||
if not devices:
|
||||
_LOGGER.warning(
|
||||
"No HID interfaces found for XVF3800 (VID=0x%04x PID=0x%04x)",
|
||||
vendor_id,
|
||||
product_id,
|
||||
)
|
||||
return None
|
||||
|
||||
dev_info = devices[0]
|
||||
path = dev_info.get("path")
|
||||
_LOGGER.info(
|
||||
"Opening XVF3800 HID device: path=%r serial=%r interface=%r",
|
||||
path,
|
||||
dev_info.get("serial_number"),
|
||||
dev_info.get("interface_number"),
|
||||
)
|
||||
|
||||
dev = hid.device() # type: ignore[call-arg]
|
||||
if path is not None:
|
||||
dev.open_path(path)
|
||||
else:
|
||||
dev.open(vendor_id, product_id)
|
||||
|
||||
dev.set_nonblocking(True)
|
||||
return dev
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Polling loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
"""Background loop reading HID reports and detecting mute changes."""
|
||||
_LOGGER.debug("Xvf3800Controller polling thread started")
|
||||
|
||||
assert self._hid_dev is not None
|
||||
|
||||
while self._running and not getattr(self.state, "shutdown", False):
|
||||
try:
|
||||
data = self._hid_dev.read( # type: ignore[union-attr]
|
||||
64,
|
||||
timeout_ms=self._cfg.read_timeout_ms,
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("Error reading from XVF3800 HID device")
|
||||
break
|
||||
|
||||
if data:
|
||||
# 'data' is typically a list of ints; convert to bytes for parsing.
|
||||
report = bytes(data)
|
||||
new_state = self._parse_hw_mute_from_report(report)
|
||||
if new_state is not None and new_state != self._last_hw_mute_state:
|
||||
_LOGGER.debug(
|
||||
"XVF3800 HW mute state changed: %s -> %s",
|
||||
self._last_hw_mute_state,
|
||||
new_state,
|
||||
)
|
||||
self._last_hw_mute_state = new_state
|
||||
|
||||
# Publish standard event; MicMuteHandler will take care
|
||||
# of updating ServerState and emitting mic_muted/mic_unmuted.
|
||||
self.loop.call_soon_threadsafe(
|
||||
self.event_bus.publish,
|
||||
"set_mic_mute",
|
||||
{"state": new_state},
|
||||
)
|
||||
|
||||
time.sleep(self._cfg.poll_interval_seconds)
|
||||
|
||||
_LOGGER.debug("Xvf3800Controller polling thread exiting")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parsing & writing HID reports (placeholders)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_hw_mute_from_report(self, report: bytes) -> Optional[bool]:
|
||||
"""Extract the current mute state from a raw HID report.
|
||||
|
||||
Returns:
|
||||
True -> HW reports 'muted'
|
||||
False -> HW reports 'unmuted'
|
||||
None -> This report does not contain mute information
|
||||
|
||||
TODO: Implement this once we know the HID report layout. For now,
|
||||
it logs the report at DEBUG level and returns None.
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"XVF3800 HID report (len=%d): %s",
|
||||
len(report),
|
||||
report.hex(),
|
||||
)
|
||||
# Once we have sample outputs from xvf3800_hid_mute_probe.py,
|
||||
# implement the bit extraction here.
|
||||
return None
|
||||
|
||||
def _send_hw_mute_state(self, muted: bool) -> None:
|
||||
"""Send a HID command to set the XVF3800 mute state + LED.
|
||||
|
||||
TODO: Once we understand the HID output/report format (from docs or
|
||||
reverse engineering), construct and write the correct report.
|
||||
For now, this method just logs what it *would* do.
|
||||
"""
|
||||
if self._hid_dev is None:
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"(stub) Would send HID command to set XVF3800 mute=%s",
|
||||
muted,
|
||||
)
|
||||
# Example once we know the correct output report:
|
||||
# report_id = 0x01
|
||||
# payload = bytes([report_id, 0x01 if muted else 0x00, 0x00, 0x00, ...])
|
||||
# try:
|
||||
# self._hid_dev.write(payload)
|
||||
# except Exception:
|
||||
# _LOGGER.exception("Error writing XVF3800 HID mute command")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# EventBus subscriptions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subscribe
|
||||
def set_mic_mute(self, data: dict) -> None:
|
||||
"""Handle global 'set_mic_mute' events.
|
||||
|
||||
This is the *same* event that MQTT, ButtonController, etc. use. By
|
||||
listening here, we can sync the XVF3800 hardware state with LVA's
|
||||
logical mute state.
|
||||
|
||||
The central MicMuteHandler is still the source of truth; this
|
||||
controller should not modify ServerState directly, only attempt to
|
||||
mirror that state to the hardware.
|
||||
"""
|
||||
desired = bool(data.get("state", False))
|
||||
_LOGGER.debug(
|
||||
"Xvf3800Controller received set_mic_mute event: desired=%s",
|
||||
desired,
|
||||
)
|
||||
|
||||
# Remember the last state so we don't treat an echo from hardware as new.
|
||||
self._last_hw_mute_state = desired
|
||||
self._send_hw_mute_state(desired)
|
||||
Reference in New Issue
Block a user