Question about how to integrate AppDaemon as a service?

I would appreciate some feedback on my plan before I do too much typing. I want to build myself a central queue for TTS and notification message dispatch. The problem I’m trying to solve is the one where TTS messages clobber each other if one is not done playing when a new one is sent to a media device.

My implementation will need persistent storage for the queue and AppDaemon seems like it could work for me.

I would like to be able to invoke an AppDaemon from an automation - essentially treat it like a service. Reading through the docs, it doesn’t seem like I can do that. There doesn’t appear to be a way to directly invoke an AppDaemon app function - everything is callback or event based. Is this true?

If I can’t call in, it looks like I can use an custom event and have the Daemon listen for the event. That will probably work fine. There is almost NO documentation on custom events. Can someone point me to a good resource or example on how to use these if this ends up being the path I must take?

Finally, are AppDaemon scripts simply the wrong solution? Would a custom component get me what I want? Has someone else solved this properly?

Thanks!

its possible, but not really the essence from appdaemon.
appdaemon is actually created to replace the automations.
but like i said it is possible.
in an app you can listen to an event or a state change so in the automation you can trigger an event or an input boolean for example. you could even listen to the state change from the automation, because thats also an entity.

allthough everything you want can be possible it would still be a lot off back and forward and not easy to figure out.

i just left the TTS from HA for what it is and made an app that does the TTS and creates the que.
but i got no automations in HA, so triggering the TTS is easy that way.

Hmm that’s interesting. I didn’t really consider people abandoning the HA automation infrastructure. It seems there is a an overly rich set of options for how to do automation. yaml - node red - appdaemon … others?

The aspect that I need is persistence so that messages can be played out ASAP but not collide.

Would you mind sharing or pointing to your queue implementation?

i got my sound app on my github,
but i just checked and its still in Appdaemon version 2, so some small changes are needed to get it working.

I have a purely local (no cloud services) tts app using the pyttsx3 module if you are interested.

I’d be interested in seeing your app. Thank you gbenton and ReneTode for generously sharing your work.

Thank you ReneTode. Your sound appdaemon does much of what I was looking to do.

1 Like

if you want to use it you need to upgrade it to version 3 from AD
https://appdaemon.readthedocs.io/en/latest/UPGRADE_FROM_2.x.html

and you can create a small second app to use the TTS from automations in HA.
if you create an input_text and an app that listens to the input text you can change the text in an HA automation.
if you want that i can help you set it up.

I notice that I am using my LoggedApp class as the base here, so you will need to do some interpretation, but I think the principle is what you are looking for. There are also several extras that are probably not relevant to you.

Basically, it starts a thread to run the pyttx engine and adds sentences to speak to its queue with the say method. It can also detect a appd_tts event and speak the data in that, but I think that is mainly for testing.


import logged_app
import pyttsx3
import time
import threading
import datetime

#
# App to do Text to Speech entirely locally (no cloud involved).
# Uses pyttsx3 module. In your appdaemon VENV do
#   pip3 install pyttsx3
#
#   On linux this needs espeak
#     sudo apt-get install espeak libespeak1
#     also, on a raspberry pi, you may need to add the user that runs appdaemon
#     to the audio group
#     sudo usermod -aG audio <your appdaemon user>
#     you will need to restart appdaemon after doing this.
#   On Windows
#     TBD uses sapi5?
#   On Mac
#     TBD uses nsss NSSpeechSynthesizer
#
# Args:
#   voicename: the espeak voicename to use - see espeak --voices
#   volume: Initial volume (0-1.0).  Optional, default 1.0
#   rate:   the speed the voice speaks. Optional, default 200
#   prefix: Something to say before the msg if nothing else is being said. Optional
#   tts_topic:  an MQTT topic to send message on. Optional 
#   toast_topic: (optional) another MQTT topic to send message on. Optional 

class Tts(logged_app.LoggedApp):

    def initialize(self):
        self.log("initialize", level="DEBUG")

        self.lock = threading.Lock()
        self.connect_list = []
        self._engine = pyttsx3.init()
        self.count = 0

        try:
            self._engine.setProperty('voice', self.args["voicename"])

        except KeyError as e:
            self.log("Argument not found : {}".format(e), level="ERROR")
            return

        try:
            self._engine.setProperty('volume', float(self.args["volume"]))
        except KeyError as e:
            self.log("Using default volume", level="DEBUG")
        except ValueError as e:
            self.log("volume must be a float", level="ERROR")
            self.error("volume must be a float", level="ERROR")
            return

        try:
            self._engine.setProperty('rate', int(self.args["rate"]))
        except KeyError as e:
            self.log("Using default rate", level="DEBUG")
        except ValueError as e:
            self.log("rate must be a int", level="ERROR")
            self.error("rate must be a int", level="ERROR")
            return

        try:
            self._prefix = self.args["prefix"]
        except KeyError as e:
            self._prefix = ""


        # Set callbacks and store ids on connect_list
#          self.connect_list.append(
#                  self._engine.connect('started-utterance', self.onStart))
#          self.connect_list.append(
#              self._engine.connect('started-word', self.onWord))
        self.connect_list.append(
              self._engine.connect('finished-utterance', self.onEnd))
        self.connect_list.append(
            self._engine.connect('error', self.onError))

        # Run the engine in a separate thread
        self._thread = threading.Thread(target=self.runEngine,
                args=(self._engine, 1))
        self._thread.start()

        # Enable this to run a periodic test
        #self.run_every(self.period, self.datetime(), 30)

        # Set callback to receive appd_tts events
        self.listen_event(self.say_event, "appd_tts")

    def period(self, kwargs):
        self.log("period", level="DEBUG")
        self.say("The spare window is open and it is raining")
        self.say("The not so spare window is open and it is raining")

    def terminate(self):
        self.log("terminate", level="DEBUG")

        if not hasattr(self, "lock"):
            return

        with self.lock:
            while len(self.connect_list) > 0:
                self._engine.disconnect(self.connect_list.pop())

            try:
                if hasattr(self, "_engine"):
                    self.log("stopping engine", level="DEBUG")
                    self._engine.endLoop()
                if hasattr(self, "_thread"):
                    self.log("Ending Engine thread", level="DEBUG")
                    self._thread.join(0.5)
                    if self._thread.is_alive():
                        self.log("Engine Thread didn't end", level="ERROR")
                        self.error("Engine Thread didn't end", level="ERROR")
            except RuntimeError as e:
                self.log("RuntimeError: {}".format(e), level="ERROR")
                self.error("RuntimeError: {}".format(e), level="ERROR")


    # Note that callbacks from the engine are running in the Engine thread
    def onStart(self, name):
       self.log ("starting {}".format(name), level="DEBUG")
    def onWord(self, name, location, length):
       self.log ("word {} {} {}".format(name, location, length), level="DEBUG")

    def onEnd(self, name, completed):
        self.log ("finishing {} {}".format(name, completed), level="DEBUG")
        if  int(name) == self.count:
            self._turn_off_speakers()

    def onError(self, name, exception):
        self.log("Error from engine: {}".format(exception), level="ERROR")
        self.error("Error from engine: {}".format(exception), level="ERROR")

    # Runs in its own thread
    def runEngine(self, engine, delay):
        self.log("engine starting", level="DEBUG")
        try:
            engine.startLoop(True)
        except RuntimeError:
            # loop is already started
            self.log("Loop already started, using existing", level="WARNING")

    def say(self, orig_text):
        self.log("Saying '{}'".format(orig_text), level="DEBUG")
        if  self._engine.isBusy():
            text = "...and " + orig_text
        else:
            text = self._prefix + orig_text
            self._turn_on_speakers()

        with self.lock:
            self.count+=1
            self._engine.say(text, self.count)

        try:
            self.call_service("mqtt/publish", topic=self.args["tts_topic"],
                    payload=orig_text)
        except ValueError:
            pass

        try:
            self.call_service("mqtt/publish", topic=self.args["toast_topic"],
                    payload=orig_text)
        except ValueError:
            pass

    def say_event(self, event_name, data, kwargs):
        try:
            self.log("say_event: {}".format(data['msg']), level="DEBUG")
            self.say(data['msg'])
        except KeyError:
            self.log("say_event: msg not found in data)", level="WARNING")

    def set_volume(self, volume):
        self.log("set_volume: {}".format(volume), level="DEBUG")
        try:
            self._engine.setProperty('volume', float(volume))
        except ValueError:
            self.log("volume must be a float", level="WARNING")

    def set_rate(self, rate):
        self.log("set_rate: {}".format(rate), level="DEBUG")
        try:
            self._engine.setProperty('rate', int(rate))
        except ValueError:
            self.log("rate must be a int", level="WARNING")

    def set_voice(self, voice):
        self.log("set_voice: {}".format(voice), level="DEBUG")
        self._engine.setProperty('voice', voice)

    def _turn_on_speakers(self):
        try:
            speakers = self.get_app("speakers")
            speakers.turn_on()
        except AttributeError:
            self.log("Speakers app not found")

    def _turn_off_speakers(self):
        try:
            speakers = self.get_app("speakers")
            speakers.turn_off()
        except AttributeError:
            self.log("Speakers app not found")
1 Like

Thank you gpbenton. I’m currently using hassio so I don’t have the flexibility to do what you’ve done just yet. I’ll definitely look into local tts eventually.

for hassio you need to add these parts to the addon config instead of pip and apt_get if you want to use my app.

  • pip3 install gtts
  • sudo apt-get install mpg321
  • sudo apt-get install vlc

Thank you ReneTode. I think what you described fits well with my understanding. I’m going to write up my version of the queuing and priority mechanisms. I hadn’t planned on the message dispatch doing the actual sound target management (both your code gpbenton’s code have some facility for turning on the speaker.)

I appreciate the offer of help. I’ll give it a go on my own and see how it goes. At the very least I’ll learn something!

1 Like

This is pretty nice for queuing. I’m guessing LoggedApp inherits from hass.HASS?

Yes, it just redefines the self.log method to enable logging on a per app basis. I understand it will be redundant in the next release of AD, as the functionality is being built in.

Ah okay, I have a similar class. Was just clarifying before I implement some of the threading code for my own TTS queue. Thanks!

What are the changes that are going into the log? Got a link?

I think I saw it in the development branch of the appdaemon docs, or maybe someone posted on this forum.

1 Like

logging will be completely changed.
you can add your own logs, per app logging, etc.

also threading will be completely different.
you can set threads per app, lock threads, etc.

there will be a gui for appdaemon, apps and threads will be objects that can be viewed
and lots more.

1 Like

Closing the circle on this.
I ended up writing a python script to handle the speech queuing issue I wanted to solve.
The problem is a common one with Sonos devices - they don’t wait for a current mp3 to finish before starting a new one.
My solution to the dumb rentrant protection for “scripts” was to use a python_script instead. That allowed me to build a service “sonos_say” that could be called by multiple actions at the same time.

I dealt with the sonos problem by using an input slider as a semaphore. The first action that can grab the semaphore gets to send the text to be spoken. The python script estimates the duration of the message based on word counts and delays for that number of seconds. After the delay it releases the semaphore.

Other actions that want to say something try to take the semaphore and delay for a brief period and spin trying to take the lock. Yes I get a warning saying delays might do bad things.

All in all it gives me a nice facility for central access to a sending text to me sonos.

Thanks again for the help.

1 Like

Hi @deddc23efb, I am very interested in your python_script. Do you mind sharing?

Thanks.