483 lines
15 KiB
Python
483 lines
15 KiB
Python
# Copyright 2017 Mycroft AI 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.
|
|
#
|
|
from __future__ import absolute_import
|
|
import re
|
|
import socket
|
|
import subprocess
|
|
import pyaudio
|
|
|
|
from os.path import join, expanduser
|
|
|
|
from threading import Thread
|
|
from time import sleep
|
|
|
|
import json
|
|
import os.path
|
|
import psutil
|
|
from stat import S_ISREG, ST_MTIME, ST_MODE, ST_SIZE
|
|
import requests
|
|
import logging
|
|
|
|
import signal as sig
|
|
|
|
import mycroft.audio
|
|
import mycroft.configuration
|
|
from mycroft.util.format import nice_number
|
|
# Officially exported methods from this file:
|
|
# play_wav, play_mp3, play_ogg, get_cache_directory,
|
|
# resolve_resource_file, wait_while_speaking
|
|
from mycroft.util.log import LOG
|
|
from mycroft.util.parse import extract_datetime, extract_number, normalize
|
|
from mycroft.util.signal import *
|
|
|
|
|
|
def resolve_resource_file(res_name):
|
|
"""Convert a resource into an absolute filename.
|
|
|
|
Resource names are in the form: 'filename.ext'
|
|
or 'path/filename.ext'
|
|
|
|
The system wil look for ~/.mycroft/res_name first, and
|
|
if not found will look at /opt/mycroft/res_name,
|
|
then finally it will look for res_name in the 'mycroft/res'
|
|
folder of the source code package.
|
|
|
|
Example:
|
|
With mycroft running as the user 'bob', if you called
|
|
resolve_resource_file('snd/beep.wav')
|
|
it would return either '/home/bob/.mycroft/snd/beep.wav' or
|
|
'/opt/mycroft/snd/beep.wav' or '.../mycroft/res/snd/beep.wav',
|
|
where the '...' is replaced by the path where the package has
|
|
been installed.
|
|
|
|
Args:
|
|
res_name (str): a resource path/name
|
|
Returns:
|
|
str: path to resource or None if no resource found
|
|
"""
|
|
config = mycroft.configuration.Configuration.get()
|
|
|
|
# First look for fully qualified file (e.g. a user setting)
|
|
if os.path.isfile(res_name):
|
|
return res_name
|
|
|
|
# Now look for ~/.mycroft/res_name (in user folder)
|
|
filename = os.path.expanduser("~/.mycroft/" + res_name)
|
|
if os.path.isfile(filename):
|
|
return filename
|
|
|
|
# Next look for /opt/mycroft/res/res_name
|
|
data_dir = expanduser(config['data_dir'])
|
|
filename = os.path.expanduser(join(data_dir, res_name))
|
|
if os.path.isfile(filename):
|
|
return filename
|
|
|
|
# Finally look for it in the source package
|
|
filename = os.path.join(os.path.dirname(__file__), '..', 'res', res_name)
|
|
filename = os.path.abspath(os.path.normpath(filename))
|
|
if os.path.isfile(filename):
|
|
return filename
|
|
|
|
return None # Resource cannot be resolved
|
|
|
|
|
|
def play_wav(uri):
|
|
""" Play a wav-file.
|
|
|
|
This will use the application specified in the mycroft config
|
|
and play the uri passed as argument. The function will return directly
|
|
and play the file in the background.
|
|
|
|
Arguments:
|
|
uri: uri to play
|
|
|
|
Returns: subprocess.Popen object
|
|
"""
|
|
config = mycroft.configuration.Configuration.get()
|
|
play_cmd = config.get("play_wav_cmdline")
|
|
play_wav_cmd = str(play_cmd).split(" ")
|
|
for index, cmd in enumerate(play_wav_cmd):
|
|
if cmd == "%1":
|
|
play_wav_cmd[index] = (get_http(uri))
|
|
try:
|
|
return subprocess.Popen(play_wav_cmd)
|
|
except Exception as e:
|
|
LOG.error("Failed to launch WAV: {}".format(play_wav_cmd))
|
|
LOG.debug("Error: {}".format(repr(e)), exc_info=True)
|
|
return None
|
|
|
|
|
|
def play_mp3(uri):
|
|
""" Play a mp3-file.
|
|
|
|
This will use the application specified in the mycroft config
|
|
and play the uri passed as argument. The function will return directly
|
|
and play the file in the background.
|
|
|
|
Arguments:
|
|
uri: uri to play
|
|
|
|
Returns: subprocess.Popen object
|
|
"""
|
|
config = mycroft.configuration.Configuration.get()
|
|
play_cmd = config.get("play_mp3_cmdline")
|
|
play_mp3_cmd = str(play_cmd).split(" ")
|
|
for index, cmd in enumerate(play_mp3_cmd):
|
|
if cmd == "%1":
|
|
play_mp3_cmd[index] = (get_http(uri))
|
|
try:
|
|
return subprocess.Popen(play_mp3_cmd)
|
|
except Exception as e:
|
|
LOG.error("Failed to launch MP3: {}".format(play_mp3_cmd))
|
|
LOG.debug("Error: {}".format(repr(e)), exc_info=True)
|
|
return None
|
|
|
|
|
|
def play_ogg(uri):
|
|
""" Play a ogg-file.
|
|
|
|
This will use the application specified in the mycroft config
|
|
and play the uri passed as argument. The function will return directly
|
|
and play the file in the background.
|
|
|
|
Arguments:
|
|
uri: uri to play
|
|
|
|
Returns: subprocess.Popen object
|
|
"""
|
|
config = mycroft.configuration.Configuration.get()
|
|
play_cmd = config.get("play_ogg_cmdline")
|
|
play_ogg_cmd = str(play_cmd).split(" ")
|
|
for index, cmd in enumerate(play_ogg_cmd):
|
|
if cmd == "%1":
|
|
play_ogg_cmd[index] = (get_http(uri))
|
|
try:
|
|
return subprocess.Popen(play_ogg_cmd)
|
|
except Exception as e:
|
|
LOG.error("Failed to launch OGG: {}".format(play_ogg_cmd))
|
|
LOG.debug("Error: {}".format(repr(e)), exc_info=True)
|
|
return None
|
|
|
|
|
|
def record(file_path, duration, rate, channels):
|
|
if duration > 0:
|
|
return subprocess.Popen(
|
|
["arecord", "-r", str(rate), "-c", str(channels), "-d",
|
|
str(duration), file_path])
|
|
else:
|
|
return subprocess.Popen(
|
|
["arecord", "-r", str(rate), "-c", str(channels), file_path])
|
|
|
|
|
|
def find_input_device(device_name):
|
|
""" Find audio input device by name.
|
|
|
|
Arguments:
|
|
device_name: device name or regex pattern to match
|
|
|
|
Returns: device_index (int) or None if device wasn't found
|
|
"""
|
|
LOG.info('Searching for input device: {}'.format(device_name))
|
|
LOG.debug('Devices: ')
|
|
pa = pyaudio.PyAudio()
|
|
pattern = re.compile(device_name)
|
|
for device_index in range(pa.get_device_count()):
|
|
dev = pa.get_device_info_by_index(device_index)
|
|
LOG.debug(' {}'.format(dev['name']))
|
|
if dev['maxInputChannels'] > 0 and pattern.match(dev['name']):
|
|
LOG.debug(' ^-- matched')
|
|
return device_index
|
|
return None
|
|
|
|
|
|
def get_http(uri):
|
|
return uri.replace("https://", "http://")
|
|
|
|
|
|
def remove_last_slash(url):
|
|
if url and url.endswith('/'):
|
|
url = url[:-1]
|
|
return url
|
|
|
|
|
|
def read_stripped_lines(filename):
|
|
with open(filename, 'r') as f:
|
|
return [line.strip() for line in f]
|
|
|
|
|
|
def read_dict(filename, div='='):
|
|
d = {}
|
|
with open(filename, 'r') as f:
|
|
for line in f:
|
|
(key, val) = line.split(div)
|
|
d[key.strip()] = val.strip()
|
|
return d
|
|
|
|
|
|
def connected():
|
|
""" Check connection by connecting to 8.8.8.8, if this is
|
|
blocked/fails, Microsoft NCSI is used as a backup
|
|
|
|
Returns:
|
|
True if internet connection can be detected
|
|
"""
|
|
return connected_dns() or connected_ncsi()
|
|
|
|
|
|
def connected_ncsi():
|
|
""" Check internet connection by retrieving the Microsoft NCSI endpoint.
|
|
|
|
Returns:
|
|
True if internet connection can be detected
|
|
"""
|
|
try:
|
|
r = requests.get('http://www.msftncsi.com/ncsi.txt')
|
|
if r.text == u'Microsoft NCSI':
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def connected_dns(host="8.8.8.8", port=53, timeout=3):
|
|
""" Check internet connection by connecting to DNS servers
|
|
|
|
Returns:
|
|
True if internet connection can be detected
|
|
"""
|
|
# Thanks to 7h3rAm on
|
|
# Host: 8.8.8.8 (google-public-dns-a.google.com)
|
|
# OpenPort: 53/tcp
|
|
# Service: domain (DNS/TCP)
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
s.settimeout(timeout)
|
|
s.connect((host, port))
|
|
return True
|
|
except IOError:
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
s.settimeout(timeout)
|
|
s.connect(("8.8.4.4", port))
|
|
return True
|
|
except IOError:
|
|
return False
|
|
|
|
|
|
def curate_cache(directory, min_free_percent=5.0, min_free_disk=50):
|
|
"""Clear out the directory if needed
|
|
|
|
This assumes all the files in the directory can be deleted as freely
|
|
|
|
Args:
|
|
directory (str): directory path that holds cached files
|
|
min_free_percent (float): percentage (0.0-100.0) of drive to keep free,
|
|
default is 5% if not specified.
|
|
min_free_disk (float): minimum allowed disk space in MB, default
|
|
value is 50 MB if not specified.
|
|
"""
|
|
|
|
# Simpleminded implementation -- keep a certain percentage of the
|
|
# disk available.
|
|
# TODO: Would be easy to add more options, like whitelisted files, etc.
|
|
space = psutil.disk_usage(directory)
|
|
|
|
# convert from MB to bytes
|
|
min_free_disk *= 1024 * 1024
|
|
# space.percent = space.used/space.total*100.0
|
|
percent_free = 100.0 - space.percent
|
|
if percent_free < min_free_percent and space.free < min_free_disk:
|
|
LOG.info('Low diskspace detected, cleaning cache')
|
|
# calculate how many bytes we need to delete
|
|
bytes_needed = (min_free_percent - percent_free) / 100.0 * space.total
|
|
bytes_needed = int(bytes_needed + 1.0)
|
|
|
|
# get all entries in the directory w/ stats
|
|
entries = (os.path.join(directory, fn) for fn in os.listdir(directory))
|
|
entries = ((os.stat(path), path) for path in entries)
|
|
|
|
# leave only regular files, insert modification date
|
|
entries = ((stat[ST_MTIME], stat[ST_SIZE], path)
|
|
for stat, path in entries if S_ISREG(stat[ST_MODE]))
|
|
|
|
# delete files with oldest modification date until space is freed
|
|
space_freed = 0
|
|
for moddate, fsize, path in sorted(entries):
|
|
try:
|
|
os.remove(path)
|
|
space_freed += fsize
|
|
except Exception:
|
|
pass
|
|
|
|
if space_freed > bytes_needed:
|
|
return # deleted enough!
|
|
|
|
|
|
def get_cache_directory(domain=None):
|
|
"""Get a directory for caching data
|
|
|
|
This directory can be used to hold temporary caches of data to
|
|
speed up performance. This directory will likely be part of a
|
|
small RAM disk and may be cleared at any time. So code that
|
|
uses these cached files must be able to fallback and regenerate
|
|
the file.
|
|
|
|
Args:
|
|
domain (str): The cache domain. Basically just a subdirectory.
|
|
|
|
Return:
|
|
str: a path to the directory where you can cache data
|
|
"""
|
|
config = mycroft.configuration.Configuration.get()
|
|
dir = config.get("cache_path")
|
|
if not dir:
|
|
# If not defined, use /tmp/mycroft/cache
|
|
dir = os.path.join(tempfile.gettempdir(), "mycroft", "cache")
|
|
return ensure_directory_exists(dir, domain)
|
|
|
|
|
|
def validate_param(value, name):
|
|
if not value:
|
|
raise ValueError("Missing or empty %s in mycroft.conf " % name)
|
|
|
|
|
|
def is_speaking():
|
|
"""Determine if Text to Speech is occurring
|
|
|
|
Returns:
|
|
bool: True while still speaking
|
|
"""
|
|
LOG.info("mycroft.utils.is_speaking() is depreciated, use "
|
|
"mycroft.audio.is_speaking() instead.")
|
|
return mycroft.audio.is_speaking()
|
|
|
|
|
|
def wait_while_speaking():
|
|
"""Pause as long as Text to Speech is still happening
|
|
|
|
Pause while Text to Speech is still happening. This always pauses
|
|
briefly to ensure that any preceeding request to speak has time to
|
|
begin.
|
|
"""
|
|
LOG.info("mycroft.utils.wait_while_speaking() is depreciated, use "
|
|
"mycroft.audio.wait_while_speaking() instead.")
|
|
return mycroft.audio.wait_while_speaking()
|
|
|
|
|
|
def stop_speaking():
|
|
# TODO: Less hacky approach to this once Audio Manager is implemented
|
|
# Skills should only be able to stop speech they've initiated
|
|
LOG.info("mycroft.utils.stop_speaking() is depreciated, use "
|
|
"mycroft.audio.stop_speaking() instead.")
|
|
mycroft.audio.stop_speaking()
|
|
|
|
|
|
def get_arch():
|
|
""" Get architecture string of system. """
|
|
return os.uname()[4]
|
|
|
|
|
|
def reset_sigint_handler():
|
|
"""
|
|
Reset the sigint handler to the default. This fixes KeyboardInterrupt
|
|
not getting raised when started via start-mycroft.sh
|
|
"""
|
|
sig.signal(sig.SIGINT, sig.default_int_handler)
|
|
|
|
|
|
def create_daemon(target, args=(), kwargs=None):
|
|
"""Helper to quickly create and start a thread with daemon = True"""
|
|
t = Thread(target=target, args=args, kwargs=kwargs)
|
|
t.daemon = True
|
|
t.start()
|
|
return t
|
|
|
|
|
|
def wait_for_exit_signal():
|
|
"""Blocks until KeyboardInterrupt is received"""
|
|
try:
|
|
while True:
|
|
sleep(100)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
_log_all_bus_messages = False
|
|
|
|
|
|
def create_echo_function(name, whitelist=None):
|
|
""" Standard logging mechanism for Mycroft processes.
|
|
|
|
This handles the setup of the basic logging for all Mycroft
|
|
messagebus-based processes.
|
|
|
|
Args:
|
|
name (str): Reference name of the process
|
|
whitelist (list, optional): List of "type" strings. If defined, only
|
|
messages in this list will be logged.
|
|
|
|
Returns:
|
|
func: The echo function
|
|
"""
|
|
|
|
from mycroft.configuration import Configuration
|
|
blacklist = Configuration.get().get("ignore_logs")
|
|
|
|
def echo(message):
|
|
global _log_all_bus_messages
|
|
try:
|
|
msg = json.loads(message)
|
|
|
|
if whitelist and msg.get("type") not in whitelist:
|
|
return
|
|
|
|
if blacklist and msg.get("type") in blacklist:
|
|
return
|
|
|
|
if msg.get("type") == "mycroft.debug.log":
|
|
# Respond to requests to adjust the logger settings
|
|
lvl = msg["data"].get("level", "").upper()
|
|
if lvl in ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]:
|
|
LOG.level = lvl
|
|
LOG(name).info("Changing log level to: {}".format(lvl))
|
|
try:
|
|
logging.getLogger('urllib3').setLevel(lvl)
|
|
except Exception:
|
|
pass # We don't really care about if this fails...
|
|
|
|
# Allow enable/disable of messagebus traffic
|
|
log_bus = msg["data"].get("bus", None)
|
|
if log_bus is not None:
|
|
LOG(name).info("Bus logging: "+str(log_bus))
|
|
_log_all_bus_messages = log_bus
|
|
elif msg.get("type") == "registration":
|
|
# do not log tokens from registration messages
|
|
msg["data"]["token"] = None
|
|
message = json.dumps(msg)
|
|
except Exception:
|
|
pass
|
|
|
|
if _log_all_bus_messages:
|
|
# Listen for messages and echo them for logging
|
|
LOG(name).debug(message)
|
|
return echo
|
|
|
|
|
|
def camel_case_split(identifier: str) -> str:
|
|
"""Split camel case string"""
|
|
regex = '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)'
|
|
matches = re.finditer(regex, identifier)
|
|
return ' '.join([m.group(0) for m in matches])
|