Update mqtt_controller.py

__init__ was completely rewritten to be decoupled. It now listens for mic_muted/mic_unmuted events to manage its own state instead of reading from ServerState.
This commit is contained in:
imonlinux
2025-11-17 21:50:08 -08:00
committed by GitHub
parent 37922351f6
commit f39df8a40a
+56 -44
View File
@@ -1,10 +1,13 @@
import asyncio
import json import json
import logging import logging
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List, Optional
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from .event_bus import EventHandler, subscribe from .config import MqttConfig
from .event_bus import EventBus, EventHandler, subscribe
from .models import Preferences
if TYPE_CHECKING: if TYPE_CHECKING:
from .models import ServerState from .models import ServerState
@@ -12,16 +15,29 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class MqttController(EventHandler): class MqttController(EventHandler):
def __init__(self, state: "ServerState", host: str, port: int, username: str, password: str): def __init__(
super().__init__(state) self,
self._host = host loop: asyncio.AbstractEventLoop,
self._port = port event_bus: EventBus,
self._username = username config: MqttConfig,
self._password = password app_name: str,
mac_address: str,
self._device_name = self.state.name preferences: Preferences,
self._device_id = self.state.name.lower().replace(" ", "_") ):
super().__init__(event_bus)
self.loop = loop
self.preferences = preferences
self._host = config.host
self._port = config.port
self._username = config.username
self._password = config.password
self._device_name = app_name
self._device_id = app_name.lower().replace(" ", "_")
self._topic_prefix = f"lva/{self._device_id}" self._topic_prefix = f"lva/{self._device_id}"
self._is_muted = False # Internal state
self.CONFIGURABLE_STATES: List[str] = ["idle", "listening", "thinking", "responding", "error"] self.CONFIGURABLE_STATES: List[str] = ["idle", "listening", "thinking", "responding", "error"]
self.topics = { self.topics = {
@@ -40,6 +56,9 @@ class MqttController(EventHandler):
self._client.on_connect = self._on_connect self._client.on_connect = self._on_connect
self._client.on_message = self._on_message self._client.on_message = self._on_message
# Subscribe to events *after* __init__ is complete
self._subscribe_all_methods()
def start(self): def start(self):
try: try:
if self._username: if self._username:
@@ -59,49 +78,33 @@ class MqttController(EventHandler):
def _on_connect(self, client, userdata, flags, rc): def _on_connect(self, client, userdata, flags, rc):
if rc == 0: if rc == 0:
_LOGGER.info("Connected to MQTT broker") _LOGGER.info("Connected to MQTT broker")
# Subscribe to all command topics client.subscribe(f"{self._topic_prefix}/+/set")
client.subscribe(f"{self._topic_prefix}/+/set") # Wildcard for all commands
# Subscribe to retained state topics to sync on startup
client.subscribe(f"{self._topic_prefix}/+/state") client.subscribe(f"{self._topic_prefix}/+/state")
self._publish_discovery_configs() self._publish_discovery_configs()
else: else:
_LOGGER.error("Failed to connect to MQTT, return code %d", rc) _LOGGER.error("Failed to connect to MQTT, return code %d", rc)
def _on_message(self, client, userdata, msg): def _on_message(self, client, userdata, msg):
"""
Handles incoming MQTT messages (runs in MQTT thread).
This must not block or modify shared state.
It schedules the real handler to run on the main asyncio loop.
"""
try: try:
topic = msg.topic topic = msg.topic
payload = msg.payload.decode() payload = msg.payload.decode()
_LOGGER.debug("Received MQTT message on topic %s (from MQTT thread)", msg.topic) _LOGGER.debug("Received MQTT message on topic %s (from MQTT thread)", msg.topic)
self.loop.call_soon_threadsafe(self._handle_message_on_loop, topic, payload)
# Schedule the actual processing on the main event loop
self.state.loop.call_soon_threadsafe(self._handle_message_on_loop, topic, payload)
except Exception: except Exception:
_LOGGER.exception("Error in _on_message") _LOGGER.exception("Error in _on_message")
def _handle_message_on_loop(self, topic: str, payload: str): def _handle_message_on_loop(self, topic: str, payload: str):
"""
This method runs on the main asyncio loop and safely handles
the MQTT message logic and state changes.
"""
_LOGGER.debug("Handling message for topic %s on main loop", topic) _LOGGER.debug("Handling message for topic %s on main loop", topic)
if topic == self.topics["mute"]["command"]: if topic == self.topics["mute"]["command"]:
self.state.event_bus.publish("set_mic_mute", {"state": payload.upper() == "ON"}) # Publish event for MicMuteHandler to process
self.event_bus.publish("set_mic_mute", {"state": payload.upper() == "ON"})
elif topic == self.topics["num_leds"]["command"]: elif topic == self.topics["num_leds"]["command"]:
try: try:
num_leds = int(payload) num_leds = int(payload)
self.state.event_bus.publish("set_num_leds", {"num_leds": num_leds}) # Publish event for LedController and MicMuteHandler (to save)
if self.state.preferences.num_leds != num_leds: self.event_bus.publish("set_num_leds", {"num_leds": num_leds})
self.state.preferences.num_leds = num_leds
self.state.save_preferences()
self.publish_num_leds_state(num_leds) self.publish_num_leds_state(num_leds)
except ValueError: pass except ValueError: pass
@@ -109,32 +112,29 @@ the MQTT message logic and state changes.
state_topics = self.topics[state_name] state_topics = self.topics[state_name]
if topic == state_topics["effect_command"]: if topic == state_topics["effect_command"]:
effect_id = payload.lower().replace(" ", "_") effect_id = payload.lower().replace(" ", "_")
self.state.event_bus.publish(f"set_{state_name}_effect", {"effect": effect_id}) self.event_bus.publish(f"set_{state_name}_effect", {"effect": effect_id})
elif topic == state_topics["light_command"]: elif topic == state_topics["light_command"]:
try: try:
data = json.loads(payload) data = json.loads(payload)
if "state" in data: if "state" in data:
if data["state"].upper() == "OFF": if data["state"].upper() == "OFF":
self.state.event_bus.publish(f"set_{state_name}_effect", {"effect": "off"}) self.event_bus.publish(f"set_{state_name}_effect", {"effect": "off"})
else: # ON command else: # ON command
self.state.event_bus.publish(f"turn_on_{state_name}") self.event_bus.publish(f"turn_on_{state_name}")
if "color" in data or "brightness" in data: if "color" in data or "brightness" in data:
self.state.event_bus.publish(f"set_{state_name}_color", data) self.event_bus.publish(f"set_{state_name}_color", data)
except json.JSONDecodeError: pass except json.JSONDecodeError: pass
# Handle retained state messages on startup
elif topic == state_topics["effect_state"]: elif topic == state_topics["effect_state"]:
effect_id = payload.lower().replace(" ", "_") effect_id = payload.lower().replace(" ", "_")
self.state.event_bus.publish(f"set_{state_name}_effect", {"effect": effect_id, "retained": True}) self.event_bus.publish(f"set_{state_name}_effect", {"effect": effect_id, "retained": True})
elif topic == state_topics["light_state"]: elif topic == state_topics["light_state"]:
try: try:
data = json.loads(payload) data = json.loads(payload)
data["retained"] = True data["retained"] = True
self.state.event_bus.publish(f"set_{state_name}_color", data) self.event_bus.publish(f"set_{state_name}_color", data)
except json.JSONDecodeError: pass except json.JSONDecodeError: pass
def _publish_discovery_configs(self): def _publish_discovery_configs(self):
@@ -160,10 +160,12 @@ the MQTT message logic and state changes.
_LOGGER.debug("Published all MQTT discovery configs") _LOGGER.debug("Published all MQTT discovery configs")
self._client.publish(availability_topic, "online", retain=True) self._client.publish(availability_topic, "online", retain=True)
self.publish_mute_state(self.state.mic_muted) # Publish initial state from internal values
self.publish_num_leds_state(self.state.preferences.num_leds) self.publish_mute_state(self._is_muted)
self.publish_num_leds_state(self.preferences.num_leds)
def publish_mute_state(self, is_muted: bool): def publish_mute_state(self, is_muted: bool):
self._is_muted = is_muted
self._client.publish(self.topics["mute"]["state"], "ON" if is_muted else "OFF", retain=True) self._client.publish(self.topics["mute"]["state"], "ON" if is_muted else "OFF", retain=True)
def publish_num_leds_state(self, num_leds: int): def publish_num_leds_state(self, num_leds: int):
@@ -184,3 +186,13 @@ the MQTT message logic and state changes.
"color": { "r": data.get("color")[0], "g": data.get("color")[1], "b": data.get("color")[2] } "color": { "r": data.get("color")[0], "g": data.get("color")[1], "b": data.get("color")[2] }
} }
self._client.publish(state_topics["light_state"], json.dumps(light_state), retain=True) self._client.publish(state_topics["light_state"], json.dumps(light_state), retain=True)
# --- Listen for state changes from other components ---
@subscribe
def mic_muted(self, data: dict):
self.publish_mute_state(True)
@subscribe
def mic_unmuted(self, data: dict):
self.publish_mute_state(False)