diff --git a/src/aiy/_drivers/_led.py b/src/aiy/_drivers/_led.py index f99b1912..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 @@ -35,7 +36,7 @@ class LED: BLINK = 2 BLINK_3 = 3 BEACON = 4 - BEACON_DARK = 5 + BEACON_DARK = 0 DECAY = 6 PULSE_SLOW = 7 PULSE_QUICK = 8 @@ -47,6 +48,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 +80,12 @@ def stop(self): self.pwm.stop() + def set_brightness(self, brightness): + try: + self.brightness = int(brightness)/100 + except ValueError: + raise ValueError('unsupported brightness: %s' % brightness) + def set_state(self, state): """Set the LED driver's new state. @@ -118,35 +126,35 @@ def _parse_state(self, state): 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 new file mode 100644 index 00000000..ba2eec8c --- /dev/null +++ b/src/main.py @@ -0,0 +1,380 @@ +#!/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 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.mqtt import MQTTSwitch +from modules.powercommand import PowerCommand + +_configPath = os.path.expanduser('~/.config/voice-assistant.ini') +_settingsPath = os.path.expanduser('~/.config/settings.ini') +_remotePath = "https://argos.int.mpember.net.au/rpc/get/remotes" + +_kodiRemote = KodiRemote(_settingsPath) +_music = Music(_settingsPath) +_podCatcher = PodCatcher(_settingsPath) +_readRssFeed = ReadRssFeed(_settingsPath) +_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'): + + 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()) + +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: + tts.say('Volume at %d %%.' % vol) + + subprocess.call('amixer -q set Master %d%%' % vol, shell=True) + tts.say('Volume at %d %%.' % vol) + + except (ValueError, subprocess.CalledProcessError): + logging.exception('Error using amixer to adjust volume.') + +def process_event(assistant, led, event): + + global _cancelAction + + logging.info(event) + if event.type == EventType.ON_START_FINISHED: + 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: + 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'])) + 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 + text = event.args['text'].lower() + + logging.info('You said: ' + text) + + if text == '': + assistant.stop_conversation() + + elif _music.getConfirmPlayback() == True: + assistant.stop_conversation() + if text == 'yes': + _music.command('podcast', 'CONFIRM') + else: + _music.setConfirmPlayback(False) + _music.setPodcastURL(None) + +# elif text.startswith('music '): +# assistant.stop_conversation() +# _music.command('music', text[6:]) + + elif text.startswith('podcast '): + assistant.stop_conversation() + _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.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() + + elif text.startswith('radio '): + assistant.stop_conversation() + _music.command('radio', text[6:]) + + elif text.startswith('headlines '): + assistant.stop_conversation() + _readRssFeed.run(text[10:]) + + elif text.startswith('turn on ') or text.startswith('turn off ') or text.startswith('turn down ') or text.startswith('turn up '): + assistant.stop_conversation() + _MQTTSwitch.run(text[5:]) + + elif text.startswith('switch to channel '): + assistant.stop_conversation() + _kodiRemote.run('tv ' + text[18:]) + + elif text.startswith('switch '): + assistant.stop_conversation() + _MQTTSwitch.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:]) + + 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:]) + + 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:]) + + elif text.startswith('play unwatched ') or text.startswith('play tv series '): + 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('shutdown') + + elif text in ['restart', '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 in ['brightness low', 'set brightness low']: + assistant.stop_conversation() + led.brightness(0.4) + + elif text in ['brightness medium', 'set brightness medium']: + assistant.stop_conversation() + led.brightness(0.7) + + elif text in ['brightness high', 'set brightness high', 'brightness full', 'set brightness full']: + assistant.stop_conversation() + led.brightness(1) + + 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: + led.state = Led.PULSE_QUICK + + 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') + 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(): + + 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='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', type=int, default=1, + help='Maximum LED brightness') + parser.add_argument('--brightness-min', type=int, default=1, + help='Minimum LED brightness') + parser.add_argument('-d', '--daemon', action='store_true', + help='Daemon Mode') + + args = parser.parse_args() + + _createPID(args.pid_file) + + 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") + + 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, board.led, event) + +if __name__ == '__main__': + 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: + pass + diff --git a/src/modules/kodi.py b/src/modules/kodi.py new file mode 100644 index 00000000..31224b84 --- /dev/null +++ b/src/modules/kodi.py @@ -0,0 +1,124 @@ +import configparser +import logging + +from kodijson import Kodi, PLAYER_VIDEO + +from aiy.voice import tts + +# 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'] + + number_mapping = [ ('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: + 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: + tts.say('No channels found') + + elif voice_command == 'tv channels': + tts.say('Available channels are') + for channel in channels: + tts.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)) + ')') + 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:] + 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: + tts.say('No new episodes of ' + voice_command + ' available') + logging.info('No new episodes of ' + voice_command + ' available') + + else: + tts.say('No new episodes of ' + voice_command + ' available') + logging.info('No new episodes of ' + voice_command + ' available') + + else: + 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)) + + 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 == 'update tv shows': + self.kodi.VideoLibrary.Scan() + + elif voice_command == 'shutdown' or voice_command == 'shut down': + self.kodi.System.Shutdown() + + else: + tts.say('Unrecognised Kodi command') + logging.warning('Unrecognised Kodi request: ' + voice_command) + return diff --git a/src/modules/mqtt.py b/src/modules/mqtt.py new file mode 100644 index 00000000..2c0db340 --- /dev/null +++ b/src/modules/mqtt.py @@ -0,0 +1,36 @@ +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) + + 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/music.py b/src/modules/music.py new file mode 100644 index 00000000..1972da5c --- /dev/null +++ b/src/modules/music.py @@ -0,0 +1,327 @@ +import configparser +import os +import logging +import time +import feedparser +import threading +import sqlite3 + +from mpd import MPDClient, MPDError, CommandError, ConnectionError + +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): + """ Define variables used by object + """ + threading.Thread.__init__(self) + self.configPath = configpath + self.dbpath = '/run/user/%d/podcasts.sqlite' % os.getuid() + + def _connectDB(self): + try: + conn = sqlite3.connect(self.dbpath) + 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);''') + conn.row_factory = sqlite3.Row + 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 podcastID,url in podcasts.items(): + if filter is not None and not podcastID == filter: + continue + + logging.info('loading ' + podcastID + ' 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(podcastID + ' podcast feed is empty') + continue + + for rssItem in rss.entries: + result = { + 'podcast':podcastID, + '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 "' + podcastID + '" 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'])) + + """ Detect existance of episode and skip remaining content + """ + if cursor.rowcount == 0: + break + + conn.commit() + logging.info('Finished updating podcast data') + + def getPodcastInfo(self, podcastID=None, offset=0): + if podcastID is None: + return None + + logging.info('Searching for information about "' + str(podcastID) + '" podcast') + + cursor = self._connectDB().cursor() + 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': + 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: + self.syncPodcasts() + time.sleep(1680) + +class Music(object): + + """Interacts with MPD""" + + def __init__(self, configpath): + self.configPath = configpath + self._confirmPlayback = False + self._podcastURL = None + self.mpd = MPDClient(use_unicode=True) + + def command(self, module, voice_command, podcatcher=None): + 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, podcatcher) + + 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 cancel.is_set(): + logging.info('stopping Music by button press') + self.mpd.stop() + self._podcastURL = None + break + + time.sleep(0.1) + self._board.button.when_pressed = None + logging.info('Music stopped playing') + self.mpd.clear() + + try: + self.mpd.close() + self.mpd.disconnect() + except ConnectionError: + logging.warning('MPD connection timed out') + pass + + def playRadio(self, station): + config = configparser.ConfigParser() + config.read(self.configPath) + + stations = config['radio'] + + if station == 'list': + logging.info('Enumerating radio stations') + tts.say('Available stations are') + for key in stations: + tts.say(key) + return + + elif station not in stations: + logging.info('Station not found: ' + station) + tts.say('radio station ' + station + ' not found') + return + + logging.info('streaming ' + station) + tts.say('tuning the radio to ' + station) + + self._cancelAction = False + + self.mpd.clear() + self.mpd.add(stations[station]) + self.mpd.play() + + def playPodcast(self, podcastID, podcatcher=None): + config = configparser.ConfigParser() + config.read(self.configPath) + podcasts = config['podcasts'] + logging.info('playPodcast "' + podcastID + "'") + + offset = 0 + if podcatcher is None: + logging.warning('playPodcast missing podcatcher object') + return + + if self._confirmPlayback == True: + self._confirmPlayback = False + + else: + if podcastID == 'list': + logging.info('Enumerating Podcasts') + tts.say('Available podcasts are') + for key in podcasts: + tts.say('' + key) + return + + elif podcastID in ['recent','today','today\'s','yesterday']: + podcasts = podcatcher.getPodcastInfo(podcastID, offset) + + if len(podcasts) == 0: + tts.say('No podcasts available') + return + + 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 cancel.is_set(): + break + elif podcast['age'] < 49: + tts.say('' + podcast['podcast'] + ' uploaded an episode ' + str(int(podcast['age'])) + ' hours ago') + else: + tts.say('' + podcast['podcast'] + ' uploaded an episode ' + str(int(podcast['age']/24)) + ' days ago') + + self._board.button.when_pressed = None + return + + elif podcastID.startswith('previous '): + offset = 1 + podcastID = podcastID[9:] + + if podcastID not in podcasts: + logging.info('Podcast not found: ' + podcastID) + tts.say('Podcast ' + podcastID + ' not found') + return + + podcastInfo = podcatcher.getPodcastInfo(podcastID, offset) + if len(podcastInfo) == 0: + return + + if podcastInfo == None: + logging.warning('Podcast data for "' + podcast + '" failed to load') + return + 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') + + tts.say('Playing episode of ' + podcastInfo[0]['title'] + ' titled ' + podcastInfo[0]['ep_title']) + if (podcastInfo[0]['age'] > 336): + 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._podcastURL = podcastInfo[0]['url'] + + if self._podcastURL is None: + return None + + try: + self.mpd.clear() + self.mpd.add(self._podcastURL) + self.mpd.play() + except ConnectionError as e: + tts.say('Error connecting to MPD service') + + self._podcastURL = None + + 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 diff --git a/src/modules/powercommand.py b/src/modules/powercommand.py new file mode 100644 index 00000000..42cba156 --- /dev/null +++ b/src/modules/powercommand.py @@ -0,0 +1,43 @@ +import logging +import time + +import aiy.audio +import aiy.voicehat + +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..2f84b4ac --- /dev/null +++ b/src/modules/powerswitch.py @@ -0,0 +1,104 @@ +import configparser +import logging + +import aiy.audio +import requests +import json + +from modules.mqtt import Mosquitto + +# PowerSwitch: Send MQTT command to control remote devices +# ================================ +# + +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): + + 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 + + if voice_command == 'list': + logging.info('Enumerating switchable devices') + aiy.audio.say('Available switches are') + for device in self.devices: + aiy.audio.say(device["names"][0]) + 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 ') + + 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) + 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): + + config = configparser.ConfigParser() + config.read(self.configPath) + + if device.startswith('the '): + device = device[4:] + + for deviceobj in self.devices: + + if device in deviceobj["names"]: + + logging.info('Device found: ' + device) + + 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) + + return + + logging.info('Device not matched') + + aiy.audio.say('Unrecognised switch') + logging.warning('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..cb265791 100644 --- a/systemd/voice-recognizer.service +++ b/systemd/voice-recognizer.service @@ -1,4 +1,4 @@ -# This service can be used to run your code automatically on startup. Look in +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]