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]