From 2942c246a0c1a9bbf19fa79c41fd1521937b7a88 Mon Sep 17 00:00:00 2001 From: mpember Date: Fri, 2 Mar 2018 23:30:41 +1100 Subject: [PATCH 01/27] Add brightness control A brightness variable make it possible to set a maximum LED brightness. --- src/aiy/_drivers/_led.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/aiy/_drivers/_led.py b/src/aiy/_drivers/_led.py index f99b1912..58d26bb6 100644 --- a/src/aiy/_drivers/_led.py +++ b/src/aiy/_drivers/_led.py @@ -47,6 +47,7 @@ def __init__(self, channel): self.running = False self.state = None self.sleep = 0 + self.brightness = 1 GPIO.setmode(GPIO.BCM) GPIO.setup(channel, GPIO.OUT) self.pwm = GPIO.PWM(channel, 100) @@ -78,6 +79,12 @@ def stop(self): self.pwm.stop() + def set_brightness(self, brightness): + try: + self.brightness = int(brightness) + except ValueError: + raise ValueError('unsupported brightness: %s' % brightness) + def set_state(self, state): """Set the LED driver's new state. @@ -100,7 +107,7 @@ def _animate(self): if not self._parse_state(state): raise ValueError('unsupported state: %d' % state) if self.iterator: - self.pwm.ChangeDutyCycle(next(self.iterator)) + self.pwm.ChangeDutyCycle(next(self.iterator) * self.brightness) time.sleep(self.sleep) else: # We can also wait for a state change here with a Condition. @@ -112,10 +119,10 @@ def _parse_state(self, state): handled = False if state == self.OFF: - self.pwm.ChangeDutyCycle(0) + self.pwm.ChangeDutyCycle(0 * self.brightness) handled = True elif state == self.ON: - self.pwm.ChangeDutyCycle(100) + self.pwm.ChangeDutyCycle(100 * self.brightness) handled = True elif state == self.BLINK: self.iterator = itertools.cycle([0, 100]) From 0c9056846ef550e901d9da836cbafb489b2e32bf Mon Sep 17 00:00:00 2001 From: mpember Date: Thu, 12 Apr 2018 09:49:20 +1000 Subject: [PATCH 02/27] Sync custom commands --- src/main.py | 274 +++++++++++++++++++++++++++++++ src/modules/kodi.py | 109 ++++++++++++ src/modules/music.py | 239 +++++++++++++++++++++++++++ src/modules/powercommand.py | 37 +++++ src/modules/powerswitch.py | 91 ++++++++++ src/modules/readrssfeed.py | 143 ++++++++++++++++ systemd/voice-recognizer.service | 35 ++-- 7 files changed, 909 insertions(+), 19 deletions(-) create mode 100644 src/main.py create mode 100644 src/modules/kodi.py create mode 100644 src/modules/music.py create mode 100644 src/modules/powercommand.py create mode 100644 src/modules/powerswitch.py create mode 100644 src/modules/readrssfeed.py diff --git a/src/main.py b/src/main.py new file mode 100644 index 00000000..da58c71b --- /dev/null +++ b/src/main.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run a recognizer using the Google Assistant Library. + +The Google Assistant Library has direct access to the audio API, so this Python +code doesn't need to record audio. Hot word detection "OK, Google" is supported. + +The Google Assistant Library can be installed with: + env/bin/pip install google-assistant-library==0.0.2 + +It is available for Raspberry Pi 2/3 only; Pi Zero is not supported. +""" + +import logging +import subprocess +import sys +import time +import json + +import aiy.assistant.auth_helpers +import aiy.assistant.device_helpers +from google.assistant.library import Assistant +import aiy.audio +import aiy.voicehat +from google.assistant.library.event import EventType + +import os.path +import configargparse + +from modules.kodi import KodiRemote +from modules.music import Music +from modules.readrssfeed import ReadRssFeed +from modules.powerswitch import PowerSwitch +from modules.powercommand import PowerCommand + +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] %(levelname)s:%(name)s:%(message)s" +) + +_configPath = os.path.expanduser('~/.config/voice-assistant.ini') +_settingsPath = os.path.expanduser('~/.config/settings.ini') +_remotePath = os.path.expanduser('~/.config/remotes.ini') + +_kodiRemote = KodiRemote(_settingsPath) +_music = Music(_settingsPath) +_readRssFeed = ReadRssFeed(_settingsPath) +_powerSwitch = PowerSwitch(_remotePath) + +def _createPID(file_name): + + if not file_name: + # Try the default locations of the pid file, preferring /run/user as + # it uses tmpfs. + pid_dir = '/run/user/%d' % os.getuid() + if not os.path.isdir(pid_dir): + pid_dir = '/tmp' + file_name = os.path.join(pid_dir, 'voice-recognizer.pid') + + with open(file_name, 'w') as pid_file: + pid_file.write("%d" % os.getpid()) + +def _volumeCommand(change): + + """Changes the volume and says the new level.""" + + res = subprocess.check_output(r'amixer get Master | grep "Front Left:" | sed "s/.*\[\([0-9]\+\)%\].*/\1/"', shell=True).strip() + try: + logging.info('volume: %s', res) + if change == 0 or change > 10: + vol = change + else: + vol = int(res) + change + + vol = max(0, min(100, vol)) + if vol == 0: + aiy.audio.say('Volume at %d %%.' % vol) + + subprocess.call('amixer -q set Master %d%%' % vol, shell=True) + aiy.audio.say('Volume at %d %%.' % vol) + + except (ValueError, subprocess.CalledProcessError): + logging.exception('Error using amixer to adjust volume.') + +def process_event(assistant, event): + status_ui = aiy.voicehat.get_status_ui() + + global _cancelAction + + if event.type == EventType.ON_START_FINISHED: + status_ui.status('ready') + if sys.stdout.isatty(): + print('Say "OK, Google" then speak, or press Ctrl+C to quit...') + + elif event.type == EventType.ON_CONVERSATION_TURN_STARTED: + status_ui.status('listening') + + elif event.type == EventType.ON_RECOGNIZING_SPEECH_FINISHED and event.args: + + _cancelAction = False + text = event.args['text'].lower() + + if sys.stdout.isatty(): + print('You said:', text) + else: + logging.info('You said: ' + text) + + if text == '': + assistant.stop_conversation() + + elif _music.getConfirmPlayback() == True: + assistant.stop_conversation() + if text == 'yes': + _music.run('podcast', 'CONFIRM') + else: + _music.setConfirmPlayback(False) + _music.setPodcastURL(None) + + elif text.startswith('music '): + assistant.stop_conversation() + _music.run('music', text[6:]) + + elif text.startswith('podcast '): + assistant.stop_conversation() + _music.run('podcast', text[8:]) + if _music.getConfirmPlayback() == True: + assistant.start_conversation() + + elif text.startswith('radio '): + assistant.stop_conversation() + _music.run('radio', text[6:]) + + elif text.startswith('headlines '): + assistant.stop_conversation() + _readRssFeed.run(text[10:]) + + elif text.startswith('turn on ') or text.startswith('turn off '): + assistant.stop_conversation() + _powerSwitch.run(text[5:]) + + elif text.startswith('switch to channel '): + assistant.stop_conversation() + _kodiRemote.run('tv ' + text[18:]) + + elif text.startswith('switch '): + assistant.stop_conversation() + _powerSwitch.run(text[7:]) + + elif text.startswith('kodi ') or text.startswith('cody '): + assistant.stop_conversation() + _kodiRemote.run(text[5:]) + + elif text.startswith('play next episode of '): + assistant.stop_conversation() + _kodiRemote.run('play unwatched ' + text[21:]) + + elif text.startswith('play most recent episode of '): + assistant.stop_conversation() + _kodiRemote.run('play unwatched ' + text[28:]) + + elif text.startswith('play unwatched ') or text.startswith('play tv series '): + assistant.stop_conversation() + _kodiRemote.run(text) + + elif text.startswith('tv '): + assistant.stop_conversation() + _kodiRemote.run(text) + + elif text == 'power off' or text == 'shutdown' or text == 'shut down' or text == 'self destruct': + assistant.stop_conversation() + PowerCommand().run('shutdown') + + elif text == 'reboot': + assistant.stop_conversation() + _powerCommand('reboot') + + elif text == 'volume up': + assistant.stop_conversation() + _volumeCommand(10) + + elif text == 'volume down': + assistant.stop_conversation() + _volumeCommand(-10) + + elif text == 'volume maximum': + assistant.stop_conversation() + _volumeCommand(100) + + elif text == 'volume mute': + assistant.stop_conversation() + _volumeCommand(0) + + elif text == 'volume reset': + assistant.stop_conversation() + _volumeCommand(80) + + elif text == 'volume medium': + assistant.stop_conversation() + _volumeCommand(50) + + elif text == 'volume low': + assistant.stop_conversation() + _volumeCommand(30) + + elif text == 'brightness low': + assistant.stop_conversation() + aiy.voicehat.get_led().set_brightness(10) + + elif text == 'brightness high': + assistant.stop_conversation() + aiy.voicehat.get_led().set_brightness(100) + + elif event.type == EventType.ON_END_OF_UTTERANCE: + status_ui.status('thinking') + + elif event.type == EventType.ON_CONVERSATION_TURN_FINISHED: + status_ui.status('ready') + + elif event.type == EventType.ON_ASSISTANT_ERROR and event.args and event.args['is_fatal']: + sys.exit(1) + + +def main(): + + parser = configargparse.ArgParser( + default_config_files=[_configPath], + description="Act on voice commands using Google's speech recognition") + parser.add_argument('-L', '--language', default='en-GB', + help='Language code to use for speech (default: en-GB)') + parser.add_argument('-p', '--pid-file', default='/tmp/voice-recognizer.pid', + help='File containing our process id for monitoring') + parser.add_argument('--trigger-sound', default=None, + help='Sound when trigger is activated (WAV format)') + parser.add_argument('--brightness-max', default=1, + help='Maximum LED brightness') + parser.add_argument('--brightness-min', default=1, + help='Minimum LED brightness') + + args = parser.parse_args() + + aiy.i18n.set_language_code(args.language) + _createPID(args.pid_file) + + aiy.voicehat.get_led().set_brightness(args.brightness_max) + + credentials = aiy.assistant.auth_helpers.get_assistant_credentials() + model_id, device_id = aiy.assistant.device_helpers.get_ids_for_service(credentials) + + with Assistant(credentials, model_id) as assistant: + for event in assistant.start(): + process_event(assistant, event) + +if __name__ == '__main__': +# try: + main() +# except KeyboardInterrupt: +# pass +# finally: +# if sys.stdout.isatty(): +# print('You pressed Ctrl+C') +# sys.exit(1) diff --git a/src/modules/kodi.py b/src/modules/kodi.py new file mode 100644 index 00000000..23c9f0a7 --- /dev/null +++ b/src/modules/kodi.py @@ -0,0 +1,109 @@ +import configparser +import logging + +from kodijson import Kodi, PLAYER_VIDEO + +import aiy.audio + +# KodiRemote: Send command to Kodi +# ================================ +# + +class KodiRemote(object): + + """Sends a command to a kodi client machine""" + + def __init__(self, configPath): + self.configPath = configPath + self.kodi = None + self.action = None + self.request = None + + def run(self, voice_command): + config = configparser.ConfigParser() + config.read(self.configPath) + settings = config['kodi'] + + kodiUsername = settings['username'] + kodiPassword = settings['password'] + + number_mapping = [ ('10 ', 'ten '), ('9 ', 'nine ') ] + + if self.kodi is None: + logging.info('No current connection to a Kodi client') + + for key in settings: + if key not in ['username','password']: + if voice_command.startswith(key): + voice_command = voice_command[(len(key)+1):] + self.kodi = Kodi('http://' + settings[key] + '/jsonrpc', kodiUsername, kodiPassword) + elif self.kodi is None: + self.kodi = Kodi('http://' + settings[key] + '/jsonrpc', kodiUsername, kodiPassword) + + try: + self.kodi.JSONRPC.Ping() + except: + aiy.audio.say(_('Unable to connect to client')) + return + + if voice_command.startswith('tv '): + result = self.kodi.PVR.GetChannels(channelgroupid='alltv') + channels = result['result']['channels'] + if len(channels) == 0: + aiy.audio.say('No channels found') + + elif voice_command == 'tv channels': + aiy.audio.say('Available channels are') + for channel in channels: + aiy.audio.say(channel['label']) + + else: + for k, v in number_mapping: + voice_command = voice_command.replace(k, v) + + channel = [item for item in channels if (str(item['label']).lower() == voice_command[3:])] + if len(channel) == 1: + self.kodi.Player.Open(item={'channelid':int(channel[0]['channelid'])}) + + else: + logging.info('No channel match found for ' + voice_command[3:] + '(' + str(len(channel)) + ')') + aiy.audio.say('No channel match found for ' + voice_command[3:]) + aiy.audio.say('Say Kodi t v channels for a list of available channels') + + elif voice_command.startswith('play unwatched ') or voice_command.startswith('play tv series '): + voice_command = voice_command[15:] + result = self.kodi.VideoLibrary.GetTVShows(sort={'method':'dateadded','order':'descending'},filter={'field':'title','operator': 'contains', 'value': voice_command}, properties=['playcount','sorttitle','dateadded','episode','watchedepisodes']) + if 'tvshows' in result['result']: + if len(result['result']['tvshows']) > 0: + result = self.kodi.VideoLibrary.GetEpisodes(tvshowid=result['result']['tvshows'][0]['tvshowid'], sort={'method':'episode','order':'ascending'},filter={'field':'playcount','operator': 'lessthan', 'value': '1'},properties=['episode','playcount'],limits={'end': 1}) + if 'episodes' in result['result']: + if len(result['result']['episodes']) > 0: + self.kodi.Player.Open(item={'episodeid':result['result']['episodes'][0]['episodeid']}) + + else: + aiy.audio.say('No new episodes of ' + voice_command + ' available') + logging.info('No new episodes of ' + voice_command + ' available') + + else: + aiy.audio.say('No new episodes of ' + voice_command + ' available') + logging.info('No new episodes of ' + voice_command + ' available') + + else: + aiy.audio.say('No tv show found titled ' + voice_command) + logging.info('No tv show found') + + elif voice_command == 'stop': + result = self.kodi.Player.Stop(playerid=1) + logging.info('Kodi response: ' + str(result)) + + elif voice_command == 'play' or voice_command == 'pause' or voice_command == 'paws' or voice_command == 'resume': + result = self.kodi.Player.PlayPause(playerid=1) + logging.info('Kodi response: ' + str(result)) + + elif voice_command == 'shutdown' or voice_command == 'shut down': + self.kodi.System.Shutdown() + + else: + aiy.audio.say('Unrecognised Kodi command') + logging.info('Unrecognised Kodi request: ' + voice_command) + return diff --git a/src/modules/music.py b/src/modules/music.py new file mode 100644 index 00000000..8dec95ee --- /dev/null +++ b/src/modules/music.py @@ -0,0 +1,239 @@ +import configparser +import logging +import time +import feedparser +import threading + +from mpd import MPDClient, MPDError, CommandError + +import aiy.audio +import aiy.voicehat + +class Music(object): + + """Interacts with MPD""" + + def __init__(self, configpath): + self._cancelAction = False + self.configPath = configpath + self._confirmPlayback = False + self._podcastURL = None + self._podcasts = {} + self._mpd = MPDClient(use_unicode=True) + + def run(self, module, voice_command): + self.resetVariables() + self._mpd.connect("localhost", 6600) + + if module == 'music': + if voice_command == 'stop': + self._mpd.stop() + self._mpd.clear() + + elif voice_command == 'resume' or voice_command == 'play': + self._mpd.pause(0) + + elif voice_command == 'pause': + self._mpd.pause(1) + + elif module == 'radio': + self.playRadio(voice_command) + + elif module == 'podcast': + self.playPodcast(voice_command) + + if self._cancelAction == False: + time.sleep(1) + button = aiy.voicehat.get_button() + button.on_press(self._buttonPressCancel) + + # Keep alive until the user cancels music with button press + while self._mpd.status()['state'] != "stop": + if self._cancelAction == True: + logging.info('stopping Music by button press') + self._mpd.stop() + self._podcastURL = None + break + + time.sleep(0.1) + button.on_press(None) + logging.info('Music stopped playing') + self._mpd.clear() + + self._mpd.close() + self._mpd.disconnect() + + def playRadio(self, station): + config = configparser.ConfigParser() + config.read(self.configPath) + + stations = config['radio'] + + if station == 'list': + logging.info('Enumerating radio stations') + aiy.audio.say('Available stations are') + for key in stations: + aiy.audio.say(key) + return + + elif station not in stations: + logging.info('Station not found: ' + station) + aiy.audio.say('radio station ' + station + ' not found') + return + + logging.info('streaming ' + station) + aiy.audio.say('tuning the radio to ' + station) + + self._cancelAction = False + + self._mpd.clear() + self._mpd.add(stations[station]) + self._mpd.play() + + def playPodcast(self, podcast): + + config = configparser.ConfigParser() + config.read(self.configPath) + podcasts = config['podcasts'] + + offset = 0 + + if self._confirmPlayback == True: + self._confirmPlayback = False + + else: + if podcast == 'list': + logging.info('Enumerating Podcasts') + aiy.audio.say('Available podcasts are') + for key in podcasts: + aiy.audio.say(key) + return + + elif podcast == 'recent': + aiy.audio.say('Recent podcasts are') + for title,url in podcasts.items(): + podcastInfo = self.getPodcastItem(podcast, url, offset) + aiy.audio.say(title + ' uploaded an episode ' + str(int(podcastInfo['age']/24)) + ' days ago') + return + + elif podcast == 'today': + aiy.audio.say('Today\'s podcasts are') + for title,url in podcasts.items(): + podcastInfo = self.getPodcastItem(podcast, url, offset) + if podcastInfo['age'] < 36: + aiy.audio.say(title + ' uploaded an episode ' + str(int(podcastInfo['age'])) + ' hours ago') + return + + elif podcast.startswith('previous '): + offset = 1 + podcast = podcast[9:] + + if podcast not in podcasts: + logging.info('Podcast not found: ' + podcast) + aiy.audio.say('Podcast ' + podcast + ' not found') + return + + podcastInfo = self.getPodcastItem(podcast, podcasts[podcast], offset) + if podcastInfo == None: + logging.info('Podcast failed to load') + return + logging.info('Podcast Title: ' + podcastInfo['title']) + logging.info('Episode Title: ' + podcastInfo['ep_title']) + logging.info('Episode URL: ' + podcastInfo['url']) + logging.info('Episode Date: ' + podcastInfo['published']) + logging.info('Podcast Age: ' + str(podcastInfo['age']) + ' hours') + + aiy.audio.say('Playing episode of ' + podcastInfo['title'] + ' titled ' + podcastInfo['ep_title']) + + self._podcastURL = podcastInfo['url'] + + if (podcastInfo['age'] > 336): + aiy.audio.say('This episode is ' + str(int(podcastInfo['age']/24)) + ' days old. Do you still want to play it?') + self._confirmPlayback = True + return None + + self._cancelAction = False + + self._mpd.clear() + self._mpd.add(self._podcastURL) + self._mpd.play() + + self._podcastURL = None + + def getPodcastItem(self, podcast, src, offset): + result = { + 'url':None, + 'title':None, + 'ep_title':None, + 'age':0, + 'published':None + } + + logging.info('loading ' + src + ' podcast feed') + rss = feedparser.parse(src) + + # get the total number of entries returned + resCount = len(rss.entries) + logging.info('feed contains ' + str(resCount) + ' items') + + # exit out if empty + if resCount < offset: + logging.info(podcast + ' podcast feed is empty') + aiy.audio.say('There are no episodes available of ' + podcast) + return None + + if 'title' in rss.feed: + result['title'] = rss.feed.title + + rssItem = rss.entries[offset] + + # Extract infromation about requested item + + if 'title' in rssItem: + result['ep_title'] = rssItem.title + + if 'published_parsed' in rssItem: + result['age'] = int((time.time() - time.mktime(rssItem['published_parsed'])) / 3600) + + if 'published' in rssItem: + result['published'] = rssItem.published + + if 'enclosures' in rssItem: + result['url'] = rssItem.enclosures[0]['href'] + + elif 'media_content' in rssItem: + result['url'] = rssItem.media_content[0]['url'] + + else: + logging.info(podcast + ' feed format is unknown') + aiy.audio.say('The feed for ' + podcast + ' is unknown format') + return None + + return result + + def _buttonPressCancel(self): + self._cancelAction = True + + def getConfirmPlayback(self): + return self._confirmPlayback + + def setConfirmPlayback(self, confirmPlayback): + self._confirmPlayback = confirmPlayback == True + + def getPodcastURL(self): + return self._podcastURL + + def setPodcastURL(self, podcastURL): + self._podcastURL = podcastURL + + def resetVariables(self): + self._cancelAction = False + + def _syncPodcasts(self): + logging.info('Starting Podcast sync') + config = configparser.ConfigParser() + config.read(self.configPath) + podcasts = config['podcasts'] + + for title,url in podcasts.items(): + self._podcasts[podcast] = self.getPodcastItem(podcast, url, 0) diff --git a/src/modules/powercommand.py b/src/modules/powercommand.py new file mode 100644 index 00000000..fadcbe81 --- /dev/null +++ b/src/modules/powercommand.py @@ -0,0 +1,37 @@ +class PowerCommand(object): + + def __init__(self): + self._cancelAction = False + + def run(self, voice_command): + self.resetVariables() + + if voice_command == 'shutdown': + button = aiy.voicehat.get_button() + button.on_press(self._buttonPressCancel) + + p = subprocess.Popen(['/usr/bin/aplay',os.path.expanduser('~/.config/self-destruct.wav')],stdout=subprocess.PIPE,stderr=subprocess.PIPE) + + while p.poll() == None: + if self._cancelAction == True: + logging.info('shutdown cancelled by button press') + p.kill() + return + break + + time.sleep(0.1) + + time.sleep(1) + button.on_press(None) + logging.info('shutdown would have just happened') + subprocess.call('sudo shutdown now', shell=True) + + elif voice_command == 'reboot': + aiy.audio.say('Rebooting') + subprocess.call('sudo shutdown -r now', shell=True) + + def _buttonPressCancel(self): + self._cancelAction = True + + def resetVariables(self): + self._cancelAction = False diff --git a/src/modules/powerswitch.py b/src/modules/powerswitch.py new file mode 100644 index 00000000..7b818237 --- /dev/null +++ b/src/modules/powerswitch.py @@ -0,0 +1,91 @@ +import configparser +import logging +import urllib.request + +import aiy.audio + +from rpi_rf import RFDevice + +# PowerSwitch: Send HTTP command to RF switch website +# ================================ +# + +class PowerSwitch(object): + + """ Control power sockets""" + + def __init__(self, configPath): + self.configPath = configPath + + def run(self, voice_command): + self.config = configparser.ConfigParser() + self.config.read(self.configPath) + self.devices = self.config.sections() + + devices = None + action = None + + if 'GPIO' not in self.config: + aiy.audio.say('No G P I O settings found') + logging.info('No GPIO settings found') + return + + if voice_command == 'list': + logging.info('Enumerating switchable devices') + aiy.audio.say('Available switches are') + for device in self.devices: + if device != 'GPIO': + aiy.audio.say(str(device)) + return + + elif voice_command.startswith('on '): + action = 'on' + devices = voice_command[3:].split(' and ') + + elif voice_command.startswith('off '): + action = 'off' + devices = voice_command[4:].split(' and ') + + else: + aiy.audio.say('Unrecognised command') + logging.info('Unrecognised command: ' + device) + return + + if (action is not None): + for device in devices: + logging.info('Processing switch request for ' + device) + self.processCommand(device, action) + + def processCommand(self, device, action): + if device in self.devices: + + code = int(self.config[device].get('code')) + + if (int(self.config['GPIO'].get('output', -1)) > 0): + if (self.config[device].get('toggle', False)): + logging.info('Power switch is a toggle') + + elif action == 'off': + code = code - 8; + + logging.info('Code to send: ' + str(code)) + + rfdevice = RFDevice(int(self.config['GPIO'].get('output', -1))) + rfdevice.enable_tx() + rfdevice.tx_code(code, 1, 380) + rfdevice.cleanup() + + elif (self.config['GPIO'].get('url', False)): + url = self.config['GPIO'].get('url', False) + logging.info('URL to send request: ' + str(url) + '?code=' + str(code) + '&action=' + action) + logging.info('Code to send via URL: ' + str(code)) + with urllib.request.urlopen(str(url) + '?code=' + str(code) + '&action=' + action) as response: + html = response.read() + + else: + aiy.audio.say('G P I O settings invalid') + logging.info('No valid GPIO settings found') + + else: + aiy.audio.say('Unrecognised switch') + logging.info('Unrecognised device: ' + device) diff --git a/src/modules/readrssfeed.py b/src/modules/readrssfeed.py new file mode 100644 index 00000000..37bce9ed --- /dev/null +++ b/src/modules/readrssfeed.py @@ -0,0 +1,143 @@ +import configparser +import feedparser +import logging +import threading +import time + +import aiy.audio + +class ReadRssFeed(object): + + """Reads out headline and summary from items in an RSS feed.""" + + ####################################################################################### + # constructor + # configPath - the config file containing the feed details + ####################################################################################### + + def __init__(self, configPath): + self._cancelAction = False + self.configPath = configPath + self.feedCount = 10 + self.properties = ['title', 'description'] + self.count = 0 + + def run(self, voice_command): + self.resetVariables() + + config = configparser.ConfigParser() + config.read(self.configPath) + sources = config['headlines'] + + if voice_command == 'list': + logging.info('Enumerating news sources') + aiy.audio.say('Available sources are') + for key in sources: + aiy.audio.say(key) + return + + elif voice_command not in sources: + logging.info('RSS feed source not found: ' + voice_command) + aiy.audio.say('source ' + voice_command + ' not found') + return + + res = self.getNewsFeed(sources[voice_command]) + + # If res is empty then let user know + if res == '': + if aiy.audio.say is not None: + aiy.audio.say('Cannot get the feed') + logging.info('Cannot get the feed') + return + + button = aiy.voicehat.get_button() + button.on_press(self._buttonPressCancel) + + # This thread handles the speech + threadSpeech = threading.Thread(target=self.processSpeech, args=[res]) + threadSpeech.daemon = True + threadSpeech.start() + + # Keep alive until the user cancels speech with button press or all records are read out + while not self._cancelAction: + time.sleep(0.1) + + button.on_press(None) + + def getNewsFeed(self, url): + # parse the feed and get the result in res + res = feedparser.parse(url) + + # get the total number of entries returned + resCount = len(res.entries) + + # exit out if empty + if resCount == 0: + return '' + + # if the resCount is less than the feedCount specified cap the feedCount to the resCount + if resCount < self.feedCount: + self.feedCount = resCount + + # create empty array + resultList = [] + + # loop from 0 to feedCount so we append the right number of entries to the return list + for x in range(0, self.feedCount): + resultList.append(res.entries[x]) + + return resultList + + def resetVariables(self): + self._cancelAction = False + self.feedCount = 10 + self.count = 0 + + def processSpeech(self, res): + # check in various places of speech thread to see if we should terminate out of speech + if not self._cancelAction: + for item in res: + speakMessage = '' + + if self._cancelAction: + logging.info('Cancel Speech detected') + break + + for property in self.properties: + if property in item: + if not speakMessage: + speakMessage = self.stripSpecialCharacters(item[property]) + else: + speakMessage = speakMessage + ', ' + self.stripSpecialCharacters(item[property]) + + if self._cancelAction: + logging.info('Cancel Speech detected') + break + + if speakMessage != '': + # get item number that is being read so you can put it at the front of the message + logging.info('Msg: ' + speakMessage) + # mock the time it takes to speak the text (only needed when not using pi to actually speak) + # time.sleep(2) + + if aiy.audio.say is not None: + aiy.audio.say(speakMessage) + + if self._cancelAction: + logging.info('Cancel Speech detected') + break + + # all records read, so allow exit + self._cancelAction = True + + else: + logging.info('Cancel Speech detected') + + def stripSpecialCharacters(self, inputValue): + return inputValue.replace('
', '\n').replace('
', '\n').replace('
', '\n') + + def _buttonPressCancel(self): + self._cancelAction = True + + def resetVariables(self): + self._cancelAction = False diff --git a/systemd/voice-recognizer.service b/systemd/voice-recognizer.service index db0191ba..15878de0 100644 --- a/systemd/voice-recognizer.service +++ b/systemd/voice-recognizer.service @@ -1,19 +1,16 @@ -# This service can be used to run your code automatically on startup. Look in -# HACKING.md for instructions on creating main.py and enabling it. - -[Unit] -Description=voice recognizer -After=network.target ntpdate.service - -[Service] -Environment=VIRTUAL_ENV=/home/pi/AIY-projects-python/env -Environment=PATH=/home/pi/AIY-projects-python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -ExecStart=/home/pi/AIY-projects-python/env/bin/python3 -u src/main.py -WorkingDirectory=/home/pi/AIY-projects-python -StandardOutput=inherit -StandardError=inherit -Restart=always -User=pi - -[Install] -WantedBy=multi-user.target +[Unit] +Description=Google AIY Voice +After=network.target ntpdate.service + +[Service] +Environment=VIRTUAL_ENV=/mnt/dietpi_userdata/voice-recognizer-raspi/env +Environment=PATH=/mnt/dietpi_userdata/voice-recognizer-raspi/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ExecStart=/mnt/dietpi_userdata/voice-recognizer-raspi/env/bin/python3 -u src/main.py +WorkingDirectory=/mnt/dietpi_userdata/voice-recognizer-raspi +StandardOutput=inherit +StandardError=inherit +Restart=always +User=dietpi + +[Install] +WantedBy=multi-user.target From a6fccfa4cf52a91115b29285c97a1bbe81866910 Mon Sep 17 00:00:00 2001 From: mpember Date: Mon, 30 Apr 2018 00:49:54 +1000 Subject: [PATCH 03/27] Clean up command identification Update code to use 'in' to detect matches, where possible. Minor cleaning up. --- src/main.py | 7 +++++- src/modules/kodi.py | 5 ++++- src/modules/music.py | 52 +++++++++++++++++++------------------------- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/main.py b/src/main.py index da58c71b..185c8686 100644 --- a/src/main.py +++ b/src/main.py @@ -159,6 +159,10 @@ def process_event(assistant, event): assistant.stop_conversation() _powerSwitch.run(text[7:]) + elif text.startswith('media center '): + assistant.stop_conversation() + _kodiRemote.run(text[13:]) + elif text.startswith('kodi ') or text.startswith('cody '): assistant.stop_conversation() _kodiRemote.run(text[5:]) @@ -179,7 +183,7 @@ def process_event(assistant, event): assistant.stop_conversation() _kodiRemote.run(text) - elif text == 'power off' or text == 'shutdown' or text == 'shut down' or text == 'self destruct': + elif text in ['power off','shutdown','shut down','self destruct']: assistant.stop_conversation() PowerCommand().run('shutdown') @@ -263,6 +267,7 @@ def main(): for event in assistant.start(): process_event(assistant, event) + if __name__ == '__main__': # try: main() diff --git a/src/modules/kodi.py b/src/modules/kodi.py index 23c9f0a7..c598ee0f 100644 --- a/src/modules/kodi.py +++ b/src/modules/kodi.py @@ -96,10 +96,13 @@ def run(self, voice_command): result = self.kodi.Player.Stop(playerid=1) logging.info('Kodi response: ' + str(result)) - elif voice_command == 'play' or voice_command == 'pause' or voice_command == 'paws' or voice_command == 'resume': + elif voice_command in ['play','pause','paws','resume']: result = self.kodi.Player.PlayPause(playerid=1) logging.info('Kodi response: ' + str(result)) + elif voice_command == 'update tv shows': + self.kodi.VideoLibrary.Scan() + elif voice_command == 'shutdown' or voice_command == 'shut down': self.kodi.System.Shutdown() diff --git a/src/modules/music.py b/src/modules/music.py index 8dec95ee..7bc73024 100644 --- a/src/modules/music.py +++ b/src/modules/music.py @@ -18,23 +18,22 @@ def __init__(self, configpath): self.configPath = configpath self._confirmPlayback = False self._podcastURL = None - self._podcasts = {} - self._mpd = MPDClient(use_unicode=True) + self.mpd = MPDClient(use_unicode=True) def run(self, module, voice_command): self.resetVariables() - self._mpd.connect("localhost", 6600) + self.mpd.connect("localhost", 6600) if module == 'music': if voice_command == 'stop': - self._mpd.stop() - self._mpd.clear() + self.mpd.stop() + self.mpd.clear() elif voice_command == 'resume' or voice_command == 'play': - self._mpd.pause(0) + self.mpd.pause(0) elif voice_command == 'pause': - self._mpd.pause(1) + self.mpd.pause(1) elif module == 'radio': self.playRadio(voice_command) @@ -48,20 +47,20 @@ def run(self, module, voice_command): button.on_press(self._buttonPressCancel) # Keep alive until the user cancels music with button press - while self._mpd.status()['state'] != "stop": + while self.mpd.status()['state'] != "stop": if self._cancelAction == True: logging.info('stopping Music by button press') - self._mpd.stop() + self.mpd.stop() self._podcastURL = None break time.sleep(0.1) button.on_press(None) logging.info('Music stopped playing') - self._mpd.clear() + self.mpd.clear() - self._mpd.close() - self._mpd.disconnect() + self.mpd.close() + self.mpd.disconnect() def playRadio(self, station): config = configparser.ConfigParser() @@ -86,9 +85,9 @@ def playRadio(self, station): self._cancelAction = False - self._mpd.clear() - self._mpd.add(stations[station]) - self._mpd.play() + self.mpd.clear() + self.mpd.add(stations[station]) + self.mpd.play() def playPodcast(self, podcast): @@ -154,9 +153,9 @@ def playPodcast(self, podcast): self._cancelAction = False - self._mpd.clear() - self._mpd.add(self._podcastURL) - self._mpd.play() + self.mpd.clear() + self.mpd.add(self._podcastURL) + self.mpd.play() self._podcastURL = None @@ -177,7 +176,7 @@ def getPodcastItem(self, podcast, src, offset): logging.info('feed contains ' + str(resCount) + ' items') # exit out if empty - if resCount < offset: + if resCount < offset or resCount == 0: logging.info(podcast + ' podcast feed is empty') aiy.audio.say('There are no episodes available of ' + podcast) return None @@ -198,10 +197,12 @@ def getPodcastItem(self, podcast, src, offset): if 'published' in rssItem: result['published'] = rssItem.published - if 'enclosures' in rssItem: + if 'enclosures' in rssItem and len(rssItem.enclosures) > 0: + logging.info(str(len(rssItem.enclosures)) + ' enclosures found') result['url'] = rssItem.enclosures[0]['href'] - elif 'media_content' in rssItem: + elif 'media_content' in rssItem and len(rssItem.media_content) > 0: + logging.info(str(len(rssItem.media_content)) + ' media found') result['url'] = rssItem.media_content[0]['url'] else: @@ -228,12 +229,3 @@ def setPodcastURL(self, podcastURL): def resetVariables(self): self._cancelAction = False - - def _syncPodcasts(self): - logging.info('Starting Podcast sync') - config = configparser.ConfigParser() - config.read(self.configPath) - podcasts = config['podcasts'] - - for title,url in podcasts.items(): - self._podcasts[podcast] = self.getPodcastItem(podcast, url, 0) From 83f9f3612c3ab4df19e1bdb1ff38115b440698f5 Mon Sep 17 00:00:00 2001 From: mpember Date: Fri, 4 May 2018 00:23:37 +1000 Subject: [PATCH 04/27] Update to power switch controls Add optional allowance for 'turn on THE ' in addition to 'turn on ' --- src/modules/powerswitch.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/modules/powerswitch.py b/src/modules/powerswitch.py index 7b818237..59093428 100644 --- a/src/modules/powerswitch.py +++ b/src/modules/powerswitch.py @@ -57,6 +57,10 @@ def run(self, voice_command): self.processCommand(device, action) def processCommand(self, device, action): + if device.startswith('the '): + + device = device[4:] + if device in self.devices: code = int(self.config[device].get('code')) From 94279e0902fecf4a163b64df2dc0828a5ebdcd08 Mon Sep 17 00:00:00 2001 From: mpember Date: Mon, 11 Jun 2018 00:23:35 +1000 Subject: [PATCH 05/27] June Updates Podcast commands: + Add support for "play podcast" variation of command. * Resolve incorrect detection of empty feeds. * Resolve handling of "&" in podcast or episode name. Kodi commands: + Add "update tv shows" command to scan for new episodes. Power commands: * Improve command detection method MPD connection: * Handle timeout of MPD connection, when MPD is not used. --- src/main.py | 6 ++++++ src/modules/kodi.py | 2 +- src/modules/music.py | 26 ++++++++++++++++++++------ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/main.py b/src/main.py index 185c8686..7901f68c 100644 --- a/src/main.py +++ b/src/main.py @@ -139,6 +139,12 @@ def process_event(assistant, event): if _music.getConfirmPlayback() == True: assistant.start_conversation() + elif text.startswith('play ') and text.endswith(' podcast'): + assistant.stop_conversation() + _music.run('podcast', text[5:][:-8]) + if _music.getConfirmPlayback() == True: + assistant.start_conversation() + elif text.startswith('radio '): assistant.stop_conversation() _music.run('radio', text[6:]) diff --git a/src/modules/kodi.py b/src/modules/kodi.py index c598ee0f..a09c894a 100644 --- a/src/modules/kodi.py +++ b/src/modules/kodi.py @@ -96,7 +96,7 @@ def run(self, voice_command): result = self.kodi.Player.Stop(playerid=1) logging.info('Kodi response: ' + str(result)) - elif voice_command in ['play','pause','paws','resume']: + elif voice_command == 'play' or voice_command == 'pause' or voice_command == 'paws' or voice_command == 'resume': result = self.kodi.Player.PlayPause(playerid=1) logging.info('Kodi response: ' + str(result)) diff --git a/src/modules/music.py b/src/modules/music.py index 7bc73024..873d20d6 100644 --- a/src/modules/music.py +++ b/src/modules/music.py @@ -59,8 +59,12 @@ def run(self, module, voice_command): logging.info('Music stopped playing') self.mpd.clear() - self.mpd.close() - self.mpd.disconnect() + try: + self.mpd.close() + self.mpd.disconnect() + except ConnectionError: + logging.info('MPD connection timed out') + pass def playRadio(self, station): config = configparser.ConfigParser() @@ -112,7 +116,8 @@ def playPodcast(self, podcast): aiy.audio.say('Recent podcasts are') for title,url in podcasts.items(): podcastInfo = self.getPodcastItem(podcast, url, offset) - aiy.audio.say(title + ' uploaded an episode ' + str(int(podcastInfo['age']/24)) + ' days ago') + if not podcastInfo == None: + aiy.audio.say(title + ' uploaded an episode ' + str(int(podcastInfo['age']/24)) + ' days ago') return elif podcast == 'today': @@ -121,6 +126,15 @@ def playPodcast(self, podcast): podcastInfo = self.getPodcastItem(podcast, url, offset) if podcastInfo['age'] < 36: aiy.audio.say(title + ' uploaded an episode ' + str(int(podcastInfo['age'])) + ' hours ago') + self._cancelAction = True + return + + elif podcast == 'yesterday': + aiy.audio.say('Yesterday\'s podcasts are') + for title,url in podcasts.items(): + podcastInfo = self.getPodcastItem(podcast, url, offset) + if podcastInfo['age'] < 60 and podcastInfo['age'] > 36: + aiy.audio.say(title + ' uploaded an episode ' + str(int(podcastInfo['age'])) + ' hours ago') return elif podcast.startswith('previous '): @@ -176,20 +190,20 @@ def getPodcastItem(self, podcast, src, offset): logging.info('feed contains ' + str(resCount) + ' items') # exit out if empty - if resCount < offset or resCount == 0: + if not resCount > offset: logging.info(podcast + ' podcast feed is empty') aiy.audio.say('There are no episodes available of ' + podcast) return None if 'title' in rss.feed: - result['title'] = rss.feed.title + result['title'] = rss.feed.title.replace(" & ", " and ") rssItem = rss.entries[offset] # Extract infromation about requested item if 'title' in rssItem: - result['ep_title'] = rssItem.title + result['ep_title'] = rssItem.title.replace(" & ", " and ") if 'published_parsed' in rssItem: result['age'] = int((time.time() - time.mktime(rssItem['published_parsed'])) / 3600) From 0e3b78c079fd1bd2c844635e78041c18621c7033 Mon Sep 17 00:00:00 2001 From: mpember Date: Mon, 18 Jun 2018 03:16:46 +1000 Subject: [PATCH 06/27] Add files via upload --- src/main.py | 43 +++---- src/modules/kodi.py | 2 +- src/modules/music.py | 221 +++++++++++++++++++++++------------- src/modules/powercommand.py | 74 ++++++------ 4 files changed, 207 insertions(+), 133 deletions(-) diff --git a/src/main.py b/src/main.py index 7901f68c..441cce44 100644 --- a/src/main.py +++ b/src/main.py @@ -41,22 +41,18 @@ import configargparse from modules.kodi import KodiRemote -from modules.music import Music +from modules.music import Music, PodCatcher from modules.readrssfeed import ReadRssFeed from modules.powerswitch import PowerSwitch from modules.powercommand import PowerCommand -logging.basicConfig( - level=logging.INFO, - format="[%(asctime)s] %(levelname)s:%(name)s:%(message)s" -) - _configPath = os.path.expanduser('~/.config/voice-assistant.ini') _settingsPath = os.path.expanduser('~/.config/settings.ini') _remotePath = os.path.expanduser('~/.config/remotes.ini') _kodiRemote = KodiRemote(_settingsPath) _music = Music(_settingsPath) +_podCatcher = PodCatcher(_settingsPath) _readRssFeed = ReadRssFeed(_settingsPath) _powerSwitch = PowerSwitch(_remotePath) @@ -124,30 +120,30 @@ def process_event(assistant, event): elif _music.getConfirmPlayback() == True: assistant.stop_conversation() if text == 'yes': - _music.run('podcast', 'CONFIRM') + _music.command('podcast', 'CONFIRM') else: _music.setConfirmPlayback(False) _music.setPodcastURL(None) elif text.startswith('music '): assistant.stop_conversation() - _music.run('music', text[6:]) + _music.command('music', text[6:]) elif text.startswith('podcast '): assistant.stop_conversation() - _music.run('podcast', text[8:]) + _music.command('podcast', text[8:], _podCatcher) if _music.getConfirmPlayback() == True: assistant.start_conversation() elif text.startswith('play ') and text.endswith(' podcast'): assistant.stop_conversation() - _music.run('podcast', text[5:][:-8]) + _music.command('podcast', text[5:][:-8], _podCatcher) if _music.getConfirmPlayback() == True: assistant.start_conversation() elif text.startswith('radio '): assistant.stop_conversation() - _music.run('radio', text[6:]) + _music.command('radio', text[6:]) elif text.startswith('headlines '): assistant.stop_conversation() @@ -269,17 +265,26 @@ def main(): credentials = aiy.assistant.auth_helpers.get_assistant_credentials() model_id, device_id = aiy.assistant.device_helpers.get_ids_for_service(credentials) + _podCatcher.start() + with Assistant(credentials, model_id) as assistant: for event in assistant.start(): process_event(assistant, event) - if __name__ == '__main__': -# try: + try: + if sys.stdout.isatty(): + logging.basicConfig( + level=logging.INFO, + format="%(levelname)s:%(name)s:%(message)s" + ) + else: + logging.basicConfig( + level=logging.WARNING, + format="%(levelname)s:%(name)s:%(message)s" + ) + main() -# except KeyboardInterrupt: -# pass -# finally: -# if sys.stdout.isatty(): -# print('You pressed Ctrl+C') -# sys.exit(1) + except Error as e: + pass + diff --git a/src/modules/kodi.py b/src/modules/kodi.py index a09c894a..98ee5ad5 100644 --- a/src/modules/kodi.py +++ b/src/modules/kodi.py @@ -108,5 +108,5 @@ def run(self, voice_command): else: aiy.audio.say('Unrecognised Kodi command') - logging.info('Unrecognised Kodi request: ' + voice_command) + logging.warning('Unrecognised Kodi request: ' + voice_command) return diff --git a/src/modules/music.py b/src/modules/music.py index 873d20d6..feab2b65 100644 --- a/src/modules/music.py +++ b/src/modules/music.py @@ -3,12 +3,123 @@ import time import feedparser import threading +import sqlite3 -from mpd import MPDClient, MPDError, CommandError +from mpd import MPDClient, MPDError, CommandError, ConnectionError import aiy.audio import aiy.voicehat +class PodCatcher(threading.Thread): + def __init__(self, configpath): + """ Define variables used by object + """ + threading.Thread.__init__(self) + self.configPath = configpath + + def _connectDB(self): + try: + conn = sqlite3.connect('/tmp/podcasts.sqlite') + conn.cursor().execute(''' + CREATE TABLE IF NOT EXISTS podcasts ( + podcast TEXT NOT NULL, + title TEXT NOT NULL, + ep_title TEXT NOT NULL, + url TEXT UNIQUE NOT NULL, + timestamp INT NOT NULL);''') + return conn + + except Error as e: + print(e) + + return None + + def syncPodcasts(self, filter=None): + config = configparser.ConfigParser() + config.read(self.configPath) + podcasts = config['podcasts'] + + conn = self._connectDB() + cursor = conn.cursor() + + logging.info('Start updating podcast data') + for podcast,url in podcasts.items(): + if filter is not None and not podcast == filter: + continue + + logging.info('loading ' + podcast + ' podcast feed') + + rss = feedparser.parse(url) + + # get the total number of entries returned + resCount = len(rss.entries) + logging.info('feed contains ' + str(resCount) + ' items') + + # exit out if empty + if not resCount > 0: + logging.warning(podcast + ' podcast feed is empty') + continue + + for rssItem in rss.entries: + result = { + 'podcast':podcast, + 'url':None, + 'title':None, + 'ep_title':None, + 'timestamp':0 + } + + if 'title' in rss.feed: + result['title'] = rss.feed.title + + # Abstract information about requested item + + if 'title' in rssItem: + result['ep_title'] = rssItem.title + + if 'published_parsed' in rssItem: + result['timestamp'] = time.mktime(rssItem['published_parsed']) + + if 'enclosures' in rssItem and len(rssItem.enclosures) > 0: + result['url'] = rssItem.enclosures[0]['href'] + + elif 'media_content' in rssItem and len(rssItem.media_content) > 0: + result['url'] = rssItem.media_content[0]['url'] + + else: + logging.warning('The feed for "' + podcast + '" is in an unknown format') + continue + + cursor.execute('''REPLACE INTO podcasts(podcast, title, ep_title, url, timestamp) + VALUES(?, ?, ?, ?, ?)''', (result['podcast'], result['title'], result['ep_title'], result['url'], result['timestamp'])) + + conn.commit() + logging.info('Finished updating podcast data') + + def getPodcastInfo(self, podcast=None, offset=0): + if podcast is None: + return None + + logging.info('Searching for information about "' + str(podcast) + '" podcast') + conn = self._connectDB() + cursor = conn.cursor() + cursor.execute("SELECT url, title, ep_title, (strftime('%s','now') - strftime('%s', datetime(timestamp, 'unixepoch', 'localtime')))/3600 as age FROM podcasts WHERE podcast LIKE ? ORDER BY timestamp DESC LIMIT ?,1", (podcast,offset,)) + result = cursor.fetchone() + if (result): + return { + 'url':result[0], + 'title':result[1], + 'ep_title':result[2], + 'age':result[3] + } + + return None + + def run(self): + while True: + self.syncPodcasts() + time.sleep(1680) + class Music(object): """Interacts with MPD""" @@ -20,7 +131,7 @@ def __init__(self, configpath): self._podcastURL = None self.mpd = MPDClient(use_unicode=True) - def run(self, module, voice_command): + def command(self, module, voice_command, podcatcher=None): self.resetVariables() self.mpd.connect("localhost", 6600) @@ -39,7 +150,7 @@ def run(self, module, voice_command): self.playRadio(voice_command) elif module == 'podcast': - self.playPodcast(voice_command) + self.playPodcast(voice_command, podcatcher) if self._cancelAction == False: time.sleep(1) @@ -63,7 +174,7 @@ def run(self, module, voice_command): self.mpd.close() self.mpd.disconnect() except ConnectionError: - logging.info('MPD connection timed out') + logging.warning('MPD connection timed out') pass def playRadio(self, station): @@ -93,13 +204,16 @@ def playRadio(self, station): self.mpd.add(stations[station]) self.mpd.play() - def playPodcast(self, podcast): - + def playPodcast(self, podcast, podcatcher=None): config = configparser.ConfigParser() config.read(self.configPath) podcasts = config['podcasts'] + logging.info('playPodcast "' + podcast + "'") offset = 0 + if podcatcher is None: + logging.warning('playPodcast missing podcatcher object') + return if self._confirmPlayback == True: self._confirmPlayback = False @@ -109,32 +223,36 @@ def playPodcast(self, podcast): logging.info('Enumerating Podcasts') aiy.audio.say('Available podcasts are') for key in podcasts: - aiy.audio.say(key) + aiy.audio.say('' + key) return elif podcast == 'recent': aiy.audio.say('Recent podcasts are') for title,url in podcasts.items(): - podcastInfo = self.getPodcastItem(podcast, url, offset) - if not podcastInfo == None: - aiy.audio.say(title + ' uploaded an episode ' + str(int(podcastInfo['age']/24)) + ' days ago') + podcastInfo = podcatcher.getPodcastInfo(title, offset) + if podcastInfo is None: + continue + elif podcastInfo['age'] < 24: + aiy.audio.say('' + podcastInfo['title'] + ' uploaded an episode ' + str(int(podcastInfo['age'])) + ' hours ago') + else: + aiy.audio.say('' + podcastInfo['title'] + ' uploaded an episode ' + str(int(podcastInfo['age']/24)) + ' days ago') return elif podcast == 'today': aiy.audio.say('Today\'s podcasts are') for title,url in podcasts.items(): - podcastInfo = self.getPodcastItem(podcast, url, offset) - if podcastInfo['age'] < 36: - aiy.audio.say(title + ' uploaded an episode ' + str(int(podcastInfo['age'])) + ' hours ago') + podcastInfo = podcatcher.getPodcastInfo(title, offset) + if podcastInfo is not None and podcastInfo['age'] < 24: + aiy.audio.say('' + title + ' uploaded an episode ' + str(int(podcastInfo['age'])) + ' hours ago') self._cancelAction = True return elif podcast == 'yesterday': aiy.audio.say('Yesterday\'s podcasts are') for title,url in podcasts.items(): - podcastInfo = self.getPodcastItem(podcast, url, offset) - if podcastInfo['age'] < 60 and podcastInfo['age'] > 36: - aiy.audio.say(title + ' uploaded an episode ' + str(int(podcastInfo['age'])) + ' hours ago') + podcastInfo = podcatcher.getPodcastInfo(title, offset) + if podcastInfo is not None and podcastInfo['age'] < 48 and podcastInfo['age'] > 24: + aiy.audio.say('' + title + ' uploaded an episode ' + str(int(podcastInfo['age'])) + ' hours ago') return elif podcast.startswith('previous '): @@ -146,15 +264,14 @@ def playPodcast(self, podcast): aiy.audio.say('Podcast ' + podcast + ' not found') return - podcastInfo = self.getPodcastItem(podcast, podcasts[podcast], offset) + podcastInfo = podcatcher.getPodcastInfo(podcast, offset) if podcastInfo == None: - logging.info('Podcast failed to load') + logging.warning('Podcast data for "' + podcast + '" failed to load') return logging.info('Podcast Title: ' + podcastInfo['title']) logging.info('Episode Title: ' + podcastInfo['ep_title']) logging.info('Episode URL: ' + podcastInfo['url']) - logging.info('Episode Date: ' + podcastInfo['published']) - logging.info('Podcast Age: ' + str(podcastInfo['age']) + ' hours') + logging.info('Episode Age: ' + str(podcastInfo['age']) + ' hours') aiy.audio.say('Playing episode of ' + podcastInfo['title'] + ' titled ' + podcastInfo['ep_title']) @@ -166,65 +283,17 @@ def playPodcast(self, podcast): return None self._cancelAction = False - - self.mpd.clear() - self.mpd.add(self._podcastURL) - self.mpd.play() - - self._podcastURL = None - - def getPodcastItem(self, podcast, src, offset): - result = { - 'url':None, - 'title':None, - 'ep_title':None, - 'age':0, - 'published':None - } - - logging.info('loading ' + src + ' podcast feed') - rss = feedparser.parse(src) - - # get the total number of entries returned - resCount = len(rss.entries) - logging.info('feed contains ' + str(resCount) + ' items') - - # exit out if empty - if not resCount > offset: - logging.info(podcast + ' podcast feed is empty') - aiy.audio.say('There are no episodes available of ' + podcast) + if self._podcastURL is None: return None - if 'title' in rss.feed: - result['title'] = rss.feed.title.replace(" & ", " and ") - - rssItem = rss.entries[offset] - - # Extract infromation about requested item - - if 'title' in rssItem: - result['ep_title'] = rssItem.title.replace(" & ", " and ") - - if 'published_parsed' in rssItem: - result['age'] = int((time.time() - time.mktime(rssItem['published_parsed'])) / 3600) - - if 'published' in rssItem: - result['published'] = rssItem.published - - if 'enclosures' in rssItem and len(rssItem.enclosures) > 0: - logging.info(str(len(rssItem.enclosures)) + ' enclosures found') - result['url'] = rssItem.enclosures[0]['href'] - - elif 'media_content' in rssItem and len(rssItem.media_content) > 0: - logging.info(str(len(rssItem.media_content)) + ' media found') - result['url'] = rssItem.media_content[0]['url'] - - else: - logging.info(podcast + ' feed format is unknown') - aiy.audio.say('The feed for ' + podcast + ' is unknown format') - return None + try: + self.mpd.clear() + self.mpd.add(self._podcastURL) + self.mpd.play() + except ConnectionError as e: + aiy.audio.say('Error connecting to MPD service') - return result + self._podcastURL = None def _buttonPressCancel(self): self._cancelAction = True diff --git a/src/modules/powercommand.py b/src/modules/powercommand.py index fadcbe81..58ada957 100644 --- a/src/modules/powercommand.py +++ b/src/modules/powercommand.py @@ -1,37 +1,37 @@ -class PowerCommand(object): - - def __init__(self): - self._cancelAction = False - - def run(self, voice_command): - self.resetVariables() - - if voice_command == 'shutdown': - button = aiy.voicehat.get_button() - button.on_press(self._buttonPressCancel) - - p = subprocess.Popen(['/usr/bin/aplay',os.path.expanduser('~/.config/self-destruct.wav')],stdout=subprocess.PIPE,stderr=subprocess.PIPE) - - while p.poll() == None: - if self._cancelAction == True: - logging.info('shutdown cancelled by button press') - p.kill() - return - break - - time.sleep(0.1) - - time.sleep(1) - button.on_press(None) - logging.info('shutdown would have just happened') - subprocess.call('sudo shutdown now', shell=True) - - elif voice_command == 'reboot': - aiy.audio.say('Rebooting') - subprocess.call('sudo shutdown -r now', shell=True) - - def _buttonPressCancel(self): - self._cancelAction = True - - def resetVariables(self): - self._cancelAction = False +class PowerCommand(object): + + def __init__(self): + self._cancelAction = False + + def run(self, voice_command): + self.resetVariables() + + if voice_command == 'shutdown': + button = aiy.voicehat.get_button() + button.on_press(self._buttonPressCancel) + + p = subprocess.Popen(['/usr/bin/aplay',os.path.expanduser('~/.config/self-destruct.wav')],stdout=subprocess.PIPE,stderr=subprocess.PIPE) + + while p.poll() == None: + if self._cancelAction == True: + logging.info('shutdown cancelled by button press') + p.kill() + return + break + + time.sleep(0.1) + + time.sleep(1) + button.on_press(None) + logging.info('shutdown would have just happened') + subprocess.call('sudo shutdown now', shell=True) + + elif voice_command == 'reboot': + aiy.audio.say('Rebooting') + subprocess.call('sudo shutdown -r now', shell=True) + + def _buttonPressCancel(self): + self._cancelAction = True + + def resetVariables(self): + self._cancelAction = False From ddc0ffca20f0460f3016ee974d0028ecdafb9235 Mon Sep 17 00:00:00 2001 From: mpember Date: Mon, 18 Jun 2018 10:14:17 +1000 Subject: [PATCH 07/27] Tweak to Podcatcher Avoid redundant processing of existing podcast episodes. --- src/modules/music.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/modules/music.py b/src/modules/music.py index feab2b65..f66d75e0 100644 --- a/src/modules/music.py +++ b/src/modules/music.py @@ -93,6 +93,11 @@ def syncPodcasts(self, filter=None): cursor.execute('''REPLACE INTO podcasts(podcast, title, ep_title, url, timestamp) VALUES(?, ?, ?, ?, ?)''', (result['podcast'], result['title'], result['ep_title'], result['url'], result['timestamp'])) + """ Detect existance of episode and skip remaining content + """ + if cursor.rowcount == 0: + break + conn.commit() logging.info('Finished updating podcast data') From 91ffeb1c408bc0648d11c4db15ed997e33ca5f6b Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Fri, 22 Jun 2018 23:02:45 +1000 Subject: [PATCH 08/27] Simplify podcast handling --- src/main.py | 25 ++++--- src/modules/kodi.py | 2 +- src/modules/music.py | 121 ++++++++++++++----------------- systemd/voice-recognizer.service | 35 +++++---- 4 files changed, 89 insertions(+), 94 deletions(-) diff --git a/src/main.py b/src/main.py index 441cce44..be0aaf7d 100644 --- a/src/main.py +++ b/src/main.py @@ -56,16 +56,16 @@ _readRssFeed = ReadRssFeed(_settingsPath) _powerSwitch = PowerSwitch(_remotePath) -def _createPID(file_name): +def _createPID(pid_file='voice-recognizer.pid'): - if not file_name: - # Try the default locations of the pid file, preferring /run/user as - # it uses tmpfs. - pid_dir = '/run/user/%d' % os.getuid() - if not os.path.isdir(pid_dir): - pid_dir = '/tmp' - file_name = os.path.join(pid_dir, 'voice-recognizer.pid') + pid_dir = '/run/user/%d' % os.getuid() + if not os.path.isdir(pid_dir): + pid_dir = '/tmp' + + logging.info('PID stored in ' + pid_dir) + + file_name = os.path.join(pid_dir, pid_file) with open(file_name, 'w') as pid_file: pid_file.write("%d" % os.getpid()) @@ -246,7 +246,7 @@ def main(): description="Act on voice commands using Google's speech recognition") parser.add_argument('-L', '--language', default='en-GB', help='Language code to use for speech (default: en-GB)') - parser.add_argument('-p', '--pid-file', default='/tmp/voice-recognizer.pid', + parser.add_argument('-p', '--pid-file', default='voice-recognizer.pid', help='File containing our process id for monitoring') parser.add_argument('--trigger-sound', default=None, help='Sound when trigger is activated (WAV format)') @@ -254,6 +254,8 @@ def main(): help='Maximum LED brightness') parser.add_argument('--brightness-min', default=1, help='Minimum LED brightness') + parser.add_argument('-d', '--daemon', action='store_false', + help='Daemon Mode') args = parser.parse_args() @@ -265,7 +267,10 @@ def main(): credentials = aiy.assistant.auth_helpers.get_assistant_credentials() model_id, device_id = aiy.assistant.device_helpers.get_ids_for_service(credentials) - _podCatcher.start() + if args.daemon is True: + _podCatcher.start() + else: + logging.info("Starting in non-daemon mode") with Assistant(credentials, model_id) as assistant: for event in assistant.start(): diff --git a/src/modules/kodi.py b/src/modules/kodi.py index 98ee5ad5..446ada4b 100644 --- a/src/modules/kodi.py +++ b/src/modules/kodi.py @@ -43,7 +43,7 @@ def run(self, voice_command): try: self.kodi.JSONRPC.Ping() except: - aiy.audio.say(_('Unable to connect to client')) + aiy.audio.say('Unable to connect to client') return if voice_command.startswith('tv '): diff --git a/src/modules/music.py b/src/modules/music.py index f66d75e0..61bc1dcb 100644 --- a/src/modules/music.py +++ b/src/modules/music.py @@ -27,6 +27,7 @@ def _connectDB(self): ep_title TEXT NOT NULL, url TEXT UNIQUE NOT NULL, timestamp INT NOT NULL);''') + conn.row_factory = sqlite3.Row return conn except Error as e: @@ -43,11 +44,11 @@ def syncPodcasts(self, filter=None): cursor = conn.cursor() logging.info('Start updating podcast data') - for podcast,url in podcasts.items(): - if filter is not None and not podcast == filter: + for podcastID,url in podcasts.items(): + if filter is not None and not podcastID == filter: continue - logging.info('loading ' + podcast + ' podcast feed') + logging.info('loading ' + podcastID + ' podcast feed') rss = feedparser.parse(url) @@ -57,12 +58,12 @@ def syncPodcasts(self, filter=None): # exit out if empty if not resCount > 0: - logging.warning(podcast + ' podcast feed is empty') + logging.warning(podcastID + ' podcast feed is empty') continue for rssItem in rss.entries: result = { - 'podcast':podcast, + 'podcast':podcastID, 'url':None, 'title':None, 'ep_title':None, @@ -87,7 +88,7 @@ def syncPodcasts(self, filter=None): result['url'] = rssItem.media_content[0]['url'] else: - logging.warning('The feed for "' + podcast + '" is in an unknown format') + logging.warning('The feed for "' + podcastID + '" is in an unknown format') continue cursor.execute('''REPLACE INTO podcasts(podcast, title, ep_title, url, timestamp) @@ -101,24 +102,27 @@ def syncPodcasts(self, filter=None): conn.commit() logging.info('Finished updating podcast data') - def getPodcastInfo(self, podcast=None, offset=0): - if podcast is None: + def getPodcastInfo(self, podcastID=None, offset=0): + if podcastID is None: return None - logging.info('Searching for information about "' + str(podcast) + '" podcast') - conn = self._connectDB() - cursor = conn.cursor() - cursor.execute("SELECT url, title, ep_title, (strftime('%s','now') - strftime('%s', datetime(timestamp, 'unixepoch', 'localtime')))/3600 as age FROM podcasts WHERE podcast LIKE ? ORDER BY timestamp DESC LIMIT ?,1", (podcast,offset,)) - result = cursor.fetchone() - if (result): - return { - 'url':result[0], - 'title':result[1], - 'ep_title':result[2], - 'age':result[3] - } + logging.info('Searching for information about "' + str(podcastID) + '" podcast') - return None + cursor = self._connectDB().cursor() + if podcastID == 'today': + logging.info("SELECT podcast, url, title, ep_title, (strftime('%s','now') - strftime('%s', datetime(timestamp, 'unixepoch', 'localtime')))/3600 as age FROM podcasts WHERE age < 25 ORDER BY timestamp DESC") + cursor.execute("SELECT podcast, url, title, ep_title, (strftime('%s','now') - strftime('%s', datetime(timestamp, 'unixepoch', 'localtime')))/3600 as age FROM podcasts WHERE age < 25 ORDER BY timestamp DESC") + elif podcastID == 'yesterday': + logging.info("SELECT podcast, url, title, ep_title, (strftime('%s','now') - strftime('%s', datetime(timestamp, 'unixepoch', 'localtime')))/3600 as age FROM podcasts WHERE age < 49 AND age > 24 ORDER BY timestamp DESC") + cursor.execute("SELECT podcast, url, title, ep_title, (strftime('%s','now') - strftime('%s', datetime(timestamp, 'unixepoch', 'localtime')))/3600 as age FROM podcasts WHERE age < 49 AND age > 24 ORDER BY timestamp DESC") + else: + logging.info("SELECT url, title, ep_title, (strftime('%s','now') - strftime('%s', datetime(timestamp, 'unixepoch', 'localtime')))/3600 as age FROM podcasts WHERE podcast LIKE ? ORDER BY timestamp DESC LIMIT ?,1") + cursor.execute("SELECT url, title, ep_title, (strftime('%s','now') - strftime('%s', datetime(timestamp, 'unixepoch', 'localtime')))/3600 as age FROM podcasts WHERE podcast LIKE ? ORDER BY timestamp DESC LIMIT ?,1", (podcastID,offset,)) + result = cursor.fetchall() + + logging.info('Found "' + str(len(result)) + '" podcasts') + + return result def run(self): while True: @@ -209,11 +213,11 @@ def playRadio(self, station): self.mpd.add(stations[station]) self.mpd.play() - def playPodcast(self, podcast, podcatcher=None): + def playPodcast(self, podcastID, podcatcher=None): config = configparser.ConfigParser() config.read(self.configPath) podcasts = config['podcasts'] - logging.info('playPodcast "' + podcast + "'") + logging.info('playPodcast "' + podcastID + "'") offset = 0 if podcatcher is None: @@ -224,70 +228,53 @@ def playPodcast(self, podcast, podcatcher=None): self._confirmPlayback = False else: - if podcast == 'list': + if podcastID == 'list': logging.info('Enumerating Podcasts') aiy.audio.say('Available podcasts are') for key in podcasts: aiy.audio.say('' + key) return - elif podcast == 'recent': - aiy.audio.say('Recent podcasts are') - for title,url in podcasts.items(): - podcastInfo = podcatcher.getPodcastInfo(title, offset) - if podcastInfo is None: - continue - elif podcastInfo['age'] < 24: - aiy.audio.say('' + podcastInfo['title'] + ' uploaded an episode ' + str(int(podcastInfo['age'])) + ' hours ago') + elif podcastID in ['recent','today','yesterday']: + aiy.audio.say('Available podcasts are') + for podcast in podcatcher.getPodcastInfo(podcastID, offset): + if podcast['age'] < 49: + aiy.audio.say('' + podcast['podcast'] + ' uploaded an episode ' + str(int(podcast['age'])) + ' hours ago') else: - aiy.audio.say('' + podcastInfo['title'] + ' uploaded an episode ' + str(int(podcastInfo['age']/24)) + ' days ago') + aiy.audio.say('' + podcast['podcast'] + ' uploaded an episode ' + str(int(podcast['age']/24)) + ' days ago') return - elif podcast == 'today': - aiy.audio.say('Today\'s podcasts are') - for title,url in podcasts.items(): - podcastInfo = podcatcher.getPodcastInfo(title, offset) - if podcastInfo is not None and podcastInfo['age'] < 24: - aiy.audio.say('' + title + ' uploaded an episode ' + str(int(podcastInfo['age'])) + ' hours ago') - self._cancelAction = True - return + elif podcastID.startswith('previous '): + offset = 1 + podcastID = podcastID[9:] - elif podcast == 'yesterday': - aiy.audio.say('Yesterday\'s podcasts are') - for title,url in podcasts.items(): - podcastInfo = podcatcher.getPodcastInfo(title, offset) - if podcastInfo is not None and podcastInfo['age'] < 48 and podcastInfo['age'] > 24: - aiy.audio.say('' + title + ' uploaded an episode ' + str(int(podcastInfo['age'])) + ' hours ago') + if podcastID not in podcasts: + logging.info('Podcast not found: ' + podcastID) + aiy.audio.say('Podcast ' + podcastID + ' not found') return - elif podcast.startswith('previous '): - offset = 1 - podcast = podcast[9:] - - if podcast not in podcasts: - logging.info('Podcast not found: ' + podcast) - aiy.audio.say('Podcast ' + podcast + ' not found') + podcastInfo = podcatcher.getPodcastInfo(podcastID, offset) + if len(podcastInfo) == 0: return - podcastInfo = podcatcher.getPodcastInfo(podcast, offset) if podcastInfo == None: logging.warning('Podcast data for "' + podcast + '" failed to load') return - logging.info('Podcast Title: ' + podcastInfo['title']) - logging.info('Episode Title: ' + podcastInfo['ep_title']) - logging.info('Episode URL: ' + podcastInfo['url']) - logging.info('Episode Age: ' + str(podcastInfo['age']) + ' hours') - - aiy.audio.say('Playing episode of ' + podcastInfo['title'] + ' titled ' + podcastInfo['ep_title']) - - self._podcastURL = podcastInfo['url'] - - if (podcastInfo['age'] > 336): - aiy.audio.say('This episode is ' + str(int(podcastInfo['age']/24)) + ' days old. Do you still want to play it?') + logging.info('Podcast Title: ' + podcastInfo[0]['title']) + logging.info('Episode Title: ' + podcastInfo[0]['ep_title']) + logging.info('Episode URL: ' + podcastInfo[0]['url']) + logging.info('Episode Age: ' + str(podcastInfo[0]['age']) + ' hours') + + aiy.audio.say('Playing episode of ' + podcastInfo[0]['title'] + ' titled ' + podcastInfo[0]['ep_title']) + if (podcastInfo[0]['age'] > 336): + aiy.audio.say('This episode is ' + str(int(podcastInfo[0]['age']/24)) + ' days old. Do you still want to play it?') self._confirmPlayback = True return None - self._cancelAction = False + self._cancelAction = False + + self._podcastURL = podcastInfo[0]['url'] + if self._podcastURL is None: return None diff --git a/systemd/voice-recognizer.service b/systemd/voice-recognizer.service index 15878de0..cb265791 100644 --- a/systemd/voice-recognizer.service +++ b/systemd/voice-recognizer.service @@ -1,16 +1,19 @@ -[Unit] -Description=Google AIY Voice -After=network.target ntpdate.service - -[Service] -Environment=VIRTUAL_ENV=/mnt/dietpi_userdata/voice-recognizer-raspi/env -Environment=PATH=/mnt/dietpi_userdata/voice-recognizer-raspi/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -ExecStart=/mnt/dietpi_userdata/voice-recognizer-raspi/env/bin/python3 -u src/main.py -WorkingDirectory=/mnt/dietpi_userdata/voice-recognizer-raspi -StandardOutput=inherit -StandardError=inherit -Restart=always -User=dietpi - -[Install] -WantedBy=multi-user.target +s service can be used to run your code automatically on startup. Look in +# HACKING.md for instructions on creating main.py and enabling it. + +[Unit] +Description=voice recognizer +After=network.target ntpdate.service + +[Service] +Environment=VIRTUAL_ENV=/home/pi/AIY-projects-python/env +Environment=PATH=/home/pi/AIY-projects-python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ExecStart=/home/pi/AIY-projects-python/env/bin/python3 -u src/main.py +WorkingDirectory=/home/pi/AIY-projects-python +StandardOutput=inherit +StandardError=inherit +Restart=always +User=pi + +[Install] +WantedBy=multi-user.target From ea4c055ba27965f09f40c6a778b18147c577c87c Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Fri, 22 Jun 2018 23:09:04 +1000 Subject: [PATCH 09/27] Disable dark beacon --- src/aiy/_drivers/_led.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aiy/_drivers/_led.py b/src/aiy/_drivers/_led.py index 58d26bb6..da7db7f9 100644 --- a/src/aiy/_drivers/_led.py +++ b/src/aiy/_drivers/_led.py @@ -35,7 +35,7 @@ class LED: BLINK = 2 BLINK_3 = 3 BEACON = 4 - BEACON_DARK = 5 + BEACON_DARK = 0 DECAY = 6 PULSE_SLOW = 7 PULSE_QUICK = 8 From cf71c84b918a9ae4c8e131bf7b3a1bcd8e1c1e66 Mon Sep 17 00:00:00 2001 From: mpember Date: Fri, 29 Jun 2018 03:16:47 +1000 Subject: [PATCH 10/27] MQTT migration Power commands are now sent to MQTT broker --- src/main.py | 2 +- src/modules/mqtt.py | 33 ++++++++++++++++++++++++++++ src/modules/powerswitch.py | 44 ++++++++++---------------------------- 3 files changed, 45 insertions(+), 34 deletions(-) create mode 100644 src/modules/mqtt.py diff --git a/src/main.py b/src/main.py index be0aaf7d..8a8c86fb 100644 --- a/src/main.py +++ b/src/main.py @@ -54,7 +54,7 @@ _music = Music(_settingsPath) _podCatcher = PodCatcher(_settingsPath) _readRssFeed = ReadRssFeed(_settingsPath) -_powerSwitch = PowerSwitch(_remotePath) +_powerSwitch = PowerSwitch(_settingsPath, _remotePath) def _createPID(pid_file='voice-recognizer.pid'): diff --git a/src/modules/mqtt.py b/src/modules/mqtt.py new file mode 100644 index 00000000..342acef6 --- /dev/null +++ b/src/modules/mqtt.py @@ -0,0 +1,33 @@ +import configparser +import logging +import time + +import paho.mqtt.publish as publish + +class Mosquitto(object): + + """Publish MQTT""" + + def __init__(self, configpath): + self.configPath = configpath + config = configparser.ConfigParser() + config.read(self.configPath) + + self.mqtt_host = config['mqtt'].get('host') + self.mqtt_port = config['mqtt'].getint('port', 1883) + self.mqtt_username = config['mqtt'].get('username') + self.mqtt_password = config['mqtt'].get('password') + + def command(self, topic=None, message=None): + + config = configparser.ConfigParser() + config.read(self.configPath) + + publish.single(topic, payload=message, + hostname=self.mqtt_host, + port=self.mqtt_port, + auth={'username':self.mqtt_username, + 'password':self.mqtt_password}) + + def resetVariables(self): + self._cancelAction = False diff --git a/src/modules/powerswitch.py b/src/modules/powerswitch.py index 59093428..4c877275 100644 --- a/src/modules/powerswitch.py +++ b/src/modules/powerswitch.py @@ -1,10 +1,9 @@ import configparser import logging -import urllib.request import aiy.audio -from rpi_rf import RFDevice +from modules.mqtt import Mosquitto # PowerSwitch: Send HTTP command to RF switch website # ================================ @@ -14,22 +13,18 @@ class PowerSwitch(object): """ Control power sockets""" - def __init__(self, configPath): - self.configPath = configPath + def __init__(self, configPath, remotePath): + self.remotePath = remotePath + self.mqtt = Mosquitto(configPath) def run(self, voice_command): self.config = configparser.ConfigParser() - self.config.read(self.configPath) + self.config.read(self.remotePath) self.devices = self.config.sections() devices = None action = None - if 'GPIO' not in self.config: - aiy.audio.say('No G P I O settings found') - logging.info('No GPIO settings found') - return - if voice_command == 'list': logging.info('Enumerating switchable devices') aiy.audio.say('Available switches are') @@ -51,7 +46,7 @@ def run(self, voice_command): logging.info('Unrecognised command: ' + device) return - if (action is not None): + if action is not None: for device in devices: logging.info('Processing switch request for ' + device) self.processCommand(device, action) @@ -65,31 +60,14 @@ def processCommand(self, device, action): code = int(self.config[device].get('code')) - if (int(self.config['GPIO'].get('output', -1)) > 0): - if (self.config[device].get('toggle', False)): - logging.info('Power switch is a toggle') - - elif action == 'off': - code = code - 8; + if action == 'off': + code = code - 8; - logging.info('Code to send: ' + str(code)) + logging.info('Code to send: ' + str(code)) - rfdevice = RFDevice(int(self.config['GPIO'].get('output', -1))) - rfdevice.enable_tx() - rfdevice.tx_code(code, 1, 380) - rfdevice.cleanup() - - elif (self.config['GPIO'].get('url', False)): - url = self.config['GPIO'].get('url', False) - logging.info('URL to send request: ' + str(url) + '?code=' + str(code) + '&action=' + action) - logging.info('Code to send via URL: ' + str(code)) - with urllib.request.urlopen(str(url) + '?code=' + str(code) + '&action=' + action) as response: - html = response.read() - - else: - aiy.audio.say('G P I O settings invalid') - logging.info('No valid GPIO settings found') + self.mqtt.command('/rf-power/code', code) else: aiy.audio.say('Unrecognised switch') logging.info('Unrecognised device: ' + device) + From 096aab7d4fa041b837ed424bc080c1b9ef834c65 Mon Sep 17 00:00:00 2001 From: mpember Date: Sat, 30 Jun 2018 23:31:55 +1000 Subject: [PATCH 11/27] Podcast updates Add ability to press button to stop podcast list action --- src/modules/music.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/modules/music.py b/src/modules/music.py index 61bc1dcb..d157e491 100644 --- a/src/modules/music.py +++ b/src/modules/music.py @@ -237,11 +237,20 @@ def playPodcast(self, podcastID, podcatcher=None): elif podcastID in ['recent','today','yesterday']: aiy.audio.say('Available podcasts are') + button = aiy.voicehat.get_button() + button.on_press(self._buttonPressCancel) + self._cancelAction = False + for podcast in podcatcher.getPodcastInfo(podcastID, offset): - if podcast['age'] < 49: + if self._cancelAction: + break + elif podcast['age'] < 49: aiy.audio.say('' + podcast['podcast'] + ' uploaded an episode ' + str(int(podcast['age'])) + ' hours ago') else: aiy.audio.say('' + podcast['podcast'] + ' uploaded an episode ' + str(int(podcast['age']/24)) + ' days ago') + + button.on_press(None) + self._cancelAction = False return elif podcastID.startswith('previous '): From 88a6d32035200deb3020257b8cdf77fbe2d5ee8c Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Tue, 17 Jul 2018 00:03:08 +1000 Subject: [PATCH 12/27] Improve error handling in MQTT --- src/main.py | 2 +- src/modules/mqtt.py | 15 +++++++++------ src/modules/powercommand.py | 6 ++++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/main.py b/src/main.py index 8a8c86fb..4fbe30ab 100644 --- a/src/main.py +++ b/src/main.py @@ -290,6 +290,6 @@ def main(): ) main() - except Error as e: + except: pass diff --git a/src/modules/mqtt.py b/src/modules/mqtt.py index 342acef6..2c0db340 100644 --- a/src/modules/mqtt.py +++ b/src/modules/mqtt.py @@ -19,15 +19,18 @@ def __init__(self, configpath): self.mqtt_password = config['mqtt'].get('password') def command(self, topic=None, message=None): - config = configparser.ConfigParser() config.read(self.configPath) - publish.single(topic, payload=message, - hostname=self.mqtt_host, - port=self.mqtt_port, - auth={'username':self.mqtt_username, - 'password':self.mqtt_password}) + try: + publish.single(topic, payload=message, + hostname=self.mqtt_host, + port=self.mqtt_port, + auth={'username':self.mqtt_username, + 'password':self.mqtt_password}) + except: + logging.error("Error sending MQTT message") + pass def resetVariables(self): self._cancelAction = False diff --git a/src/modules/powercommand.py b/src/modules/powercommand.py index 58ada957..42cba156 100644 --- a/src/modules/powercommand.py +++ b/src/modules/powercommand.py @@ -1,3 +1,9 @@ +import logging +import time + +import aiy.audio +import aiy.voicehat + class PowerCommand(object): def __init__(self): From c575ab09913cd3379001dc6468839265f0f62041 Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Thu, 19 Jul 2018 22:07:26 +1000 Subject: [PATCH 13/27] Tweak logging level for powerswitch module --- src/modules/powerswitch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/powerswitch.py b/src/modules/powerswitch.py index 4c877275..79349862 100644 --- a/src/modules/powerswitch.py +++ b/src/modules/powerswitch.py @@ -43,7 +43,7 @@ def run(self, voice_command): else: aiy.audio.say('Unrecognised command') - logging.info('Unrecognised command: ' + device) + logging.warning('Unrecognised command: ' + device) return if action is not None: @@ -69,5 +69,5 @@ def processCommand(self, device, action): else: aiy.audio.say('Unrecognised switch') - logging.info('Unrecognised device: ' + device) + logging.warning('Unrecognised device: ' + device) From b8de7200a65fd355696f92875ecb8bc18e4af124 Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Thu, 19 Jul 2018 22:33:40 +1000 Subject: [PATCH 14/27] Resolve toggle support for powerswitch module --- src/modules/powerswitch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/powerswitch.py b/src/modules/powerswitch.py index 79349862..6fd6497f 100644 --- a/src/modules/powerswitch.py +++ b/src/modules/powerswitch.py @@ -58,9 +58,9 @@ def processCommand(self, device, action): if device in self.devices: - code = int(self.config[device].get('code')) + code = self.config[device].getint('code') - if action == 'off': + if action == 'off' and self.config[device].get('toggle', fallback=False) is not True: code = code - 8; logging.info('Code to send: ' + str(code)) From b634c925270942bb9d00a3b6305b43e8bedb9b5e Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Fri, 3 Aug 2018 21:34:38 +1000 Subject: [PATCH 15/27] Resolve toggle support for powerswitch module --- src/modules/powerswitch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/powerswitch.py b/src/modules/powerswitch.py index 6fd6497f..82c1b7a7 100644 --- a/src/modules/powerswitch.py +++ b/src/modules/powerswitch.py @@ -60,7 +60,7 @@ def processCommand(self, device, action): code = self.config[device].getint('code') - if action == 'off' and self.config[device].get('toggle', fallback=False) is not True: + if action == 'off' and self.config[device].getboolean('toggle', fallback=False) is not True: code = code - 8; logging.info('Code to send: ' + str(code)) From 1cf3d1e9211ba47924da81663bbb5750fda117d0 Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Mon, 6 Aug 2018 02:25:48 +1000 Subject: [PATCH 16/27] Modify daemon-mode settings --- src/main.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main.py b/src/main.py index 4fbe30ab..a3df72ba 100644 --- a/src/main.py +++ b/src/main.py @@ -104,6 +104,13 @@ def process_event(assistant, event): elif event.type == EventType.ON_CONVERSATION_TURN_STARTED: status_ui.status('listening') + elif event.type == EventType.ON_ALERT_STARTED and event.args: + logging.warning('An alert just started, type = ' + str(event.args['alert_type'])) + assistant.stop_conversation() + + elif event.type == EventType.ON_ALERT_FINISHED and event.args: + logging.warning('An alert just finished, type = ' + str(event.args['alert_type'])) + elif event.type == EventType.ON_RECOGNIZING_SPEECH_FINISHED and event.args: _cancelAction = False @@ -254,7 +261,7 @@ def main(): help='Maximum LED brightness') parser.add_argument('--brightness-min', default=1, help='Minimum LED brightness') - parser.add_argument('-d', '--daemon', action='store_false', + parser.add_argument('-d', '--daemon', action='store_true', help='Daemon Mode') args = parser.parse_args() @@ -267,7 +274,7 @@ def main(): credentials = aiy.assistant.auth_helpers.get_assistant_credentials() model_id, device_id = aiy.assistant.device_helpers.get_ids_for_service(credentials) - if args.daemon is True: + if args.daemon is True or sys.stdout.isatty() is not True: _podCatcher.start() else: logging.info("Starting in non-daemon mode") From 242b69fb934c724fb54c988680e85316468afaad Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Tue, 14 Aug 2018 02:08:44 +1000 Subject: [PATCH 17/27] Update status LED brightness controls --- src/aiy/_drivers/_led.py | 23 ++++++++++++----------- src/main.py | 15 ++++++++++----- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/aiy/_drivers/_led.py b/src/aiy/_drivers/_led.py index da7db7f9..a6eb4e71 100644 --- a/src/aiy/_drivers/_led.py +++ b/src/aiy/_drivers/_led.py @@ -17,6 +17,7 @@ import itertools import threading import time +import math import RPi.GPIO as GPIO @@ -81,7 +82,7 @@ def stop(self): def set_brightness(self, brightness): try: - self.brightness = int(brightness) + self.brightness = int(brightness)/100 except ValueError: raise ValueError('unsupported brightness: %s' % brightness) @@ -107,7 +108,7 @@ def _animate(self): if not self._parse_state(state): raise ValueError('unsupported state: %d' % state) if self.iterator: - self.pwm.ChangeDutyCycle(next(self.iterator) * self.brightness) + self.pwm.ChangeDutyCycle(next(self.iterator)) time.sleep(self.sleep) else: # We can also wait for a state change here with a Condition. @@ -119,41 +120,41 @@ def _parse_state(self, state): handled = False if state == self.OFF: - self.pwm.ChangeDutyCycle(0 * self.brightness) + self.pwm.ChangeDutyCycle(0) handled = True elif state == self.ON: - self.pwm.ChangeDutyCycle(100 * self.brightness) + self.pwm.ChangeDutyCycle(100) handled = True elif state == self.BLINK: - self.iterator = itertools.cycle([0, 100]) + self.iterator = itertools.cycle([0, int(100 * self.brightness)]) self.sleep = 0.5 handled = True elif state == self.BLINK_3: - self.iterator = itertools.cycle([0, 100] * 3 + [0, 0]) + self.iterator = itertools.cycle([0, int(100 * self.brightness)] * 3 + [0, 0]) self.sleep = 0.25 handled = True elif state == self.BEACON: self.iterator = itertools.cycle( - itertools.chain([30] * 100, [100] * 8, range(100, 30, -5))) + itertools.chain([int(30 * self.brightness)] * 100, [int(100 * self.brightness)] * 8, range(int(100 * self.brightness), int(30 * self.brightness), 0 - math.ceil(4 * self.brightness)))) self.sleep = 0.05 handled = True elif state == self.BEACON_DARK: self.iterator = itertools.cycle( - itertools.chain([0] * 100, range(0, 30, 3), range(30, 0, -3))) + itertools.chain([0] * 100, range(0, int(30 * self.brightness), math.ceil(2 * self.brightness), range(int(30 * self.brightness), 0, 0 - math.ceil(2 * self.brightness))))) self.sleep = 0.05 handled = True elif state == self.DECAY: - self.iterator = itertools.cycle(range(100, 0, -2)) + self.iterator = self.iterator = itertools.cycle(range(int(100 * self.brightness), 0, int(math.ceil(-2 * self.brightness)))) self.sleep = 0.05 handled = True elif state == self.PULSE_SLOW: self.iterator = itertools.cycle( - itertools.chain(range(0, 100, 2), range(100, 0, -2))) + itertools.chain(range(0, int(100 * self.brightness), math.ceil(2 * self.brightness)), range(int(100 * self.brightness), 0, 0 - math.ceil(2 * self.brightness)))) self.sleep = 0.1 handled = True elif state == self.PULSE_QUICK: self.iterator = itertools.cycle( - itertools.chain(range(0, 100, 5), range(100, 0, -5))) + itertools.chain(range(0, int(100 * self.brightness), math.ceil(4 * self.brightness)), range(int(100 * self.brightness), 0, 0 - math.ceil(4 * self.brightness)))) self.sleep = 0.05 handled = True diff --git a/src/main.py b/src/main.py index a3df72ba..90be4045 100644 --- a/src/main.py +++ b/src/main.py @@ -228,11 +228,15 @@ def process_event(assistant, event): assistant.stop_conversation() _volumeCommand(30) - elif text == 'brightness low': + elif text in ['brightness low', 'set brightness low']: assistant.stop_conversation() - aiy.voicehat.get_led().set_brightness(10) + aiy.voicehat.get_led().set_brightness(40) - elif text == 'brightness high': + elif text in ['brightness medium', 'set brightness medium']: + assistant.stop_conversation() + aiy.voicehat.get_led().set_brightness(70) + + elif text in ['brightness high', 'set brightness high', 'brightness full', 'set brightness full']: assistant.stop_conversation() aiy.voicehat.get_led().set_brightness(100) @@ -257,9 +261,9 @@ def main(): help='File containing our process id for monitoring') parser.add_argument('--trigger-sound', default=None, help='Sound when trigger is activated (WAV format)') - parser.add_argument('--brightness-max', default=1, + parser.add_argument('--brightness-max', type=int, default=1, help='Maximum LED brightness') - parser.add_argument('--brightness-min', default=1, + parser.add_argument('--brightness-min', type=int, default=1, help='Minimum LED brightness') parser.add_argument('-d', '--daemon', action='store_true', help='Daemon Mode') @@ -269,6 +273,7 @@ def main(): aiy.i18n.set_language_code(args.language) _createPID(args.pid_file) + logging.info('Setting brightness to %d %%' % args.brightness_max) aiy.voicehat.get_led().set_brightness(args.brightness_max) credentials = aiy.assistant.auth_helpers.get_assistant_credentials() From 78528ad48e663cc3b7750805fd13a5b89b1df389 Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Wed, 22 Aug 2018 23:57:07 +1000 Subject: [PATCH 18/27] Add flexibility to list podcast today or todays's --- src/main.py | 6 ++++++ src/modules/music.py | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main.py b/src/main.py index 90be4045..7145215a 100644 --- a/src/main.py +++ b/src/main.py @@ -148,6 +148,12 @@ def process_event(assistant, event): if _music.getConfirmPlayback() == True: assistant.start_conversation() + elif text.startswith('play ') and text.endswith(' podcasts'): + assistant.stop_conversation() + _music.command('podcast', text[5:][:-9], _podCatcher) + if _music.getConfirmPlayback() == True: + assistant.start_conversation() + elif text.startswith('radio '): assistant.stop_conversation() _music.command('radio', text[6:]) diff --git a/src/modules/music.py b/src/modules/music.py index d157e491..2ab79097 100644 --- a/src/modules/music.py +++ b/src/modules/music.py @@ -109,7 +109,7 @@ def getPodcastInfo(self, podcastID=None, offset=0): logging.info('Searching for information about "' + str(podcastID) + '" podcast') cursor = self._connectDB().cursor() - if podcastID == 'today': + if podcastID in ['today', 'today\'s']: logging.info("SELECT podcast, url, title, ep_title, (strftime('%s','now') - strftime('%s', datetime(timestamp, 'unixepoch', 'localtime')))/3600 as age FROM podcasts WHERE age < 25 ORDER BY timestamp DESC") cursor.execute("SELECT podcast, url, title, ep_title, (strftime('%s','now') - strftime('%s', datetime(timestamp, 'unixepoch', 'localtime')))/3600 as age FROM podcasts WHERE age < 25 ORDER BY timestamp DESC") elif podcastID == 'yesterday': @@ -235,7 +235,7 @@ def playPodcast(self, podcastID, podcatcher=None): aiy.audio.say('' + key) return - elif podcastID in ['recent','today','yesterday']: + elif podcastID in ['recent','today','today\'s','yesterday']: aiy.audio.say('Available podcasts are') button = aiy.voicehat.get_button() button.on_press(self._buttonPressCancel) From 2292a26c2201d904cfe310959254a1b7cdd267fa Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Sun, 21 Oct 2018 00:12:25 +1100 Subject: [PATCH 19/27] Improve tv-related voice command syntax --- src/main.py | 52 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/src/main.py b/src/main.py index 7145215a..aa7bfb56 100644 --- a/src/main.py +++ b/src/main.py @@ -116,10 +116,7 @@ def process_event(assistant, event): _cancelAction = False text = event.args['text'].lower() - if sys.stdout.isatty(): - print('You said:', text) - else: - logging.info('You said: ' + text) + logging.info('You said: ' + text) if text == '': assistant.stop_conversation() @@ -143,16 +140,18 @@ def process_event(assistant, event): assistant.start_conversation() elif text.startswith('play ') and text.endswith(' podcast'): - assistant.stop_conversation() - _music.command('podcast', text[5:][:-8], _podCatcher) - if _music.getConfirmPlayback() == True: - assistant.start_conversation() + if sys.stdout.isatty() is not True: + assistant.stop_conversation() + _music.command('podcast', text[5:][:-8], _podCatcher) + if _music.getConfirmPlayback() == True: + assistant.start_conversation() elif text.startswith('play ') and text.endswith(' podcasts'): - assistant.stop_conversation() - _music.command('podcast', text[5:][:-9], _podCatcher) - if _music.getConfirmPlayback() == True: - assistant.start_conversation() + if sys.stdout.isatty() is not True: + assistant.stop_conversation() + _music.command('podcast', text[5:][:-9], _podCatcher) + if _music.getConfirmPlayback() == True: + assistant.start_conversation() elif text.startswith('radio '): assistant.stop_conversation() @@ -182,6 +181,10 @@ def process_event(assistant, event): assistant.stop_conversation() _kodiRemote.run(text[5:]) + elif text.startswith('play the next episode of '): + assistant.stop_conversation() + _kodiRemote.run('play unwatched ' + text[25:]) + elif text.startswith('play next episode of '): assistant.stop_conversation() _kodiRemote.run('play unwatched ' + text[21:]) @@ -246,6 +249,20 @@ def process_event(assistant, event): assistant.stop_conversation() aiy.voicehat.get_led().set_brightness(100) + elif event.type == EventType.ON_RESPONDING_FINISHED: + assistant.stop_conversation() + logging.info('EventType.ON_RESPONDING_FINISHED') + + elif event.type == EventType.ON_MEDIA_TRACK_LOAD: + assistant.stop_conversation() + logging.info('EventType.ON_MEDIA_TRACK_LOAD') + logging.info(event.args) + + elif event.type == EventType.ON_MEDIA_TRACK_PLAY: + assistant.stop_conversation() + logging.info('EventType.ON_MEDIA_TRACK_PLAY') + logging.info(event.args) + elif event.type == EventType.ON_END_OF_UTTERANCE: status_ui.status('thinking') @@ -253,8 +270,19 @@ def process_event(assistant, event): status_ui.status('ready') elif event.type == EventType.ON_ASSISTANT_ERROR and event.args and event.args['is_fatal']: + logging.info('EventType.ON_ASSISTANT_ERROR') sys.exit(1) + elif event.type == EventType.ON_ASSISTANT_ERROR: + logging.info('EventType.ON_ASSISTANT_ERROR') + + elif event.args: + logging.info(event.type) + logging.info(event.args) + + else: + logging.info(event.type) + def main(): From 01b678990beb32b492b97f59be17bccd659af471 Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Sun, 21 Oct 2018 01:14:35 +1100 Subject: [PATCH 20/27] Improve tv-related voice command syntax --- src/main.py | 52 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/src/main.py b/src/main.py index 7145215a..aa7bfb56 100644 --- a/src/main.py +++ b/src/main.py @@ -116,10 +116,7 @@ def process_event(assistant, event): _cancelAction = False text = event.args['text'].lower() - if sys.stdout.isatty(): - print('You said:', text) - else: - logging.info('You said: ' + text) + logging.info('You said: ' + text) if text == '': assistant.stop_conversation() @@ -143,16 +140,18 @@ def process_event(assistant, event): assistant.start_conversation() elif text.startswith('play ') and text.endswith(' podcast'): - assistant.stop_conversation() - _music.command('podcast', text[5:][:-8], _podCatcher) - if _music.getConfirmPlayback() == True: - assistant.start_conversation() + if sys.stdout.isatty() is not True: + assistant.stop_conversation() + _music.command('podcast', text[5:][:-8], _podCatcher) + if _music.getConfirmPlayback() == True: + assistant.start_conversation() elif text.startswith('play ') and text.endswith(' podcasts'): - assistant.stop_conversation() - _music.command('podcast', text[5:][:-9], _podCatcher) - if _music.getConfirmPlayback() == True: - assistant.start_conversation() + if sys.stdout.isatty() is not True: + assistant.stop_conversation() + _music.command('podcast', text[5:][:-9], _podCatcher) + if _music.getConfirmPlayback() == True: + assistant.start_conversation() elif text.startswith('radio '): assistant.stop_conversation() @@ -182,6 +181,10 @@ def process_event(assistant, event): assistant.stop_conversation() _kodiRemote.run(text[5:]) + elif text.startswith('play the next episode of '): + assistant.stop_conversation() + _kodiRemote.run('play unwatched ' + text[25:]) + elif text.startswith('play next episode of '): assistant.stop_conversation() _kodiRemote.run('play unwatched ' + text[21:]) @@ -246,6 +249,20 @@ def process_event(assistant, event): assistant.stop_conversation() aiy.voicehat.get_led().set_brightness(100) + elif event.type == EventType.ON_RESPONDING_FINISHED: + assistant.stop_conversation() + logging.info('EventType.ON_RESPONDING_FINISHED') + + elif event.type == EventType.ON_MEDIA_TRACK_LOAD: + assistant.stop_conversation() + logging.info('EventType.ON_MEDIA_TRACK_LOAD') + logging.info(event.args) + + elif event.type == EventType.ON_MEDIA_TRACK_PLAY: + assistant.stop_conversation() + logging.info('EventType.ON_MEDIA_TRACK_PLAY') + logging.info(event.args) + elif event.type == EventType.ON_END_OF_UTTERANCE: status_ui.status('thinking') @@ -253,8 +270,19 @@ def process_event(assistant, event): status_ui.status('ready') elif event.type == EventType.ON_ASSISTANT_ERROR and event.args and event.args['is_fatal']: + logging.info('EventType.ON_ASSISTANT_ERROR') sys.exit(1) + elif event.type == EventType.ON_ASSISTANT_ERROR: + logging.info('EventType.ON_ASSISTANT_ERROR') + + elif event.args: + logging.info(event.type) + logging.info(event.args) + + else: + logging.info(event.type) + def main(): From 71fd405ced4e28f4db662eeab6c4fe7f917cab59 Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Sun, 21 Oct 2018 01:22:37 +1100 Subject: [PATCH 21/27] Remove debug code in podcast handling --- src/main.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main.py b/src/main.py index aa7bfb56..cdd34a7a 100644 --- a/src/main.py +++ b/src/main.py @@ -140,11 +140,10 @@ def process_event(assistant, event): assistant.start_conversation() elif text.startswith('play ') and text.endswith(' podcast'): - if sys.stdout.isatty() is not True: - assistant.stop_conversation() - _music.command('podcast', text[5:][:-8], _podCatcher) - if _music.getConfirmPlayback() == True: - assistant.start_conversation() + assistant.stop_conversation() + _music.command('podcast', text[5:][:-8], _podCatcher) + if _music.getConfirmPlayback() == True: + assistant.start_conversation() elif text.startswith('play ') and text.endswith(' podcasts'): if sys.stdout.isatty() is not True: From 72bcfb73f752bfd552eb58feecc5c2dbc2bf6df2 Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Sun, 21 Oct 2018 01:24:27 +1100 Subject: [PATCH 22/27] Remove missed debug code in podcast handling --- src/main.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main.py b/src/main.py index cdd34a7a..d828faa8 100644 --- a/src/main.py +++ b/src/main.py @@ -146,11 +146,10 @@ def process_event(assistant, event): assistant.start_conversation() elif text.startswith('play ') and text.endswith(' podcasts'): - if sys.stdout.isatty() is not True: - assistant.stop_conversation() - _music.command('podcast', text[5:][:-9], _podCatcher) - if _music.getConfirmPlayback() == True: - assistant.start_conversation() + assistant.stop_conversation() + _music.command('podcast', text[5:][:-9], _podCatcher) + if _music.getConfirmPlayback() == True: + assistant.start_conversation() elif text.startswith('radio '): assistant.stop_conversation() From febd21a484e397ff36b172ff8c2e580cc23770a4 Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Sun, 21 Oct 2018 01:38:00 +1100 Subject: [PATCH 23/27] Add alternative TV-related command --- src/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.py b/src/main.py index d828faa8..2946194c 100644 --- a/src/main.py +++ b/src/main.py @@ -187,6 +187,10 @@ def process_event(assistant, event): assistant.stop_conversation() _kodiRemote.run('play unwatched ' + text[21:]) + elif text.startswith('play the most recent episode of '): + assistant.stop_conversation() + _kodiRemote.run('play unwatched ' + text[32:]) + elif text.startswith('play most recent episode of '): assistant.stop_conversation() _kodiRemote.run('play unwatched ' + text[28:]) From 648d347f672a0f04231bfc851adac1729de2c5d6 Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Wed, 31 Oct 2018 21:05:01 +1100 Subject: [PATCH 24/27] Update MQTT details for power-related commands --- src/modules/powerswitch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/powerswitch.py b/src/modules/powerswitch.py index 82c1b7a7..86f5024f 100644 --- a/src/modules/powerswitch.py +++ b/src/modules/powerswitch.py @@ -65,7 +65,7 @@ def processCommand(self, device, action): logging.info('Code to send: ' + str(code)) - self.mqtt.command('/rf-power/code', code) + self.mqtt.command('/power/code', code) else: aiy.audio.say('Unrecognised switch') From bd6432c1ace7111432f0229b5ed11270500a9b2f Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Mon, 5 Nov 2018 11:24:19 +1100 Subject: [PATCH 25/27] Inform when no podcasts available --- src/modules/music.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/modules/music.py b/src/modules/music.py index 2ab79097..88d19fd2 100644 --- a/src/modules/music.py +++ b/src/modules/music.py @@ -236,6 +236,12 @@ def playPodcast(self, podcastID, podcatcher=None): return elif podcastID in ['recent','today','today\'s','yesterday']: + podcasts = podcatcher.getPodcastInfo(podcastID, offset) + + if len(podcasts) == 0: + aiy.audio.say('No podcasts available') + return + aiy.audio.say('Available podcasts are') button = aiy.voicehat.get_button() button.on_press(self._buttonPressCancel) From 36c0c85de4f77af14bee435c866763bf100cdfef Mon Sep 17 00:00:00 2001 From: Michael Pemberton <8grzbsf9d9@snkmail.com> Date: Wed, 2 Jan 2019 23:12:07 +1100 Subject: [PATCH 26/27] Major upgrade to power commands --- src/main.py | 4 +-- src/modules/powerswitch.py | 63 ++++++++++++++++++++++++++++---------- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/main.py b/src/main.py index 2946194c..1a81bdca 100644 --- a/src/main.py +++ b/src/main.py @@ -48,7 +48,7 @@ _configPath = os.path.expanduser('~/.config/voice-assistant.ini') _settingsPath = os.path.expanduser('~/.config/settings.ini') -_remotePath = os.path.expanduser('~/.config/remotes.ini') +_remotePath = "https://argos.int.mpember.net.au/rpc/get/remotes" _kodiRemote = KodiRemote(_settingsPath) _music = Music(_settingsPath) @@ -159,7 +159,7 @@ def process_event(assistant, event): assistant.stop_conversation() _readRssFeed.run(text[10:]) - elif text.startswith('turn on ') or text.startswith('turn off '): + elif text.startswith('turn on ') or text.startswith('turn off ') or text.startswith('turn down ') or text.startswith('turn up '): assistant.stop_conversation() _powerSwitch.run(text[5:]) diff --git a/src/modules/powerswitch.py b/src/modules/powerswitch.py index 86f5024f..2f84b4ac 100644 --- a/src/modules/powerswitch.py +++ b/src/modules/powerswitch.py @@ -2,10 +2,12 @@ import logging import aiy.audio +import requests +import json from modules.mqtt import Mosquitto -# PowerSwitch: Send HTTP command to RF switch website +# PowerSwitch: Send MQTT command to control remote devices # ================================ # @@ -14,13 +16,27 @@ class PowerSwitch(object): """ Control power sockets""" def __init__(self, configPath, remotePath): + self.configPath = configPath self.remotePath = remotePath self.mqtt = Mosquitto(configPath) def run(self, voice_command): - self.config = configparser.ConfigParser() - self.config.read(self.remotePath) - self.devices = self.config.sections() + + try: + if self.remotePath.startswith("http"): + logging.warning('Loading remote device list') + response = requests.get(self.remotePath) + self.config = json.loads(response.text.lower()) + + else: + logging.warning('Loading local device list') + self.config = json.loads(open(self.remotePath).read()) + + except: + logging.warning('Failed to load remote device list') + return + + self.devices = self.config["remotes"] devices = None action = None @@ -29,8 +45,7 @@ def run(self, voice_command): logging.info('Enumerating switchable devices') aiy.audio.say('Available switches are') for device in self.devices: - if device != 'GPIO': - aiy.audio.say(str(device)) + aiy.audio.say(device["names"][0]) return elif voice_command.startswith('on '): @@ -41,6 +56,14 @@ def run(self, voice_command): action = 'off' devices = voice_command[4:].split(' and ') + elif voice_command.startswith('up '): + action = 'up' + devices = voice_command[3:].split(' and ') + + elif voice_command.startswith('down '): + action = 'down' + devices = voice_command[5:].split(' and ') + else: aiy.audio.say('Unrecognised command') logging.warning('Unrecognised command: ' + device) @@ -52,22 +75,30 @@ def run(self, voice_command): self.processCommand(device, action) def processCommand(self, device, action): - if device.startswith('the '): + config = configparser.ConfigParser() + config.read(self.configPath) + + if device.startswith('the '): device = device[4:] - if device in self.devices: + for deviceobj in self.devices: - code = self.config[device].getint('code') + if device in deviceobj["names"]: - if action == 'off' and self.config[device].getboolean('toggle', fallback=False) is not True: - code = code - 8; + logging.info('Device found: ' + device) - logging.info('Code to send: ' + str(code)) + if action in deviceobj["codes"]: + logging.info('Code found for "' + action + '" action') + self.mqtt.command(config["mqtt"].get("power_topic","power/code"), deviceobj["codes"][action]) + else: + aiy.audio.say(device + ' does not support command ' + action) + logging.warning('Device "' + device + '" does not support command: ' + action) - self.mqtt.command('/power/code', code) + return - else: - aiy.audio.say('Unrecognised switch') - logging.warning('Unrecognised device: ' + device) + logging.info('Device not matched') + + aiy.audio.say('Unrecognised switch') + logging.warning('Unrecognised device: ' + device) From 0fab1d56b278a07fb64a99ecbc4838ffdbca813a Mon Sep 17 00:00:00 2001 From: mpember Date: Mon, 21 Oct 2019 09:27:26 +1100 Subject: [PATCH 27/27] Update to voice commands, modules Updates to voice commands, Kodi-related module and music-related modules. --- src/main.py | 119 ++++++++++++++++++++++++++++--------------- src/modules/kodi.py | 44 ++++++++++------ src/modules/music.py | 84 ++++++++++++++++-------------- 3 files changed, 151 insertions(+), 96 deletions(-) diff --git a/src/main.py b/src/main.py index 1a81bdca..ba2eec8c 100644 --- a/src/main.py +++ b/src/main.py @@ -30,20 +30,26 @@ import time import json -import aiy.assistant.auth_helpers -import aiy.assistant.device_helpers -from google.assistant.library import Assistant -import aiy.audio -import aiy.voicehat -from google.assistant.library.event import EventType - import os.path +import pathlib2 as pathlib import configargparse +import google.oauth2.credentials + +# from google.assistant.library import Assistant +# from google.assistant.library.file_helpers import existing_file +# from google.assistant.library.device_helpers import register_device +from google.assistant.library.event import EventType + +from aiy.assistant import auth_helpers +from aiy.assistant.library import Assistant +from aiy.board import Board, Led +from aiy.voice import tts + from modules.kodi import KodiRemote from modules.music import Music, PodCatcher from modules.readrssfeed import ReadRssFeed -from modules.powerswitch import PowerSwitch +from modules.mqtt import MQTTSwitch from modules.powercommand import PowerCommand _configPath = os.path.expanduser('~/.config/voice-assistant.ini') @@ -54,7 +60,21 @@ _music = Music(_settingsPath) _podCatcher = PodCatcher(_settingsPath) _readRssFeed = ReadRssFeed(_settingsPath) -_powerSwitch = PowerSwitch(_settingsPath, _remotePath) +_MQTTSwitch = MQTTSwitch(_settingsPath, _remotePath) + +try: + FileNotFoundError +except NameError: + FileNotFoundError = IOError + + +WARNING_NOT_REGISTERED = """ + This device is not registered. This means you will not be able to use + Device Actions or see your device in Assistant Settings. In order to + register this device follow instructions at: + + https://developers.google.com/assistant/sdk/guides/library/python/embed/register-device +""" def _createPID(pid_file='voice-recognizer.pid'): @@ -83,26 +103,26 @@ def _volumeCommand(change): vol = max(0, min(100, vol)) if vol == 0: - aiy.audio.say('Volume at %d %%.' % vol) + tts.say('Volume at %d %%.' % vol) subprocess.call('amixer -q set Master %d%%' % vol, shell=True) - aiy.audio.say('Volume at %d %%.' % vol) + tts.say('Volume at %d %%.' % vol) except (ValueError, subprocess.CalledProcessError): logging.exception('Error using amixer to adjust volume.') -def process_event(assistant, event): - status_ui = aiy.voicehat.get_status_ui() +def process_event(assistant, led, event): global _cancelAction + logging.info(event) if event.type == EventType.ON_START_FINISHED: - status_ui.status('ready') - if sys.stdout.isatty(): - print('Say "OK, Google" then speak, or press Ctrl+C to quit...') + led.state = Led.OFF # Ready. + print('Say "OK, Google" then speak, or press Ctrl+C to quit...') + elif event.type == EventType.ON_CONVERSATION_TURN_STARTED: - status_ui.status('listening') + led.state = Led.ON elif event.type == EventType.ON_ALERT_STARTED and event.args: logging.warning('An alert just started, type = ' + str(event.args['alert_type'])) @@ -129,9 +149,9 @@ def process_event(assistant, event): _music.setConfirmPlayback(False) _music.setPodcastURL(None) - elif text.startswith('music '): - assistant.stop_conversation() - _music.command('music', text[6:]) +# elif text.startswith('music '): +# assistant.stop_conversation() +# _music.command('music', text[6:]) elif text.startswith('podcast '): assistant.stop_conversation() @@ -161,7 +181,7 @@ def process_event(assistant, event): elif text.startswith('turn on ') or text.startswith('turn off ') or text.startswith('turn down ') or text.startswith('turn up '): assistant.stop_conversation() - _powerSwitch.run(text[5:]) + _MQTTSwitch.run(text[5:]) elif text.startswith('switch to channel '): assistant.stop_conversation() @@ -169,7 +189,7 @@ def process_event(assistant, event): elif text.startswith('switch '): assistant.stop_conversation() - _powerSwitch.run(text[7:]) + _MQTTSwitch.run(text[7:]) elif text.startswith('media center '): assistant.stop_conversation() @@ -199,17 +219,33 @@ def process_event(assistant, event): assistant.stop_conversation() _kodiRemote.run(text) + elif text.startswith('play the lastest recording of '): + assistant.stop_conversation() + _kodiRemote.run('play recording ' + text[31:]) + + elif text.startswith('play the recording of '): + assistant.stop_conversation() + _kodiRemote.run('play recording ' + text[23:]) + + elif text.startswith('play recording of '): + assistant.stop_conversation() + _kodiRemote.run('play recording ' + text[18:]) + + elif text.startswith('play recording '): + assistant.stop_conversation() + _kodiRemote.run(text) + elif text.startswith('tv '): assistant.stop_conversation() _kodiRemote.run(text) elif text in ['power off','shutdown','shut down','self destruct']: assistant.stop_conversation() - PowerCommand().run('shutdown') + PowerCommand('shutdown') - elif text == 'reboot': + elif text in ['restart', 'reboot']: assistant.stop_conversation() - _powerCommand('reboot') + PowerCommand('reboot') elif text == 'volume up': assistant.stop_conversation() @@ -241,15 +277,15 @@ def process_event(assistant, event): elif text in ['brightness low', 'set brightness low']: assistant.stop_conversation() - aiy.voicehat.get_led().set_brightness(40) + led.brightness(0.4) elif text in ['brightness medium', 'set brightness medium']: assistant.stop_conversation() - aiy.voicehat.get_led().set_brightness(70) + led.brightness(0.7) elif text in ['brightness high', 'set brightness high', 'brightness full', 'set brightness full']: assistant.stop_conversation() - aiy.voicehat.get_led().set_brightness(100) + led.brightness(1) elif event.type == EventType.ON_RESPONDING_FINISHED: assistant.stop_conversation() @@ -266,10 +302,12 @@ def process_event(assistant, event): logging.info(event.args) elif event.type == EventType.ON_END_OF_UTTERANCE: - status_ui.status('thinking') + led.state = Led.PULSE_QUICK - elif event.type == EventType.ON_CONVERSATION_TURN_FINISHED: - status_ui.status('ready') + elif (event.type == EventType.ON_CONVERSATION_TURN_FINISHED + or event.type == EventType.ON_CONVERSATION_TURN_TIMEOUT + or event.type == EventType.ON_NO_RESPONSE): + led.state = Led.OFF elif event.type == EventType.ON_ASSISTANT_ERROR and event.args and event.args['is_fatal']: logging.info('EventType.ON_ASSISTANT_ERROR') @@ -285,7 +323,6 @@ def process_event(assistant, event): else: logging.info(event.type) - def main(): parser = configargparse.ArgParser( @@ -306,23 +343,23 @@ def main(): args = parser.parse_args() - aiy.i18n.set_language_code(args.language) _createPID(args.pid_file) - logging.info('Setting brightness to %d %%' % args.brightness_max) - aiy.voicehat.get_led().set_brightness(args.brightness_max) - - credentials = aiy.assistant.auth_helpers.get_assistant_credentials() - model_id, device_id = aiy.assistant.device_helpers.get_ids_for_service(credentials) - if args.daemon is True or sys.stdout.isatty() is not True: _podCatcher.start() + logging.info("Starting in daemon mode") else: logging.info("Starting in non-daemon mode") - with Assistant(credentials, model_id) as assistant: + credentials = auth_helpers.get_assistant_credentials() + with Board() as board, Assistant(credentials) as assistant: + logging.info('Setting brightness to %d %%' % args.brightness_max) + logging.info(board.led) + logging.info('Setting brightness to %d %%' % args.brightness_max) + # led.brightness(0.2) + for event in assistant.start(): - process_event(assistant, event) + process_event(assistant, board.led, event) if __name__ == '__main__': try: diff --git a/src/modules/kodi.py b/src/modules/kodi.py index 446ada4b..31224b84 100644 --- a/src/modules/kodi.py +++ b/src/modules/kodi.py @@ -3,7 +3,7 @@ from kodijson import Kodi, PLAYER_VIDEO -import aiy.audio +from aiy.voice import tts # KodiRemote: Send command to Kodi # ================================ @@ -24,10 +24,7 @@ def run(self, voice_command): config.read(self.configPath) settings = config['kodi'] - kodiUsername = settings['username'] - kodiPassword = settings['password'] - - number_mapping = [ ('10 ', 'ten '), ('9 ', 'nine ') ] + number_mapping = [ ('9 ', 'nine ') ] if self.kodi is None: logging.info('No current connection to a Kodi client') @@ -43,19 +40,19 @@ def run(self, voice_command): try: self.kodi.JSONRPC.Ping() except: - aiy.audio.say('Unable to connect to client') + tts.say('Unable to connect to client') return if voice_command.startswith('tv '): result = self.kodi.PVR.GetChannels(channelgroupid='alltv') channels = result['result']['channels'] if len(channels) == 0: - aiy.audio.say('No channels found') + tts.say('No channels found') elif voice_command == 'tv channels': - aiy.audio.say('Available channels are') + tts.say('Available channels are') for channel in channels: - aiy.audio.say(channel['label']) + tts.say(channel['label']) else: for k, v in number_mapping: @@ -67,8 +64,8 @@ def run(self, voice_command): else: logging.info('No channel match found for ' + voice_command[3:] + '(' + str(len(channel)) + ')') - aiy.audio.say('No channel match found for ' + voice_command[3:]) - aiy.audio.say('Say Kodi t v channels for a list of available channels') + tts.say('No channel match found for ' + voice_command[3:]) + tts.say('Say Kodi t v channels for a list of available channels') elif voice_command.startswith('play unwatched ') or voice_command.startswith('play tv series '): voice_command = voice_command[15:] @@ -81,17 +78,32 @@ def run(self, voice_command): self.kodi.Player.Open(item={'episodeid':result['result']['episodes'][0]['episodeid']}) else: - aiy.audio.say('No new episodes of ' + voice_command + ' available') + tts.say('No new episodes of ' + voice_command + ' available') logging.info('No new episodes of ' + voice_command + ' available') else: - aiy.audio.say('No new episodes of ' + voice_command + ' available') + tts.say('No new episodes of ' + voice_command + ' available') logging.info('No new episodes of ' + voice_command + ' available') else: - aiy.audio.say('No tv show found titled ' + voice_command) + tts.say('No tv show found titled ' + voice_command) logging.info('No tv show found') - + + elif voice_command.startswith('play recording '): + voice_command = voice_command[15:] + result = self.kodi.PVR.GetRecordings(properties=["starttime"]) + if 'recordings' in result['result']: + if len(result['result']['recordings']) > 0: + recordings = sorted([recording for recording in result["result"]["recordings"] if recording["label"].lower() == voice_command], key = lambda x : x["starttime"], reverse=True) + if len(recordings) > 0: + self.kodi.Player.Open(item={'recordingid':int(recordings[0]["recordingid"])}) + else: + tts.say('No recording titled ' + voice_command) + logging.info('No recording found') + else: + tts.say('No recordings found') + logging.info('No PVR recordings found') + elif voice_command == 'stop': result = self.kodi.Player.Stop(playerid=1) logging.info('Kodi response: ' + str(result)) @@ -107,6 +119,6 @@ def run(self, voice_command): self.kodi.System.Shutdown() else: - aiy.audio.say('Unrecognised Kodi command') + tts.say('Unrecognised Kodi command') logging.warning('Unrecognised Kodi request: ' + voice_command) return diff --git a/src/modules/music.py b/src/modules/music.py index 88d19fd2..1972da5c 100644 --- a/src/modules/music.py +++ b/src/modules/music.py @@ -1,4 +1,5 @@ import configparser +import os import logging import time import feedparser @@ -7,8 +8,11 @@ from mpd import MPDClient, MPDError, CommandError, ConnectionError -import aiy.audio -import aiy.voicehat +from aiy.voice import tts +from aiy.voice import tts +# from gpiozero import Button +# from aiy.pins import BUTTON_GPIO_PIN +from aiy.board import Board class PodCatcher(threading.Thread): def __init__(self, configpath): @@ -16,10 +20,11 @@ def __init__(self, configpath): """ threading.Thread.__init__(self) self.configPath = configpath + self.dbpath = '/run/user/%d/podcasts.sqlite' % os.getuid() def _connectDB(self): try: - conn = sqlite3.connect('/tmp/podcasts.sqlite') + conn = sqlite3.connect(self.dbpath) conn.cursor().execute(''' CREATE TABLE IF NOT EXISTS podcasts ( podcast TEXT NOT NULL, @@ -134,14 +139,12 @@ class Music(object): """Interacts with MPD""" def __init__(self, configpath): - self._cancelAction = False self.configPath = configpath self._confirmPlayback = False self._podcastURL = None self.mpd = MPDClient(use_unicode=True) def command(self, module, voice_command, podcatcher=None): - self.resetVariables() self.mpd.connect("localhost", 6600) if module == 'music': @@ -161,21 +164,30 @@ def command(self, module, voice_command, podcatcher=None): elif module == 'podcast': self.playPodcast(voice_command, podcatcher) - if self._cancelAction == False: - time.sleep(1) - button = aiy.voicehat.get_button() - button.on_press(self._buttonPressCancel) + logging.info('checking MPD status') + + time.sleep(1) + + logging.info('checking MPD status') + + if self.mpd.status()['state'] != "stop": + logging.info('Initialising thread to monitor for button press') + cancel = threading.Event() + logging.info('Attaching Button object to GPIO') + self._board = Board() + logging.info('Attaching thread to Button object') + self._board.button.when_pressed = cancel.set # Keep alive until the user cancels music with button press while self.mpd.status()['state'] != "stop": - if self._cancelAction == True: + if cancel.is_set(): logging.info('stopping Music by button press') self.mpd.stop() self._podcastURL = None break time.sleep(0.1) - button.on_press(None) + self._board.button.when_pressed = None logging.info('Music stopped playing') self.mpd.clear() @@ -194,18 +206,18 @@ def playRadio(self, station): if station == 'list': logging.info('Enumerating radio stations') - aiy.audio.say('Available stations are') + tts.say('Available stations are') for key in stations: - aiy.audio.say(key) + tts.say(key) return elif station not in stations: logging.info('Station not found: ' + station) - aiy.audio.say('radio station ' + station + ' not found') + tts.say('radio station ' + station + ' not found') return logging.info('streaming ' + station) - aiy.audio.say('tuning the radio to ' + station) + tts.say('tuning the radio to ' + station) self._cancelAction = False @@ -230,33 +242,35 @@ def playPodcast(self, podcastID, podcatcher=None): else: if podcastID == 'list': logging.info('Enumerating Podcasts') - aiy.audio.say('Available podcasts are') + tts.say('Available podcasts are') for key in podcasts: - aiy.audio.say('' + key) + tts.say('' + key) return elif podcastID in ['recent','today','today\'s','yesterday']: podcasts = podcatcher.getPodcastInfo(podcastID, offset) if len(podcasts) == 0: - aiy.audio.say('No podcasts available') + tts.say('No podcasts available') return - aiy.audio.say('Available podcasts are') - button = aiy.voicehat.get_button() - button.on_press(self._buttonPressCancel) - self._cancelAction = False + tts.say('Available podcasts are') + logging.info('Initialising thread to monitor for button press') + cancel = threading.Event() + logging.info('Attaching Button object to GPIO') + self._board = Board() + logging.info('Attaching thread to Button object') + self._board.button.when_pressed = cancel.set for podcast in podcatcher.getPodcastInfo(podcastID, offset): - if self._cancelAction: + if cancel.is_set(): break elif podcast['age'] < 49: - aiy.audio.say('' + podcast['podcast'] + ' uploaded an episode ' + str(int(podcast['age'])) + ' hours ago') + tts.say('' + podcast['podcast'] + ' uploaded an episode ' + str(int(podcast['age'])) + ' hours ago') else: - aiy.audio.say('' + podcast['podcast'] + ' uploaded an episode ' + str(int(podcast['age']/24)) + ' days ago') + tts.say('' + podcast['podcast'] + ' uploaded an episode ' + str(int(podcast['age']/24)) + ' days ago') - button.on_press(None) - self._cancelAction = False + self._board.button.when_pressed = None return elif podcastID.startswith('previous '): @@ -265,7 +279,7 @@ def playPodcast(self, podcastID, podcatcher=None): if podcastID not in podcasts: logging.info('Podcast not found: ' + podcastID) - aiy.audio.say('Podcast ' + podcastID + ' not found') + tts.say('Podcast ' + podcastID + ' not found') return podcastInfo = podcatcher.getPodcastInfo(podcastID, offset) @@ -280,14 +294,12 @@ def playPodcast(self, podcastID, podcatcher=None): logging.info('Episode URL: ' + podcastInfo[0]['url']) logging.info('Episode Age: ' + str(podcastInfo[0]['age']) + ' hours') - aiy.audio.say('Playing episode of ' + podcastInfo[0]['title'] + ' titled ' + podcastInfo[0]['ep_title']) + tts.say('Playing episode of ' + podcastInfo[0]['title'] + ' titled ' + podcastInfo[0]['ep_title']) if (podcastInfo[0]['age'] > 336): - aiy.audio.say('This episode is ' + str(int(podcastInfo[0]['age']/24)) + ' days old. Do you still want to play it?') + tts.say('This episode is ' + str(int(podcastInfo[0]['age']/24)) + ' days old. Do you still want to play it?') self._confirmPlayback = True return None - self._cancelAction = False - self._podcastURL = podcastInfo[0]['url'] if self._podcastURL is None: @@ -298,13 +310,10 @@ def playPodcast(self, podcastID, podcatcher=None): self.mpd.add(self._podcastURL) self.mpd.play() except ConnectionError as e: - aiy.audio.say('Error connecting to MPD service') + tts.say('Error connecting to MPD service') self._podcastURL = None - def _buttonPressCancel(self): - self._cancelAction = True - def getConfirmPlayback(self): return self._confirmPlayback @@ -316,6 +325,3 @@ def getPodcastURL(self): def setPodcastURL(self, podcastURL): self._podcastURL = podcastURL - - def resetVariables(self): - self._cancelAction = False