From a0059583175b67021e165b3291a9bf149602b83a Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <fabio@manganiello.tech>
Date: Mon, 23 Oct 2023 16:02:05 +0200
Subject: [PATCH] [Assistant] Added `assistant` entity type.

---
 .../backend/http/webapp/src/assets/icons.json |   3 +
 .../src/components/elements/TextPrompt.vue    | 107 ++++++++
 .../components/panels/Entities/Assistant.vue  | 250 ++++++++++++++++++
 .../src/components/panels/Entities/meta.json  |   8 +
 platypush/entities/assistants.py              |  29 ++
 platypush/entities/managers/assistants.py     |  78 ++++++
 platypush/plugins/assistant/__init__.py       | 222 +++++++++++++++-
 .../plugins/assistant/google/__init__.py      | 116 +++-----
 8 files changed, 725 insertions(+), 88 deletions(-)
 create mode 100644 platypush/backend/http/webapp/src/components/elements/TextPrompt.vue
 create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/Assistant.vue
 create mode 100644 platypush/entities/assistants.py
 create mode 100644 platypush/entities/managers/assistants.py

diff --git a/platypush/backend/http/webapp/src/assets/icons.json b/platypush/backend/http/webapp/src/assets/icons.json
index 6b7d050ca..3a481c13a 100644
--- a/platypush/backend/http/webapp/src/assets/icons.json
+++ b/platypush/backend/http/webapp/src/assets/icons.json
@@ -2,6 +2,9 @@
   "arduino": {
     "class": "fas fa-microchip"
   },
+  "assistant.google": {
+    "class": "fas fa-microphone-lines"
+  },
   "bluetooth": {
     "class": "fab fa-bluetooth"
   },
diff --git a/platypush/backend/http/webapp/src/components/elements/TextPrompt.vue b/platypush/backend/http/webapp/src/components/elements/TextPrompt.vue
new file mode 100644
index 000000000..e68aee55b
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/elements/TextPrompt.vue
@@ -0,0 +1,107 @@
+<template>
+  <Modal ref="modal" :title="title">
+    <form @submit.prevent="onConfirm">
+      <div class="dialog-content">
+        <slot />
+        <input type="text" ref="input" />
+      </div>
+
+      <div class="buttons">
+        <button type="submit" class="ok-btn" @click="onConfirm" @touch="onConfirm">
+          <i class="fas fa-check" /> &nbsp; {{ confirmText }}
+        </button>
+        <button type="button" class="cancel-btn" @click="close" @touch="close">
+          <i class="fas fa-xmark" /> &nbsp; {{ cancelText }}
+        </button>
+      </div>
+    </form>
+  </Modal>
+</template>
+
+<script>
+import Modal from "@/components/Modal";
+
+export default {
+  emits: ['input'],
+  components: {Modal},
+  props: {
+    title: {
+      type: String,
+    },
+
+    confirmText: {
+      type: String,
+      default: "OK",
+    },
+
+    cancelText: {
+      type: String,
+      default: "Cancel",
+    },
+  },
+
+  methods: {
+    onConfirm() {
+      this.$emit('input', this.$refs.input.value)
+      this.close()
+    },
+
+    show() {
+      this.$refs.modal.show()
+    },
+
+    close() {
+      this.$refs.modal.hide()
+    },
+  },
+
+  mounted() {
+    this.$nextTick(() => {
+      this.$refs.input.value = ""
+      this.$refs.input.focus()
+    })
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+:deep(.modal) {
+  .dialog-content {
+    padding: 1em;
+  }
+
+  .body {
+    padding: 1.5em;
+  }
+
+  .buttons {
+    display: flex;
+    flex-direction: row;
+    justify-content: right;
+    margin-bottom: 1em;
+    border: 0;
+    border-radius: 0;
+
+    button {
+      margin-right: 1em;
+      padding: 0.5em 1em;
+      border: 1px solid $border-color-2;
+      border-radius: 1em;
+
+      &:hover {
+        background: $hover-bg;
+      }
+    }
+  }
+}
+
+form {
+  display: flex;
+  flex-direction: column !important;
+
+  .buttons {
+    flex-direction: row !important;
+  }
+}
+
+</style>
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Assistant.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Assistant.vue
new file mode 100644
index 000000000..90818d82f
--- /dev/null
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/Assistant.vue
@@ -0,0 +1,250 @@
+<template>
+  <div class="entity assistant-container">
+    <TextPrompt ref="prompt">
+      Enter a text query to send to the assistant.
+    </TextPrompt>
+
+    <div class="head" @click="onHeadClick">
+      <div class="col-1 icon entity-icon" ref="icon">
+        <EntityIcon
+          :entity="value"
+          :class="{active: value.conversation_running}"
+          :loading="loading"
+          :error="error" />
+      </div>
+
+      <div class="label">
+        <div class="name" ref="name" v-text="value.name" />
+      </div>
+
+      <div class="value-container">
+        <button @click.stop="collapsed = !collapsed">
+          <i class="fas"
+            :class="{'fa-angle-up': !collapsed, 'fa-angle-down': collapsed}" />
+        </button>
+      </div>
+    </div>
+
+    <div class="body" ref="body" v-if="!collapsed" @click.stop="prevent">
+      <div class="row" @click.stop="stopConversation" v-if="value.conversation_running">
+        <div class="icon">
+          <i class="fas fa-comment-slash" />
+        </div>
+        <div class="label">
+          <div class="name">Stop Conversation</div>
+        </div>
+        <div class="value">
+          <ToggleSwitch
+            @click.stop="stopConversation"
+            :value="false"
+            :disabled="loading" />
+        </div>
+      </div>
+
+      <div class="row" @click.stop="startConversation" v-else>
+        <div class="icon">
+          <i class="fas fa-comment" />
+        </div>
+        <div class="label">
+          <div class="name">Start Conversation</div>
+        </div>
+        <div class="value">
+          <ToggleSwitch
+            @click.stop="startConversation"
+            :value="false"
+            :disabled="loading" />
+        </div>
+      </div>
+
+      <div class="row" @click.stop="toggleMute">
+        <div class="icon">
+          <i class="fas fa-microphone-lines-slash" />
+        </div>
+        <div class="label">
+          <div class="name">Muted</div>
+        </div>
+        <div class="value">
+          <ToggleSwitch
+            @click.stop="toggleMute"
+            :value="value.is_muted"
+            :disabled="loading" />
+        </div>
+      </div>
+
+      <div class="row" @click.stop="showPrompt">
+        <div class="icon">
+          <i class="fas fa-keyboard" />
+        </div>
+        <div class="label">
+          <div class="name">Send query from text prompt</div>
+        </div>
+        <div class="value">
+          <ToggleSwitch
+            @click.stop="showPrompt"
+            :value="false"
+            :disabled="loading" />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import TextPrompt from "@/components/elements/TextPrompt"
+import ToggleSwitch from "@/components/elements/ToggleSwitch"
+import EntityIcon from "./EntityIcon"
+import EntityMixin from "./EntityMixin"
+
+export default {
+  name: 'Assistant',
+  mixins: [EntityMixin],
+  components: {
+    EntityIcon,
+    TextPrompt,
+    ToggleSwitch,
+  },
+
+  data() {
+    return {
+      collapsed: true,
+      showTextQueryPrompt: false,
+      modalId: 'assistant-text-prompt-modal',
+    }
+  },
+
+  methods: {
+    hidePrompt() {
+      document.body.querySelector(`#${this.modalId}`)?.remove()
+    },
+
+    showPrompt() {
+      const modalElement = this.$refs.prompt.$el
+      this.hidePrompt()
+
+      modalElement.id = this.modalId
+      modalElement.classList.remove('hidden')
+
+      const input = modalElement.querySelector('input[type="text"]')
+      const form = modalElement.querySelector('form')
+      if (form) {
+        form.addEventListener('submit', (event) => {
+          event.stopPropagation()
+          this.onTextPrompt(input?.value)
+        })
+      }
+
+      const cancelBtn = modalElement.querySelector('.cancel-btn')
+      if (cancelBtn) {
+        cancelBtn.onclick = (event) => {
+          this.hidePrompt()
+          event.stopPropagation()
+        }
+      }
+
+      modalElement.onclick = (event) => {
+        const modalContent = modalElement.querySelector('.modal')
+        if (modalContent?.contains(event.target)) {
+          event.stopPropagation()
+          return false
+        }
+
+        this.hidePrompt()
+      }
+
+      document.body.appendChild(modalElement)
+      this.$nextTick(() => {
+        modalElement.querySelector('input[type="text"]').focus()
+      })
+    },
+
+    onHeadClick(event) {
+      if (
+        this.$refs.name.contains(event.target) ||
+        this.$refs.icon.contains(event.target)
+      ) {
+        // Propagate the event upwards and let it open the entity modal
+        return true
+      }
+
+      // Toggle the collapse state if the click is outside of the entity
+      // name/icon
+      this.collapsed = !this.collapsed
+      event.stopPropagation()
+    },
+
+    async toggleMute() {
+      await this.request('entities.execute', {
+        id: this.value.id,
+        action: 'toggle_mute',
+      })
+    },
+
+    async startConversation() {
+      await this.request('entities.execute', {
+        id: this.value.id,
+        action: 'start_conversation',
+      })
+    },
+
+    async stopConversation() {
+      await this.request('entities.execute', {
+        id: this.value.id,
+        action: 'stop_conversation',
+      })
+    },
+
+    async onTextPrompt(query) {
+      await this.request('entities.execute', {
+        id: this.value.id,
+        action: 'send_text_query',
+        query: query,
+      })
+
+      this.hidePrompt()
+    },
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+@import "common";
+
+$icon-size: 2em;
+
+.assistant-container {
+  .body {
+    padding: 0;
+  }
+
+  .row {
+    margin: 0;
+    padding: 1em 0.5em;
+    display: flex;
+
+    &:hover {
+      background: $hover-bg;
+    }
+
+    &:not(:last-child) {
+      border-bottom: 1px solid $border-color-1;
+    }
+
+    .icon {
+      flex: 0 0 $icon-size;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    .label {
+      width: calc(100% - $icon-size);
+    }
+  }
+
+  :deep(.entity-icon) {
+    .active {
+      color: $selected-fg;
+    }
+  }
+}
+</style>
diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/meta.json b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json
index 41a050a03..fb7fd0465 100644
--- a/platypush/backend/http/webapp/src/components/panels/Entities/meta.json
+++ b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json
@@ -1,4 +1,12 @@
 {
+  "assistant": {
+    "name": "Assistant",
+    "name_plural": "Assistants",
+    "icon": {
+      "class": "fas fa-microphone-lines"
+    }
+  },
+
   "battery": {
     "name": "Battery",
     "name_plural": "Batteries",
diff --git a/platypush/entities/assistants.py b/platypush/entities/assistants.py
new file mode 100644
index 000000000..db7bcff2a
--- /dev/null
+++ b/platypush/entities/assistants.py
@@ -0,0 +1,29 @@
+from sqlalchemy import Column, Integer, ForeignKey, Boolean, String
+
+from platypush.common.db import is_defined
+
+from . import Entity
+
+
+if not is_defined('assistant'):
+
+    class Assistant(Entity):
+        """
+        Base class for voice assistant entities.
+        """
+
+        __tablename__ = 'assistant'
+
+        id = Column(
+            Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True
+        )
+        last_query = Column(String)
+        last_response = Column(String)
+        conversation_running = Column(Boolean)
+        is_muted = Column(Boolean, default=False)
+        is_detecting = Column(Boolean, default=True)
+
+        __table_args__ = {'extend_existing': True}
+        __mapper_args__ = {
+            'polymorphic_identity': __tablename__,
+        }
diff --git a/platypush/entities/managers/assistants.py b/platypush/entities/managers/assistants.py
new file mode 100644
index 000000000..ae4128374
--- /dev/null
+++ b/platypush/entities/managers/assistants.py
@@ -0,0 +1,78 @@
+from abc import ABC, abstractmethod
+
+from . import EntityManager
+
+
+class AssistantEntityManager(EntityManager, ABC):
+    """
+    Base class for voice assistant integrations that support entity management.
+    """
+
+    @abstractmethod
+    def start_conversation(self, *args, **kwargs):
+        """
+        Programmatically starts a conversation.
+        """
+        raise NotImplementedError()
+
+    @abstractmethod
+    def stop_conversation(self, *args, **kwargs):
+        """
+        Programmatically stops a conversation.
+        """
+        raise NotImplementedError()
+
+    @abstractmethod
+    def is_muted(self, *args, **kwargs) -> bool:
+        """
+        :return: True if the microphone is muted, False otherwise.
+        """
+        raise NotImplementedError()
+
+    @abstractmethod
+    def mute(self, *args, **kwargs):
+        """
+        Mute the microphone.
+        """
+        raise NotImplementedError()
+
+    @abstractmethod
+    def unmute(self, *args, **kwargs):
+        """
+        Unmute the microphone.
+        """
+        raise NotImplementedError()
+
+    def toggle_mute(self, *_, **__):
+        """
+        Toggle the mute state of the microphone.
+        """
+        return self.mute() if self.is_muted() else self.unmute()
+
+    @abstractmethod
+    def pause_detection(self, *args, **kwargs):
+        """
+        Put the assistant on pause. No new conversation events will be triggered.
+        """
+        raise NotImplementedError()
+
+    @abstractmethod
+    def resume_detection(self, *args, **kwargs):
+        """
+        Resume the assistant hotword detection from a paused state.
+        """
+        raise NotImplementedError()
+
+    @abstractmethod
+    def is_detecting(self, *args, **kwargs) -> bool:
+        """
+        :return: True if the asistant is detecting, False otherwise.
+        """
+        raise NotImplementedError()
+
+    @abstractmethod
+    def send_text_query(self, *args, **kwargs):
+        """
+        Send a text query to the assistant.
+        """
+        raise NotImplementedError()
diff --git a/platypush/plugins/assistant/__init__.py b/platypush/plugins/assistant/__init__.py
index 40f35d32b..6291b1116 100644
--- a/platypush/plugins/assistant/__init__.py
+++ b/platypush/plugins/assistant/__init__.py
@@ -1,21 +1,72 @@
 from abc import ABC, abstractmethod
+from dataclasses import asdict, dataclass
+from enum import Enum
+import os
 from threading import Event
-from typing import Any, Dict, Optional
+from typing import Any, Collection, Dict, Optional
 
-from platypush.context import get_plugin
+from platypush.context import get_bus, get_plugin
+from platypush.entities.assistants import Assistant
+from platypush.entities.managers.assistants import AssistantEntityManager
+from platypush.message.event.assistant import (
+    AssistantEvent,
+    ConversationStartEvent,
+    ConversationEndEvent,
+    ConversationTimeoutEvent,
+    ResponseEvent,
+    NoResponseEvent,
+    SpeechRecognizedEvent,
+    AlarmStartedEvent,
+    AlarmEndEvent,
+    TimerStartedEvent,
+    TimerEndEvent,
+    AlertStartedEvent,
+    AlertEndEvent,
+    MicMutedEvent,
+    MicUnmutedEvent,
+)
 from platypush.plugins import Plugin, action
+from platypush.utils import get_plugin_name_by_class
 
 
-class AssistantPlugin(ABC, Plugin):
+@dataclass
+class AlertType(Enum):
+    """
+    Enum representing the type of an alert.
+    """
+
+    ALARM = 'alarm'
+    TIMER = 'timer'
+    ALERT = 'alert'
+
+
+@dataclass
+class AssistantState:
+    """
+    Dataclass representing the state of an assistant.
+    """
+
+    last_query: Optional[str] = None
+    last_response: Optional[str] = None
+    conversation_running: bool = False
+    is_muted: bool = False
+    is_detecting: bool = True
+    alert_state: Optional[str] = None
+
+
+class AssistantPlugin(Plugin, AssistantEntityManager, ABC):
     """
     Base class for assistant plugins.
     """
 
+    _entity_name = 'Assistant'
+
     def __init__(
         self,
         *args,
         tts_plugin: Optional[str] = None,
         tts_plugin_args: Optional[Dict[str, Any]] = None,
+        conversation_start_sound: Optional[str] = None,
         **kwargs
     ):
         """
@@ -25,11 +76,37 @@ class AssistantPlugin(ABC, Plugin):
 
         :param tts_plugin_args: Optional arguments to be passed to the TTS
             ``say`` action, if ``tts_plugin`` is set.
+
+        :param conversation_start_sound: If set, the assistant will play this
+            audio file when it detects a speech. The sound file will be played
+            on the default audio output device. If not set, the assistant won't
+            play any sound when it detects a speech.
         """
         super().__init__(*args, **kwargs)
         self.tts_plugin = tts_plugin
         self.tts_plugin_args = tts_plugin_args or {}
+        if conversation_start_sound:
+            self._conversation_start_sound = os.path.abspath(
+                os.path.expanduser(conversation_start_sound)
+            )
+
         self._detection_paused = Event()
+        self._conversation_running = Event()
+        self._is_muted = False
+        self._last_query: Optional[str] = None
+        self._last_response: Optional[str] = None
+        self._cur_alert_type: Optional[AlertType] = None
+
+    @property
+    def _state(self) -> AssistantState:
+        return AssistantState(
+            last_query=self._last_query,
+            last_response=self._last_response,
+            conversation_running=self._conversation_running.is_set(),
+            is_muted=self._is_muted,
+            is_detecting=not self._detection_paused.is_set(),
+            alert_state=self._cur_alert_type.value if self._cur_alert_type else None,
+        )
 
     @abstractmethod
     def start_conversation(self, *_, **__):
@@ -46,31 +123,164 @@ class AssistantPlugin(ABC, Plugin):
         raise NotImplementedError
 
     @action
-    def pause_detection(self):
+    def pause_detection(self, *_, **__):
         """
         Put the assistant on pause. No new conversation events will be triggered.
         """
         self._detection_paused.set()
 
     @action
-    def resume_detection(self):
+    def resume_detection(self, *_, **__):
         """
         Resume the assistant hotword detection from a paused state.
         """
         self._detection_paused.clear()
 
     @action
-    def is_detecting(self) -> bool:
+    def is_detecting(self, *_, **__) -> bool:
         """
         :return: True if the asistant is detecting, False otherwise.
         """
         return not self._detection_paused.is_set()
 
+    @action
+    def is_muted(self, *_, **__) -> bool:
+        """
+        :return: True if the microphone is muted, False otherwise.
+        """
+        return self._is_muted
+
+    @action
+    def status(self, *_, **__):
+        """
+        :return: The current assistant status:
+
+            .. code-block:: json
+
+                {
+                    "last_query": "What time is it?",
+                    "last_response": "It's 10:30 AM",
+                    "conversation_running": true,
+                    "is_muted": false,
+                    "is_detecting": true
+                }
+
+        """
+        self.publish_entities([self])
+        return asdict(self._state)
+
     def _get_tts_plugin(self):
         if not self.tts_plugin:
             return None
 
         return get_plugin(self.tts_plugin)
 
+    def _play_conversation_start_sound(self):
+        if not self._conversation_start_sound:
+            return
+
+        audio = get_plugin('sound')
+        if not audio:
+            self.logger.warning(
+                'Unable to play conversation start sound: sound plugin not found'
+            )
+            return
+
+        audio.play(self._conversation_start_sound)
+
+    def _send_event(self, event: AssistantEvent):
+        self.publish_entities([self])
+        get_bus().post(event)
+
+    def _on_conversation_start(self):
+        self._last_response = None
+        self._last_query = None
+        self._conversation_running.set()
+        self._send_event(ConversationStartEvent(assistant=self))
+        self._play_conversation_start_sound()
+
+    def _on_conversation_end(self):
+        self._conversation_running.clear()
+        self._send_event(ConversationEndEvent(assistant=self))
+
+    def _on_conversation_timeout(self):
+        self._last_response = None
+        self._last_query = None
+        self._conversation_running.clear()
+        self._send_event(ConversationTimeoutEvent(assistant=self))
+
+    def _on_no_response(self):
+        self._last_response = None
+        self._conversation_running.clear()
+        self._send_event(NoResponseEvent(assistant=self))
+
+    def _on_reponse_rendered(self, text: Optional[str]):
+        self._last_response = text
+        self._send_event(ResponseEvent(assistant=self, response_text=text))
+        tts = self._get_tts_plugin()
+
+        if tts and text:
+            self.stop_conversation()
+            tts.say(text=text, **self.tts_plugin_args)
+
+    def _on_speech_recognized(self, phrase: Optional[str]):
+        phrase = (phrase or '').lower().strip()
+        self._last_query = phrase
+        self._send_event(SpeechRecognizedEvent(assistant=self, phrase=phrase))
+
+    def _on_alarm_start(self):
+        self._cur_alert_type = AlertType.ALARM
+        self._send_event(AlarmStartedEvent(assistant=self))
+
+    def _on_alarm_end(self):
+        self._cur_alert_type = None
+        self._send_event(AlarmEndEvent(assistant=self))
+
+    def _on_timer_start(self):
+        self._cur_alert_type = AlertType.TIMER
+        self._send_event(TimerStartedEvent(assistant=self))
+
+    def _on_timer_end(self):
+        self._cur_alert_type = None
+        self._send_event(TimerEndEvent(assistant=self))
+
+    def _on_alert_start(self):
+        self._cur_alert_type = AlertType.ALERT
+        self._send_event(AlertStartedEvent(assistant=self))
+
+    def _on_alert_end(self):
+        self._cur_alert_type = None
+        self._send_event(AlertEndEvent(assistant=self))
+
+    def _on_mute(self):
+        self._is_muted = True
+        self._send_event(MicMutedEvent(assistant=self))
+
+    def _on_unmute(self):
+        self._is_muted = False
+        self._send_event(MicUnmutedEvent(assistant=self))
+
+    def _on_mute_changed(self, value: bool):
+        if value:
+            self._on_mute()
+        else:
+            self._on_unmute()
+
+    def transform_entities(self, entities: Collection['AssistantPlugin']):
+        return super().transform_entities(
+            [
+                Assistant(
+                    external_id=get_plugin_name_by_class(type(dev)),
+                    name=self._entity_name,
+                    last_query=dev._state.last_query,
+                    last_response=dev._state.last_response,
+                    conversation_running=dev._state.conversation_running,
+                    is_muted=dev._state.is_muted,
+                    is_detecting=dev._state.is_detecting,
+                )
+                for dev in (entities or [])
+            ]
+        )
+
 
 # vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/assistant/google/__init__.py b/platypush/plugins/assistant/google/__init__.py
index 89ed6a587..52758cb5c 100644
--- a/platypush/plugins/assistant/google/__init__.py
+++ b/platypush/plugins/assistant/google/__init__.py
@@ -3,23 +3,6 @@ import os
 from typing import Optional
 
 from platypush.config import Config
-from platypush.context import get_bus, get_plugin
-from platypush.message.event.assistant import (
-    ConversationStartEvent,
-    ConversationEndEvent,
-    ConversationTimeoutEvent,
-    ResponseEvent,
-    NoResponseEvent,
-    SpeechRecognizedEvent,
-    AlarmStartedEvent,
-    AlarmEndEvent,
-    TimerStartedEvent,
-    TimerEndEvent,
-    AlertStartedEvent,
-    AlertEndEvent,
-    MicMutedEvent,
-    MicUnmutedEvent,
-)
 from platypush.plugins import RunnablePlugin, action
 from platypush.plugins.assistant import AssistantPlugin
 
@@ -79,6 +62,8 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
         the automated ways fail.
     """
 
+    _entity_name = 'Google Assistant'
+
     _default_credentials_files = (
         os.path.join(Config.get_workdir(), 'credentials', 'google', 'assistant.json'),
         os.path.join(
@@ -90,7 +75,6 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
         self,
         credentials_file: Optional[str] = None,
         device_model_id: str = 'Platypush',
-        conversation_start_sound: Optional[str] = None,
         **kwargs,
     ):
         """
@@ -109,11 +93,6 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
         :param device_model_id: The device model ID that identifies the device
             where the assistant is running (default: Platypush). It can be a
             custom string.
-
-        :param conversation_start_sound: If set, the assistant will play this
-            audio file when it detects a speech. The sound file will be played
-            on the default audio output device. If not set, the assistant won't
-            play any sound when it detects a speech.
         """
 
         super().__init__(**kwargs)
@@ -121,13 +100,6 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
         self.device_model_id = device_model_id
         self.credentials = None
         self._assistant = None
-        self._is_muted = False
-
-        if conversation_start_sound:
-            self._conversation_start_sound = os.path.abspath(
-                os.path.expanduser(conversation_start_sound)
-            )
-
         self.logger.info('Initialized Google Assistant plugin')
 
     @property
@@ -152,79 +124,54 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
 
         return self._assistant
 
-    def _play_conversation_start_sound(self):
-        if not self._conversation_start_sound:
-            return
-
-        audio = get_plugin('sound')
-        if not audio:
-            self.logger.warning(
-                'Unable to play conversation start sound: sound plugin not found'
-            )
-            return
-
-        audio.play(self._conversation_start_sound)
-
     def _process_event(self, event):
         from google.assistant.library.event import EventType, AlertType
 
         self.logger.info('Received assistant event: %s', event)
 
         if event.type == EventType.ON_CONVERSATION_TURN_STARTED:
-            get_bus().post(ConversationStartEvent(assistant=self))
-            self._play_conversation_start_sound()
+            self._on_conversation_start()
         elif event.type == EventType.ON_CONVERSATION_TURN_FINISHED:
             if not event.args.get('with_follow_on_turn'):
-                get_bus().post(ConversationEndEvent(assistant=self))
+                self._on_conversation_end()
         elif event.type == EventType.ON_CONVERSATION_TURN_TIMEOUT:
-            get_bus().post(ConversationTimeoutEvent(assistant=self))
+            self._on_conversation_timeout()
         elif event.type == EventType.ON_NO_RESPONSE:
-            get_bus().post(NoResponseEvent(assistant=self))
+            self._on_no_response()
         elif (
             hasattr(EventType, 'ON_RENDER_RESPONSE')
             and event.type == EventType.ON_RENDER_RESPONSE
         ):
-            get_bus().post(
-                ResponseEvent(assistant=self, response_text=event.args.get('text'))
-            )
-            tts = self._get_tts_plugin()
-
-            if tts and event.args.get('text'):
-                self.stop_conversation()
-                tts.say(text=event.args['text'], **self.tts_plugin_args)
+            self._on_reponse_rendered(event.args.get('text'))
         elif (
             hasattr(EventType, 'ON_RESPONDING_STARTED')
             and event.type == EventType.ON_RESPONDING_STARTED
-            and event.args.get('is_error_response', False) is True
+            and event.args.get('is_error_response') is True
         ):
-            self.logger.warning('Assistant response error')
+            self.logger.warning('Assistant response error: %s', json.dumps(event.args))
         elif event.type == EventType.ON_RECOGNIZING_SPEECH_FINISHED:
-            phrase = event.args['text'].lower().strip()
-            self.logger.info('Speech recognized: %s', phrase)
-            get_bus().post(SpeechRecognizedEvent(assistant=self, phrase=phrase))
+            self._on_speech_recognized(event.args.get('text'))
         elif event.type == EventType.ON_ALERT_STARTED:
             if event.args.get('alert_type') == AlertType.ALARM:
-                get_bus().post(AlarmStartedEvent(assistant=self))
+                self._on_alarm_start()
             elif event.args.get('alert_type') == AlertType.TIMER:
-                get_bus().post(TimerStartedEvent(assistant=self))
+                self._on_timer_start()
             else:
-                get_bus().post(AlertStartedEvent(assistant=self))
+                self._on_alert_start()
         elif event.type == EventType.ON_ALERT_FINISHED:
             if event.args.get('alert_type') == AlertType.ALARM:
-                get_bus().post(AlarmEndEvent(assistant=self))
+                self._on_alarm_end()
             elif event.args.get('alert_type') == AlertType.TIMER:
-                get_bus().post(TimerEndEvent(assistant=self))
+                self._on_timer_end()
             else:
-                get_bus().post(AlertEndEvent(assistant=self))
+                self._on_alert_end()
         elif event.type == EventType.ON_ASSISTANT_ERROR:
             if event.args.get('is_fatal'):
                 raise RuntimeError(f'Fatal assistant error: {json.dumps(event.args)}')
 
             self.logger.warning('Assistant error: %s', json.dumps(event.args))
         elif event.type == EventType.ON_MUTED_CHANGED:
-            self._is_muted = event.args.get('is_muted')
-            event = MicMutedEvent() if self._is_muted else MicUnmutedEvent()
-            get_bus().post(event)
+            self._on_mute_changed(event.args.get('is_muted', False))
 
     @action
     def start_conversation(self, *_, **__):
@@ -235,7 +182,7 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
             self.assistant.start_conversation()
 
     @action
-    def stop_conversation(self):
+    def stop_conversation(self, *_, **__):
         """
         Programmatically stop a running conversation with the assistant
         """
@@ -243,20 +190,20 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
             self.assistant.stop_conversation()
 
     @action
-    def mute(self):
+    def mute(self, *_, **__):
         """
         Mute the microphone. Alias for :meth:`.set_mic_mute` with
         ``muted=True``.
         """
-        return self.set_mic_mute(muted=True)
+        self.set_mic_mute(muted=True)
 
     @action
-    def unmute(self):
+    def unmute(self, *_, **__):
         """
         Unmute the microphone. Alias for :meth:`.set_mic_mute` with
         ``muted=False``.
         """
-        return self.set_mic_mute(muted=False)
+        self.set_mic_mute(muted=False)
 
     @action
     def set_mic_mute(self, muted: bool):
@@ -268,23 +215,27 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
         if self.assistant:
             self.assistant.set_mic_mute(muted)
 
+            if muted:
+                self._on_mute()
+            else:
+                self._on_unmute()
+
     @action
-    def is_muted(self) -> bool:
+    def toggle_mute(self, *_, **__):
         """
-        :return: True if the microphone is muted, False otherwise.
+        Toggle the mic mute state.
         """
-        return self._is_muted
+        self.set_mic_mute(muted=not self._is_muted)
 
     @action
     def toggle_mic_mute(self):
         """
-        Toggle the mic mute state.
+        Deprecated alias for :meth:`.toggle_mute`.
         """
-        is_muted = self.is_muted()
-        self.set_mic_mute(muted=not is_muted)
+        return self.toggle_mute()
 
     @action
-    def send_text_query(self, query: str):
+    def send_text_query(self, *_, query: str, **__):
         """
         Send a text query to the assistant.
 
@@ -323,6 +274,7 @@ class AssistantGooglePlugin(AssistantPlugin, RunnablePlugin):
                 with Assistant(
                     self.credentials, self.device_model_id
                 ) as self._assistant:
+                    self.publish_entities([self])
                     for event in self._assistant.start():
                         last_sleep = 0