diff --git a/README.md b/README.md index 4faf492..b0f59b5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ Runs on Linux `aarch64` and `x86_64` platforms. Tested with Python 3.13 and Pyth **Confirmed working on Rasberry OS (Trixie)** Supports announcments, start/continue conversation, and timers. -See [the tutorial](docs/linux-voice-assistant-2mic-install.md) to build a satellite using a [Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) and a [ReSpeaker 2Mic HAT](https://wiki.keyestudio.com/Ks0314_keyestudio_ReSpeaker_2-Mic_Pi_HAT_V1.0). +See [the tutorial](docs/linux-voice-assistant-2mic-install.md) to build a satellite using a [Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) and a [ReSpeaker 2Mic HAT](https://wiki.keyestudio.com/Ks0314_keyestudio_ReSpeaker_2-Mic_Pi_HAT_V1.0). + +Want to run the satellite on a Linux desktop using a simple tray client? See [this tutorial](docs/lva-desktop.md). ### What's working: - This fork is from https://github.com/OHF-Voice/linux-voice-assistant Release v1.0.0 which introduces the ability to use both MicroWakeWord and OpenWakeWord detections models. diff --git a/docs/lva-desktop.md b/docs/lva-desktop.md new file mode 100644 index 0000000..4829b6f --- /dev/null +++ b/docs/lva-desktop.md @@ -0,0 +1,158 @@ +# Want to run LVA on a Linux Graphical Desktop? + +### 1. Install the dependancies: + +### Debian based distro: + +```bash +sudo apt update +sudo apt install \ + libmpv-dev git python3-dev +``` + +### Fedora based distro: + +```bash +sudo dnf install \ + mpv-devel python3-devel git +``` + +### Arch based distro: + +```bash +sudo pacman -Syu \ + python-pip python-virtualenv +``` + +### 2. Open Firewall for LVA: + +### UFW: + +```bash +sudo ufw allow 6053/udp +sudo ufw allow 6053/tcp +sudo ufw reload +``` + +### Firewall-CMD: + +```bash +sudo firewall-cmd --permanent --zone=public --add-port=6053/tcp +sudo firewall-cmd --permanent --zone=public --add-port=6053/udp +sudo firewall-cmd --reload +``` + +### 3. Get the code: + +```bash +git clone https://github.com/imonlinux/linux-voice-assistant.git +``` + +### 4. Setup Linux Voice Assistant (LVA): + +```bash +cd linux-voice-assistant/ +script/setup +``` + +### 5. Modify config.json File: + +```bash +nano linux_voice_assistant/config.json +``` + +Change the LVA name and add the details for MQTT (MQTT is required for the LVA Tray Client) +***Update the MQTT details to match your system!*** + +```bash +{ + "app": { + "name": "Debian Voice Assistant" + }, + "mqtt": { + "host": "192.168.1.2", + "port": 1883, + "username": "mqtt-user", + "password": "mqtt-password" + } +} +``` + +### 6. Create LVA User Mode Systemd Unit file: + +```bash +mkdir -p ~/.config/systemd/user + +cp service/linux-voice-assistant.service ~/.config/systemd/user/linux-voice-assistant.service + +``` + +### Edit the LVA Unit file for correct path: + +```bash +systemctl --user edit --force --full linux-voice-assistant.service +``` +### Enter the correct path for WorkingDirectory and ExecStart + +```bash +[Unit] +Description=Linux Voice Assistant + +[Service] +Type=simple +WorkingDirectory=/home/pi/linux-voice-assistant +Environment=PYTHONUNBUFFERED=1 + +ExecStart=/home/pi/linux-voice-assistant/script/run + +Restart=always +RestartSec=2 + +[Install] +WantedBy=default.target +``` + +### 7. Create the LVA Tray Client User Mode Systemd Unit file: + +```bash +cp service/linux-voice-assistant-tray.service ~/.config/systemd/user/linux-voice-assistant-tray.service +``` + +### Edit the LVA Tray Unit file for correct path: + +```bash +systemctl --user edit --force --full linux-voice-assistant-tray.service +``` + +### Enter the correct path for WorkingDirectory and ExecStart + +```bash +[Unit] +Description=Linux Voice Assistant Tray Client +# Make sure we have a graphical session +After=graphical-session.target +Wants=graphical-session.target + +[Service] +Type=simple +WorkingDirectory=/home/james/Workbench/linux-voice-assistant +ExecStart=/home/james/Workbench/linux-voice-assistant/script/tray +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=default.target +``` + +### Start only the LVA Tray Application + +```bash +systemctl --user daemon-reload +systemctl --user enable --now linux-voice-assistant-tray.service +``` + +This will add an icon to your taskbar. Click on the icon and select "Start LVA". + +Follow the instructions in HA to register the LVA. + +Use the MQTT device that is created in HA to adjust the icon color for each of the LVA states. diff --git a/lva_tray_client.py b/lva_tray_client.py new file mode 100644 index 0000000..39a5fd9 --- /dev/null +++ b/lva_tray_client.py @@ -0,0 +1,499 @@ +#!/usr/bin/env python3 +""" +LVA Tray Client + +- Reads linux_voice_assistant/config.json to discover: + - app.name -> used for device_id + - mqtt.host / port / username / password +- Subscribes to the LVA's MQTT topics to: + - Track availability (online/offline) + - Track mute state + - Track per-state LED color (idle/listening/thinking/responding/error) +- Shows a system tray icon whose color matches the current LVA state. +- Provides a tray menu to start/stop/restart the LVA systemd --user service + and toggle microphone mute. + +Idle state now **also uses the MQTT color**, not a fixed purple. +""" + +import argparse +import json +import logging +import os +import subprocess +import sys +from pathlib import Path +from typing import Dict, Optional + +import paho.mqtt.client as mqtt +from PyQt5 import QtCore, QtGui, QtWidgets + +_LOGGER = logging.getLogger("lva_tray_client") + +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- + + +def slugify_device_id(name: str) -> str: + """Convert display name to a consistent device_id.""" + return name.strip().lower().replace(" ", "_") + + +def load_config(config_path: Optional[Path]) -> dict: + """Load LVA config.json.""" + if config_path is None: + # Default: same layout you’re using now: + # /linux_voice_assistant/config.json + base = Path(__file__).resolve().parent + config_path = base / "linux_voice_assistant" / "config.json" + + _LOGGER.info("Using config file: %s", config_path) + with config_path.open("r", encoding="utf-8") as f: + cfg = json.load(f) + + app_cfg = cfg.get("app", {}) + mqtt_cfg = cfg.get("mqtt", {}) + + name = app_cfg.get("name", "Linux Voice Assistant") + device_id = slugify_device_id(name) + + host = mqtt_cfg.get("host", "127.0.0.1") + port = int(mqtt_cfg.get("port", 1883)) + username = mqtt_cfg.get("username") or None + password = mqtt_cfg.get("password") or None + + cfg["_resolved"] = { + "name": name, + "device_id": device_id, + "mqtt_enabled": True, + "host": host, + "port": port, + "username": username, + "password": password, + } + + _LOGGER.info( + "Loaded config: name=%s device_id=%s mqtt_enabled=%s host=%s port=%s", + name, + device_id, + True, + host, + port, + ) + return cfg + + +# --------------------------------------------------------------------------- +# Tray client +# --------------------------------------------------------------------------- + + +class LvaTrayClient(QtWidgets.QSystemTrayIcon): + """System tray integration for the Linux Voice Assistant.""" + + STATES = ["idle", "listening", "thinking", "responding", "error"] + + def __init__(self, app: QtWidgets.QApplication, cfg: dict): + # QSystemTrayIcon init + super().__init__(parent=None) + self._app = app + + resolved = cfg["_resolved"] + self._device_name = resolved["name"] + self._device_id = resolved["device_id"] + self._mqtt_host = resolved["host"] + self._mqtt_port = resolved["port"] + self._mqtt_username = resolved["username"] + self._mqtt_password = resolved["password"] + + self._topic_prefix = f"lva/{self._device_id}" + + # Current state + self._available: bool = False + self._muted: bool = False + self._current_state: str = "idle" + + # Default colors per state (fallbacks) + self._default_colors: Dict[str, QtGui.QColor] = { + "idle": QtGui.QColor(128, 0, 255), # purple + "listening": QtGui.QColor(0, 0, 255), # blue + "thinking": QtGui.QColor(255, 255, 0), # yellow + "responding": QtGui.QColor(0, 255, 0), # green + "error": QtGui.QColor(255, 165, 0), # orange + } + # Last MQTT-derived color per state (idle included!) + self._last_color_by_state: Dict[str, QtGui.QColor] = dict( + self._default_colors + ) + + # Build context menu + self._build_menu() + + # Initial icon: offline + self._set_icon_by_key("offline") + + # MQTT setup + self._client = mqtt.Client() + if self._mqtt_username: + self._client.username_pw_set(self._mqtt_username, self._mqtt_password) + + self._client.on_connect = self._on_connect + self._client.on_message = self._on_message + self._client.on_disconnect = self._on_disconnect + + _LOGGER.debug( + "MQTT connecting to %s:%s", self._mqtt_host, self._mqtt_port + ) + try: + self._client.connect(self._mqtt_host, self._mqtt_port, 60) + self._client.loop_start() + except Exception: # noqa: BLE001 + _LOGGER.exception("Failed to connect to MQTT broker") + + # Show tray icon + self.setVisible(True) + _LOGGER.info( + "LVA Tray Client started for device_id=%s", self._device_id + ) + + # ------------------------------------------------------------------ + # Menu / actions + # ------------------------------------------------------------------ + + def _build_menu(self): + menu = QtWidgets.QMenu() + + # Status label + self._status_action = QtWidgets.QAction("LVA Tray Client", self) + self._status_action.setEnabled(False) + menu.addAction(self._status_action) + menu.addSeparator() + + # Service control + start_action = QtWidgets.QAction("Start LVA", self) + start_action.triggered.connect(self._start_lva) + menu.addAction(start_action) + + stop_action = QtWidgets.QAction("Stop LVA", self) + stop_action.triggered.connect(self._stop_lva) + menu.addAction(stop_action) + + restart_action = QtWidgets.QAction("Restart LVA", self) + restart_action.triggered.connect(self._restart_lva) + menu.addAction(restart_action) + + menu.addSeparator() + + # Mute toggle + self._mute_action = QtWidgets.QAction("Mute Microphone", self) + self._mute_action.setCheckable(True) + self._mute_action.triggered.connect(self._toggle_mute) + menu.addAction(self._mute_action) + + menu.addSeparator() + + # Quit + quit_action = QtWidgets.QAction("Quit Tray Client", self) + quit_action.triggered.connect(self._quit) + menu.addAction(quit_action) + + self.setContextMenu(menu) + + def _start_lva(self): + _LOGGER.info("Running: systemctl --user start linux-voice-assistant.service") + subprocess.run( + ["systemctl", "--user", "start", "linux-voice-assistant.service"], + check=False, + ) + + def _stop_lva(self): + _LOGGER.info("Running: systemctl --user stop linux-voice-assistant.service") + subprocess.run( + ["systemctl", "--user", "stop", "linux-voice-assistant.service"], + check=False, + ) + + def _restart_lva(self): + _LOGGER.info("Running: systemctl --user restart linux-voice-assistant.service") + subprocess.run( + ["systemctl", "--user", "restart", "linux-voice-assistant.service"], + check=False, + ) + + def _toggle_mute(self, checked: bool): + self._mute_action.setChecked(checked) + self._muted = checked + # Publish to LVA + topic = f"{self._topic_prefix}/mute/set" + payload = "ON" if checked else "OFF" + _LOGGER.debug("Publishing mute command: %s -> %s", topic, payload) + try: + self._client.publish(topic, payload, retain=False) + except Exception: # noqa: BLE001 + _LOGGER.exception("Failed to publish mute command") + # Update local icon + self._update_tray_icon() + + def _quit(self): + try: + self._client.loop_stop() + self._client.disconnect() + except Exception: # noqa: BLE001 + pass + self._app.quit() + + # ------------------------------------------------------------------ + # MQTT callbacks + # ------------------------------------------------------------------ + + def _on_connect(self, client, userdata, flags, rc): # noqa: ARG002 + if rc == 0: + _LOGGER.info("Connected to MQTT broker (tray)") + # Subscribe to availability + mute + all LVA topics (for LED states, num_leds, etc.) + client.subscribe(f"{self._topic_prefix}/availability") + client.subscribe(f"{self._topic_prefix}/mute/state") + client.subscribe(f"{self._topic_prefix}/#") + else: + _LOGGER.error("Failed to connect to MQTT, return code %d", rc) + + def _on_disconnect(self, client, userdata, rc): # noqa: ARG002 + _LOGGER.warning("MQTT disconnected (rc=%s)", rc) + self._available = False + self._update_tray_icon() + + def _on_message(self, client, userdata, msg): # noqa: ARG002 + try: + topic = msg.topic + payload = msg.payload.decode() + _LOGGER.debug( + "MQTT message: topic=%s payload=%s", topic, payload + ) + + # availability + if topic == f"{self._topic_prefix}/availability": + self._handle_availability(payload) + return + + # mute state + if topic == f"{self._topic_prefix}/mute/state": + self._handle_mute_state(payload) + return + + # effect state (we don't currently use it for color, but log it) + if topic.endswith("_effect/state"): + return + + # light state + if topic.endswith("_light/state"): + self._handle_light_state(topic, payload) + return + + except Exception: # noqa: BLE001 + _LOGGER.exception("Error in _on_message") + + # ------------------------------------------------------------------ + # MQTT handlers + # ------------------------------------------------------------------ + + def _handle_availability(self, payload: str): + online = payload.strip().lower() == "online" + _LOGGER.debug("Availability update: %s", payload) + self._available = online + self._update_tray_icon() + + def _handle_mute_state(self, payload: str): + new_muted = payload.strip().upper() == "ON" + _LOGGER.debug("Mute update: %s", new_muted) + self._muted = new_muted + # Reflect in menu + self._mute_action.setChecked(self._muted) + self._update_tray_icon() + + def _handle_light_state(self, topic: str, payload: str): + """ + Handle JSON from .../_light/state + Example payload: + {"state": "ON", "brightness": 127, + "color": {"r": 0, "g": 0, "b": 255}} + """ + try: + data = json.loads(payload) + except json.JSONDecodeError: + _LOGGER.warning("Invalid JSON on light state: %s", payload) + return + + # Extract state name from topic + # lva//_light/state + parts = topic.split("/") + if len(parts) < 4: + return + state_part = parts[-2] # "_light" + if not state_part.endswith("_light"): + return + state_name = state_part[: -len("_light")] + if state_name not in self.STATES: + return + + _LOGGER.debug( + "Decoded light state: state_name=%s data=%s", state_name, data + ) + + state_flag = data.get("state", "OFF").upper() + color_dict = data.get("color", {}) or {} + brightness = int(data.get("brightness", 255)) + brightness = max(0, min(brightness, 255)) + + r = int(color_dict.get("r", 0)) + g = int(color_dict.get("g", 0)) + b = int(color_dict.get("b", 0)) + + # Apply brightness scaling to the MQTT color + scale = brightness / 255.0 if brightness > 0 else 0.0 + r_scaled = max(0, min(255, int(r * scale))) + g_scaled = max(0, min(255, int(g * scale))) + b_scaled = max(0, min(255, int(b * scale))) + + qcolor = QtGui.QColor(r_scaled, g_scaled, b_scaled) + self._last_color_by_state[state_name] = qcolor + + # State transitions: + # - For non-idle: treat "ON" as "this is the active state" + # - For idle: **always** treat updates as "idle is the current baseline" + if state_name == "idle": + _LOGGER.debug( + "Tray state_update (idle): data=%s (using MQTT color for idle)", + data, + ) + self._current_state = "idle" + else: + if state_flag == "ON": + _LOGGER.debug( + "Tray state_update: state_name=%s data=%s", + state_name, + data, + ) + if self._current_state != state_name: + _LOGGER.debug( + "Tray state changing: %s -> %s", + self._current_state, + state_name, + ) + self._current_state = state_name + else: + # Ignore OFF for non-idle states + _LOGGER.debug( + "Ignoring OFF for non-idle state %s", state_name + ) + + self._update_tray_icon() + + # ------------------------------------------------------------------ + # Icon rendering + # ------------------------------------------------------------------ + + def _set_icon_by_key(self, key: str): + """ + Convenience wrapper to set icon for: + - 'offline' + - any state name in STATES + """ + if key == "offline": + color = QtGui.QColor(128, 128, 128) # grey + tooltip_state = "offline" + else: + color = self._last_color_by_state.get( + key, self._default_colors.get(key, QtGui.QColor(128, 128, 128)) + ) + tooltip_state = key + + # If muted, tint red (but keep some info from base color) + if self._muted and key != "offline": + base = color + color = QtGui.QColor( + min(255, base.red() + 120), + max(0, int(base.green() * 0.4)), + max(0, int(base.blue() * 0.4)), + ) + tooltip_state += " (muted)" + + icon = self._make_circle_icon(color) + self.setIcon(icon) + + # Update tooltip + tip = f"{self._device_name} – {tooltip_state}" + self.setToolTip(tip) + if self.contextMenu(): + self._status_action.setText(tip) + + _LOGGER.debug( + "Setting tray icon: key=%s available=%s muted=%s", key, self._available, self._muted + ) + + def _make_circle_icon(self, color: QtGui.QColor) -> QtGui.QIcon: + size = 20 + pixmap = QtGui.QPixmap(size, size) + pixmap.fill(QtCore.Qt.transparent) + + painter = QtGui.QPainter(pixmap) + painter.setRenderHint(QtGui.QPainter.Antialiasing, True) + + pen = QtGui.QPen(QtGui.QColor(0, 0, 0, 160)) + pen.setWidth(1) + painter.setPen(pen) + painter.setBrush(QtGui.QBrush(color)) + + radius = size // 2 - 2 + painter.drawEllipse(2, 2, radius * 2, radius * 2) + painter.end() + + return QtGui.QIcon(pixmap) + + def _update_tray_icon(self): + if not self._available: + self._set_icon_by_key("offline") + else: + self._set_icon_by_key(self._current_state) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main(argv=None): + parser = argparse.ArgumentParser(description="LVA Tray Client") + parser.add_argument( + "--config", + type=str, + help="Path to LVA config.json (defaults to linux_voice_assistant/config.json)", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug logging", + ) + + args = parser.parse_args(argv) + + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + config_path = Path(args.config) if args.config else None + cfg = load_config(config_path) + + # Make Qt app + app = QtWidgets.QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + + tray = LvaTrayClient(app, cfg) + + # Start event loop + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() + diff --git a/script/tray b/script/tray new file mode 100644 index 0000000..bb36477 --- /dev/null +++ b/script/tray @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Resolve repo root (one level up from this script) +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Activate venv if present +if [[ -d "$ROOT_DIR/.venv" ]]; then + # shellcheck disable=SC1090 + source "$ROOT_DIR/.venv/bin/activate" +fi + +cd "$ROOT_DIR" + +# Run the tray client +exec python lva_tray_client.py "$@" +