Add LVA Tray Client
Provide a simple tray client to show the status of the LVA running on a Linux desktop.
This commit is contained in:
@@ -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:
|
||||||
|
# <repo_root>/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 .../<state>_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/<device>/<state>_light/state
|
||||||
|
parts = topic.split("/")
|
||||||
|
if len(parts) < 4:
|
||||||
|
return
|
||||||
|
state_part = parts[-2] # "<state>_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()
|
||||||
|
|
||||||
Reference in New Issue
Block a user