├── docs ├── squeeze-alexa-networking.png ├── lambda-management-screenshot-2018-7.png ├── amazon-developer-alexa-screenshot-2018-7.png ├── amazon-developer-slots-screenshot-2017-11.png ├── amazon-developer-alexa-v1-intents-screenshot-2018-7.png ├── example-config │ ├── docker │ │ └── nginx-tcp-ssl │ │ │ ├── Dockerfile │ │ │ └── nginx.conf │ ├── systemd │ │ └── mqtt-squeeze.service │ ├── upstart │ │ └── mqtt-squeeze.conf │ ├── iot-iam-policy.json │ └── stunnel.conf ├── squeeze-alexa-networking.xml ├── TROUBLESHOOTING.md ├── CONTRIBUTING.md ├── MQTT.md └── SSL.md ├── setup.cfg ├── tests ├── alexa │ ├── __init__.py │ └── alexa_handlers_test.py ├── squeezebox │ ├── __init__.py │ └── test_server.py ├── transport │ ├── __init__.py │ ├── test_base.py │ ├── base.py │ ├── fake_transport.py │ ├── test_mqtt.py │ ├── test_ssl.py │ └── mqtt_integration_test.py ├── __init__.py ├── data │ ├── cert-only.pem │ ├── cert-and-key.pem │ ├── bad-hostname.pem │ └── broker-certificate.pem.crt ├── lambda_handler_test.py ├── genre_test.py ├── test_squeezealexa.py ├── utils.py ├── test_gettext.py ├── intents_test.py ├── test_utils.py └── integration_test.py ├── bin ├── compile-translations ├── update-translations ├── build.sh ├── release.sh ├── local_test.py └── deploy.py ├── squeezealexa ├── alexa │ ├── __init__.py │ ├── utterances.py │ ├── requests.py │ ├── intents.py │ ├── response.py │ └── handlers.py ├── transport │ ├── __init__.py │ ├── factory.py │ ├── base.py │ ├── mqtt.py │ └── ssl_wrap.py ├── squeezebox │ └── __init__.py ├── __init__.py ├── i18n.py ├── utils.py └── settings.py ├── pyproject.toml ├── etc └── certs │ └── README.md ├── .gitignore ├── .circleci └── config.yml ├── handler.py ├── metadata └── intents │ └── v0 │ ├── locale │ ├── de_DE │ │ └── utterances.txt │ └── fr_FR │ │ └── utterances.txt │ └── intents.json ├── mqtt_squeeze.py ├── README.md └── locale ├── en_GB └── LC_MESSAGES │ └── squeeze-alexa.po ├── fr_FR └── LC_MESSAGES │ └── squeeze-alexa.po └── de_DE └── LC_MESSAGES └── squeeze-alexa.po /docs/squeeze-alexa-networking.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/declension/squeeze-alexa/HEAD/docs/squeeze-alexa-networking.png -------------------------------------------------------------------------------- /docs/lambda-management-screenshot-2018-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/declension/squeeze-alexa/HEAD/docs/lambda-management-screenshot-2018-7.png -------------------------------------------------------------------------------- /docs/amazon-developer-alexa-screenshot-2018-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/declension/squeeze-alexa/HEAD/docs/amazon-developer-alexa-screenshot-2018-7.png -------------------------------------------------------------------------------- /docs/amazon-developer-slots-screenshot-2017-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/declension/squeeze-alexa/HEAD/docs/amazon-developer-slots-screenshot-2017-11.png -------------------------------------------------------------------------------- /docs/amazon-developer-alexa-v1-intents-screenshot-2018-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/declension/squeeze-alexa/HEAD/docs/amazon-developer-alexa-v1-intents-screenshot-2018-7.png -------------------------------------------------------------------------------- /docs/example-config/docker/nginx-tcp-ssl/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.15-alpine 2 | 3 | ARG INTERNAL_SERVER_HOSTNAME 4 | ARG SSL_PORT=19090 5 | 6 | COPY nginx.conf nginx.conf 7 | RUN envsubst /etc/nginx/nginx.conf 8 | COPY squeeze-alexa.pem /etc/ssl/certs/squeeze-alexa.pem 9 | 10 | EXPOSE $SSL_PORT 11 | 12 | -------------------------------------------------------------------------------- /docs/example-config/systemd/mqtt-squeeze.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=mqtt-squeeze 3 | After=network.target 4 | 5 | [Service] 6 | # UPDATE THIS to your installation directory (making sure all files are copied) 7 | ExecStart=/usr/local/bin/mqtt_squeeze.py 8 | Restart=always 9 | RestartSec=2 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/example-config/upstart/mqtt-squeeze.conf: -------------------------------------------------------------------------------- 1 | # Upstart script for mqtt-squeeze 2 | # For Synology DSM only, though it should be very similar on other systems. 3 | # 4 | 5 | description "mqtt-squeeze" 6 | author "Nick Boultbee" 7 | 8 | start on syno.share.ready 9 | stop on runlevel [06] 10 | respawn 11 | 12 | # EDIT THIS 13 | env DIR="/volume1/mqtt-squeeze" 14 | 15 | exec $DIR/mqtt_squeeze.py 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E252, E402, F403, F405, W504 3 | # Otherwise 2/3 mixing issues... 4 | builtins = unichr, unicode 5 | exclude = 6 | *.pyc, 7 | __pycache__, 8 | dist, 9 | .git 10 | 11 | [coverage:report] 12 | exclude_lines = 13 | pragma: no cover 14 | def __repr__ 15 | raise NotImplementedError 16 | if __name__ == .__main__.: 17 | 18 | [tool:pytest] 19 | addopts = --junitxml=test-results/py.test/results.xml 20 | -------------------------------------------------------------------------------- /tests/alexa/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | -------------------------------------------------------------------------------- /bin/compile-translations: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | DOMAIN=squeeze-alexa 5 | 6 | locale_dir="$(dirname $0)/../locale" 7 | which greadlink >/dev/null && rl=greadlink || rl=readlink 8 | # Always succeed - the base_dir is probably correct anyway 9 | locale_dir=$($rl -f "$locale_dir") || true 10 | 11 | echo -e "\nCompiling translations in $locale_dir" 12 | find $locale_dir -iname '*.po' -execdir msgfmt -v -o ${DOMAIN}.mo {} \; 13 | echo "...done." 14 | -------------------------------------------------------------------------------- /tests/squeezebox/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | -------------------------------------------------------------------------------- /tests/transport/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2018 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | -------------------------------------------------------------------------------- /squeezealexa/alexa/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | -------------------------------------------------------------------------------- /squeezealexa/transport/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | -------------------------------------------------------------------------------- /squeezealexa/squeezebox/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | -------------------------------------------------------------------------------- /squeezealexa/alexa/utterances.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | 14 | class Utterances(object): 15 | SELECT_PLAYER = "select" 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "squeeze-alexa" 3 | version = "2.5.0" 4 | description = "Squeezebox integration for Amazon Alexa " 5 | authors = ["Nick Boultbee "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.6" 9 | fuzzywuzzy = "=0.17" 10 | paho-mqtt = "^1.4" 11 | 12 | [tool.poetry.dev-dependencies] 13 | pytest = "^3.0" 14 | coveralls = "^1.5" 15 | flake8 = "^3.6" 16 | pytest-cov = "^2.6" 17 | pytest-sugar = "^0.9.2" 18 | hbmqtt = "^0.9.5" 19 | boto3 = "^1.9" 20 | 21 | [build-system] 22 | requires = ["poetry>=0.12"] 23 | build-backend = "poetry.masonry.api" 24 | -------------------------------------------------------------------------------- /docs/example-config/iot-iam-policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Action": "iot:Connect", 7 | "Resource": "*" 8 | }, 9 | { 10 | "Effect": "Allow", 11 | "Action": "iot:Subscribe", 12 | "Resource": "*" 13 | }, 14 | { 15 | "Effect": "Allow", 16 | "Action": "iot:Publish", 17 | "Resource": "arn:aws:iot:*:*:topic/squeeze-*" 18 | }, 19 | { 20 | "Effect": "Allow", 21 | "Action": "iot:Receive", 22 | "Resource": "arn:aws:iot:*:*:topic/squeeze-*" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2018-2019 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | from logging import basicConfig, INFO 14 | 15 | LOG_FORMAT = "%(asctime)s %(levelname)-7s [%(name)-20s] %(message)s" 16 | basicConfig(level=INFO, format=LOG_FORMAT, datefmt="%H:%M:%S") 17 | -------------------------------------------------------------------------------- /etc/certs/README.md: -------------------------------------------------------------------------------- 1 | `certs` directory 2 | ================= 3 | 4 | Keep certs for in this directory (by default at least). 5 | 6 | `squeeze-alexa.pem` 7 | ------------------- 8 | 9 | The combined cert & private key in PEM format, for SSL transport, 10 | named `squeeze-alexa.pem` unless you've changed `settings.CERT_FILE`. 11 | 12 | 13 | MQTT Certs 14 | ---------- 15 | 16 | If you're using MQTT transport instead, you'll need different ones: 17 | 18 | * A PEM-format certificate named like `SOMETHING-certificate.pem.crt` 19 | * A PEM-format private key for this named like `SOMETHING-private.pem.key` 20 | 21 | This directory is also configurable with `settings.MQTT_CERT_DIR`. 22 | -------------------------------------------------------------------------------- /squeezealexa/alexa/requests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | 14 | class Request(object): 15 | LAUNCH, INTENT, SESSION_ENDED = ("%sRequest" % s for s in 16 | ("Launch", "Intent", "SessionEnded")) 17 | EXCEPTION = "System.ExceptionEncountered" 18 | -------------------------------------------------------------------------------- /bin/update-translations: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | DOMAIN=squeeze-alexa 5 | 6 | # Work out base directory with some cross-platform(ish) magic 7 | which greadlink >/dev/null && rl=greadlink || rl=readlink 8 | root=$($rl -f "$(dirname $0)/..") || true 9 | echo "Detected root at $root" 10 | pushd "$root" >/dev/null 11 | 12 | echo -e "\nUpdating PO from source files..." 13 | find squeezealexa -iname '*.py' | xargs --verbose xgettext --omit-header --width=120 --color=auto --package-name $DOMAIN -o locale/$DOMAIN.pot -d $DOMAIN 14 | echo "...done" 15 | 16 | echo -e "\nMerging translations sources..." 17 | find locale -iname '*.po' -execdir msgmerge --width=120 -U -v {} ../../${DOMAIN}.pot \; 18 | echo "...done" 19 | 20 | echo -e "\nTidying up..." 21 | rm "locale/$DOMAIN.pot" 22 | echo "...done" 23 | popd >/dev/null 24 | -------------------------------------------------------------------------------- /docs/example-config/docker/nginx-tcp-ssl/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes 1; 3 | 4 | error_log /dev/stdout info; 5 | pid /var/run/nginx.pid; 6 | 7 | 8 | events { 9 | worker_connections 128; 10 | } 11 | 12 | 13 | stream { 14 | upstream lms_cli { 15 | server ${INTERNAL_SERVER_HOSTNAME}:9090; 16 | } 17 | 18 | server { 19 | listen ${SSL_PORT} ssl; 20 | proxy_pass lms_cli; 21 | 22 | ssl_certificate /etc/ssl/certs/squeeze-alexa.pem; 23 | ssl_certificate_key /etc/ssl/certs/squeeze-alexa.pem; 24 | ssl_client_certificate /etc/ssl/certs/squeeze-alexa.pem; 25 | ssl_verify_client on; 26 | ssl_protocols TLSv1.2; 27 | ssl_prefer_server_ciphers on; 28 | ssl_session_timeout 4h; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /docs/example-config/stunnel.conf: -------------------------------------------------------------------------------- 1 | ; Sample config for squeeze-alexa. Make sure it's suitable for your usage. 2 | ; In particular make sure you change MY-PORT and MY-HOSTNAME 3 | 4 | 5 | ; Some security enhancements for UNIX systems - comment them out on Win32 6 | chroot = /Apps/opt/var/stunnel/ 7 | ; PID is created inside chroot jail 8 | pid = /stunnel.pid 9 | 10 | ; Some performance tunings 11 | socket = l:TCP_NODELAY=1 12 | socket = r:TCP_NODELAY=1 13 | 14 | ; Security 15 | options = NO_SSLv3 16 | options = NO_SSLv2 17 | options = DONT_INSERT_EMPTY_FRAGMENTS 18 | 19 | ; Reduce connection problems on LMS (see Issue #52) 20 | TIMEOUTidle=7200 21 | 22 | ; Some debugging stuff useful for troubleshooting 23 | debug = 7 24 | output = stunnel.log 25 | 26 | ; Service-level configuration 27 | 28 | ; ########### squeeze-alexa config ########## 29 | [slim] 30 | accept = MY-PORT 31 | ; If your LMS isn't on this server, adjust the below 32 | connect = localhost:9090 33 | 34 | 35 | ; #### These directories might need adjusting for your server ### 36 | cert = /Apps/opt/etc/stunnel/squeeze-alexa.pem 37 | CAfile = /Apps/opt/etc/stunnel/squeeze-alexa.pem 38 | verify = 3 39 | -------------------------------------------------------------------------------- /tests/data/cert-only.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDATCCAemgAwIBAgIJAIQ9YEdkePBEMA0GCSqGSIb3DQEBCwUAMBcxFTATBgNV 3 | BAMMDGJhZC1ob3N0bmFtZTAeFw0xNzAzMjgyMjE4NTJaFw0yNzAzMjYyMjE4NTJa 4 | MBcxFTATBgNVBAMMDGJhZC1ob3N0bmFtZTCCASIwDQYJKoZIhvcNAQEBBQADggEP 5 | ADCCAQoCggEBAL2WMhV2x+LXOHCORnSAUUDuy0A3xtuzRkzqkEPFEC8iwqnTClHq 6 | mGIlwFmBBLopbCVwrZH019HhCe/O6AzYseJle456vqJd1WrhIJjSv9K0M86rtdjg 7 | x9rBCKIJaMu2LzTBYKDnhbRRk7tDf7IZTm4C/ANbS635J0nQ0AYRs1eSBn3OvQRw 8 | 2RLJKmyZ5rioDstDxkTRtheA3vcErYbVsUk6UaPIhX3A116IJVk+Is9D5nYF/n4h 9 | HKcfvL2DVJQhwYIY2KuSi60YZiSZD7sAxM5NVB5zdME4wAA/iXtNnOyQEocTXpiA 10 | sAHCm1G4JZbClOz94JtKfCM4z+k5UTbWAy0CAwEAAaNQME4wHQYDVR0OBBYEFGkk 11 | R2Tk+9YURIzfsoYCqZJ2HldOMB8GA1UdIwQYMBaAFGkkR2Tk+9YURIzfsoYCqZJ2 12 | HldOMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAAiT9b4e1R+1HEcE 13 | pIODFCZvnQrwyehrqvUG2kz34dJIZRk5j1LocyOZ6qP2wUeO+ShDsRKnVL5smmG4 14 | 0VX2lEVh7A3oQR6AUrSwXPZplK8agY4SilAb6RVtJl3wi/hWMbG74lk9h+8f2oUI 15 | zsT6DHql6son5XFmghAXuL359sYOaQeuXLucuEhWeSNTFpwi00E6U1z+pD7y5nu8 16 | dt30qKTEbRyVkZLhJGZOt7S4155KKRAzKxVZ0xjLa6thxzX+1eKlqUNzU4Ph6Gsy 17 | QEhhK4BHbeGynOuIO/27MtXjw/A/VJo+bxCTPKcv8hGXQFo8uMJY5nXFMaBgouQ/ 18 | vXHQOSI= 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /squeezealexa/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-18 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | from os.path import dirname 14 | from typing import Dict, Any 15 | 16 | ROOT_DIR = dirname(dirname(__file__)) 17 | """The squeeze-alexa root directory""" 18 | 19 | 20 | class Settings: 21 | """Class-level settings base. 22 | It's in here to avoid circular imports""" 23 | 24 | def __str__(self) -> str: 25 | return str(self.dict()) 26 | 27 | def dict(self) -> Dict[str, Any]: 28 | return dict(self.__dict__.items()) 29 | 30 | def __init__(self): 31 | # Set the instance-level things: 32 | for k, v in type(self).__dict__.items(): 33 | if not k.startswith('_') and k not in Settings.__dict__: 34 | setattr(self, k.lower(), v) 35 | 36 | def configured(self): 37 | return True 38 | -------------------------------------------------------------------------------- /bin/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Builds the squeeze-alexa codebase: 3 | # * compiling translations 4 | # * copying source, config, docs and scripting to a dist/ folder 5 | # * extracting and cleaning runtime dependencies (using pip) 6 | set -e 7 | 8 | root=$(readlink -f "$(dirname $0)/..") 9 | includes="handler.py squeezealexa/ locale/ etc/ metadata/ bin docs/ README.md" 10 | 11 | pushd "$root" >/dev/null 12 | dist_dir="$PWD/dist" 13 | echo "Building to $dist_dir" 14 | 15 | echo "Installing Dev dependencies..." 16 | poetry install >/dev/null 17 | 18 | echo "Installing runtime dependencies with pip..." 19 | # TODO: safer / prettier way of generating requirements.txt 20 | poetry show --no-dev | sed -r 's/(\S+)\s+(\S+).*/\1==\2/' > requirements.txt 21 | 22 | [ -e "$dist_dir" ] && rm -rf "$dist_dir" 23 | mkdir "$dist_dir" && cd "$dist_dir" 24 | 25 | poetry run pip install -q -r "$root/requirements.txt" -t ./ 26 | 27 | echo "Cleaning up..." 28 | rm "$root"/requirements*.txt 29 | rm -rf ./*.dist-info/ 30 | 31 | echo "Copying source and config..." 32 | for inc in $includes; do 33 | cp -r "$root/$inc" "./$inc" 34 | done 35 | 36 | echo "Compiling translations..." 37 | "$root/bin/compile-translations" 38 | 39 | popd >/dev/null 40 | -------------------------------------------------------------------------------- /squeezealexa/transport/factory.py: -------------------------------------------------------------------------------- 1 | from squeezealexa.settings import MQTT_SETTINGS, SSL_SETTINGS 2 | from squeezealexa.transport.mqtt import CustomClient, MqttTransport 3 | from squeezealexa.transport.ssl_wrap import SslSocketTransport 4 | from squeezealexa.utils import print_d 5 | 6 | 7 | class TransportFactory: 8 | """Create Transports on demand. Helps with caching""" 9 | 10 | def __init__(self, ssl_config=SSL_SETTINGS, mqtt_settings=MQTT_SETTINGS): 11 | self.ssl_config = ssl_config 12 | self.mqtt_settings = mqtt_settings 13 | 14 | def create(self, mqtt_client=None): 15 | if self.mqtt_settings.configured: 16 | s = self.mqtt_settings 17 | print_d("Found MQTT config, so setting up MQTT transport.") 18 | client = mqtt_client or CustomClient(s) 19 | return MqttTransport(client, 20 | req_topic=s.topic_req, 21 | resp_topic=s.topic_resp) 22 | 23 | print_d("Defaulting to SSL transport") 24 | s = self.ssl_config 25 | return SslSocketTransport(hostname=s.server_hostname, 26 | port=s.port, 27 | ca_file=s.ca_file_path, 28 | cert_file=s.cert_file_path, 29 | verify_hostname=s.verify_server_hostname) 30 | -------------------------------------------------------------------------------- /bin/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | function die() { 4 | echo -e "FATAL: $@" 5 | exit 2 6 | } 7 | 8 | 9 | root=$(readlink -f "$(dirname $0)/..") 10 | release_dir="$root/releases" 11 | [ -d "$release_dir" ] || mkdir -p "$release_dir" 12 | 13 | echo "Checking for uncommitted changes..." 14 | files=$(git diff --cached --exit-code --name-only) || die "You have staged Git changes. Commit or stash: \n $files" 15 | files=$(git diff --exit-code --name-only) || die "You have unstaged Git changes. Commit or stash: \n $files" 16 | 17 | echo "Running the local build..." 18 | $root/bin/build.sh 19 | pushd "$root/dist" >/dev/null || die "Perhaps you haven't run the build yet?" 20 | version=${1:-latest} 21 | echo "<<<< Doing release build for version '$version'. Continue?... >>>>" 22 | if [ "$1" != "-y" ]; then 23 | read -n 1 -p "Continue? (ctrl-c to abort)" 24 | fi 25 | 26 | echo -e "\nContinuing with build...\n" 27 | output="squeeze-alexa-release-$version.zip" 28 | 29 | RELEASE_EXCLUDES=$(tr '\n' ' ' <<< """ 30 | *.pem 31 | *.crt 32 | *.key 33 | *.pyc 34 | *__pycache__/* 35 | *.pytest_cache/* 36 | *.cache/* 37 | *.po 38 | *~ 39 | *.egg-info/* 40 | *bin/release* 41 | *-translations 42 | test-results 43 | bin/build.sh""") 44 | 45 | echo "Creating $output (excluding $RELEASE_EXCLUDES)" 46 | rm "$release_dir/$output" 2>/dev/null || true 47 | zip -r "$release_dir/$output" * -x $RELEASE_EXCLUDES 48 | cd "$root" 49 | popd >/dev/null 50 | echo -e "\nSuccess! Created release ZIP: ($(ls -sh "$release_dir/$output"))" 51 | -------------------------------------------------------------------------------- /tests/lambda_handler_test.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from handler import lambda_handler, _ 4 | from tests.alexa.alexa_handlers_test import NO_SESSION 5 | from tests.integration_test import FakeSqueeze 6 | 7 | 8 | def test_entrypoint_error(): 9 | full_response = lambda_handler(None, {}, server=FakeSqueeze()) 10 | assert 'sessionAttributes' in full_response 11 | resp = full_response['response'] 12 | assert resp, "Blank response generated" 13 | assert resp['card']['title'] == _("All went wrong") 14 | 15 | 16 | def test_entrypoint_error_ssml(): 17 | full_response = lambda_handler(None, {}, server=FakeSqueeze()) 18 | resp = full_response['response'] 19 | assert resp['outputSpeech']['type'] == "SSML" 20 | regex = re.compile('.+') 21 | assert regex.match(resp['outputSpeech']['ssml']) 22 | 23 | 24 | def test_entrypoint_ignore_audio(): 25 | request = {'request': {'type': 'AudioPlayer.PlaybackStarted', 26 | 'requestId': 1234}} 27 | full_response = lambda_handler(request, {}, server=FakeSqueeze()) 28 | resp = full_response['response'] 29 | assert not resp, "Should have ignored AudioPlayer request" 30 | 31 | 32 | def test_entrypoint_launch(): 33 | request = {'request': {'type': 'LaunchRequest', 'requestId': 1234}, 34 | 'session': NO_SESSION} 35 | full_response = lambda_handler(request, {}, server=FakeSqueeze()) 36 | resp = full_response['response'] 37 | assert _("Squeezebox is online") in resp['outputSpeech']['text'] 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | *.whl 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | test-results 51 | 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | 58 | # Sphinx documentation 59 | docs/_build/ 60 | 61 | # PyBuilder 62 | target/ 63 | 64 | # IPython Notebook 65 | .ipynb_checkpoints 66 | 67 | # dotenv 68 | .env 69 | 70 | # virtualenv 71 | venv/ 72 | ENV/ 73 | 74 | # Jetbrains 75 | .idea/ 76 | *.iml 77 | 78 | # Custom 79 | /include/ 80 | 81 | /pip-selfcheck.json 82 | /*.pem 83 | metadata/audio/ 84 | 85 | 86 | # Amazon / release prep 87 | *.zip 88 | *.tgz 89 | 90 | *.pot~ 91 | *.po~ 92 | 93 | .pytest_cache 94 | 95 | # Certs (but allow test ones) 96 | *.pem 97 | *.crt 98 | *.key 99 | !tests/**/*.crt 100 | !tests/**/*.key 101 | !tests/**/*.pem 102 | -------------------------------------------------------------------------------- /squeezealexa/i18n.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2018 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | import gettext 14 | from gettext import GNUTranslations 15 | from glob import glob 16 | 17 | from os import path 18 | from os.path import dirname 19 | 20 | from squeezealexa.settings import SKILL_SETTINGS 21 | 22 | LOCALE_DIR = path.abspath(path.join(dirname(dirname(__file__)), 'locale')) 23 | DOMAIN = 'squeeze-alexa' 24 | # Realistically this will have to be the default, sigh. 25 | CODE_LOCALE = "en_US" 26 | 27 | 28 | def set_up_gettext(user_locale): 29 | t = gettext.translation(DOMAIN, localedir=LOCALE_DIR, 30 | languages=[user_locale], fallback=True) 31 | if not isinstance(t, GNUTranslations) and user_locale != CODE_LOCALE: 32 | # Can't import print_d here... 33 | print("No translation file found for requested locale '{locale}', " 34 | "using default ({default}) instead.".format(locale=user_locale, 35 | default=CODE_LOCALE)) 36 | return t.gettext 37 | 38 | 39 | def available_translations(): 40 | files = glob(path.join(LOCALE_DIR, '*', 'LC_MESSAGES', '%s.mo' % DOMAIN)) 41 | return [file.split(path.sep)[-3] for file in files] 42 | 43 | 44 | _ = set_up_gettext(SKILL_SETTINGS.LOCALE) 45 | 46 | # Canary translation 47 | _("favorites") 48 | -------------------------------------------------------------------------------- /tests/genre_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | from unittest import TestCase 14 | 15 | from squeezealexa.main import SqueezeAlexa 16 | from tests.utils import GENRES 17 | 18 | 19 | class GenreTest(TestCase): 20 | 21 | def setUp(self): 22 | self.alexa = SqueezeAlexa(server=None) 23 | 24 | def get_results(self, *slots): 25 | return self.alexa._genres_from_slots(slots, GENRES) 26 | 27 | def test_difficult_ands(self): 28 | results = self.get_results('R', 'B') 29 | assert 'R and B' in results 30 | 31 | def test_dnb(self): 32 | results = self.get_results('Drum', 'Base') 33 | assert 'Drum n Bass' in results 34 | 35 | def test_complete_answer(self): 36 | results = self.get_results('blues', 'bluegrass') 37 | assert results == {'Blues', 'Bluegrass'} 38 | 39 | def test_complete_answer_overlapping_words(self): 40 | results = self.get_results('funk rock') 41 | assert results == {'Funk', 'Rock'} 42 | 43 | def test_hyphenation(self): 44 | results = self.get_results('hip-hop') 45 | assert results == {'Hip Hop'} 46 | 47 | def test_exact(self): 48 | results = self.get_results('dub') 49 | assert results == {'Dub'} 50 | results = self.get_results('House') 51 | assert results == {'House'} 52 | -------------------------------------------------------------------------------- /tests/transport/test_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2018 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | import pytest 14 | from pytest import raises 15 | 16 | from squeezealexa.settings import MqttSettings, SslSettings 17 | from squeezealexa.transport.base import check_listening, Error 18 | from squeezealexa.transport.factory import TransportFactory 19 | from tests.transport.base import TimeoutServer 20 | 21 | UNCONFIGURED_MQTT_SETTINGS = MqttSettings(hostname=None) 22 | 23 | 24 | def test_check_listening(): 25 | with TimeoutServer() as server: 26 | check_listening("localhost", server.port, timeout=1) 27 | 28 | wrong_port = server.port + 1 29 | with pytest.raises(Error) as e: 30 | check_listening("localhost", wrong_port, timeout=1, 31 | msg="OHNOES") 32 | s = str(e) 33 | assert ("on localhost:%d" % wrong_port) in s 34 | assert "OHNOES" in s 35 | 36 | 37 | def test_create_transport_uses_full_ca_path(): 38 | ssls = SslSettings() 39 | ssls.ca_file_path = "/foo/bar" 40 | with raises(Error) as e: 41 | TransportFactory(ssls, UNCONFIGURED_MQTT_SETTINGS).create() 42 | assert ("CA '%s'" % ssls.ca_file_path) in str(e) 43 | 44 | 45 | def test_create_transport_uses_cert_path(): 46 | ssls = SslSettings() 47 | ssls.cert_file_path = "/foo/bar" 48 | with raises(Error) as e: 49 | TransportFactory(ssls, UNCONFIGURED_MQTT_SETTINGS).create() 50 | assert ("cert '%s'" % ssls.cert_file_path) in str(e) 51 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: python:3.6 6 | environment: 7 | - LC_ALL: C.UTF-8 8 | - LANG: C.UTF-8 9 | steps: 10 | - checkout 11 | 12 | - run: 13 | name: Set up various tools 14 | command: apt update && apt install -y gettext zip unzip 15 | 16 | - run: 17 | name: Set up Poetry 18 | command: pip -q install poetry 19 | 20 | - run: 21 | name: Install all dependencies 22 | command: poetry install 23 | 24 | - run: 25 | name: Compile translations 26 | command: bin/compile-translations 27 | 28 | - run: 29 | name: Tests 30 | command: poetry run pytest -v --cov=squeezealexa --cov-report=term tests 31 | 32 | - store_test_results: 33 | path: test-results 34 | 35 | - store_artifacts: 36 | path: test-results 37 | 38 | - run: 39 | name: Code Quality 40 | command: poetry run flake8 --statistics . 41 | 42 | - run: 43 | name: Coverage submission 44 | command: poetry run coveralls 45 | 46 | - run: 47 | name: Test build script 48 | command: bin/build.sh 49 | 50 | - run: 51 | name: Test release script 52 | command: bin/release.sh -y 53 | 54 | - run: 55 | name: Test mqtt-squeeze zip creation 56 | command: touch dist/etc/certs/foo.pem.crt && poetry run bin/deploy.py mqtt 57 | 58 | - run: 59 | name: Test deploy script (from source) 60 | command: poetry run bin/deploy.py zip 61 | 62 | - run: 63 | name: Test deploy script (from source, from dist/bin/) 64 | command: cd dist/bin && poetry run ./deploy.py zip 65 | 66 | - run: 67 | name: Test deploy script (without build) 68 | command: rm -rf dist/ tests/ && poetry run bin/deploy.py zip 69 | 70 | -------------------------------------------------------------------------------- /tests/test_squeezealexa.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2018 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | from unittest.mock import MagicMock 14 | 15 | import pytest 16 | 17 | from squeezealexa.main import SqueezeAlexa 18 | from squeezealexa.squeezebox.server import Server 19 | from tests.integration_test import speech_in 20 | 21 | 22 | @pytest.fixture(scope="module") 23 | def mock_server(): 24 | return MagicMock(Server) 25 | 26 | 27 | @pytest.fixture(scope="module") 28 | def alexa(mock_server): 29 | return SqueezeAlexa(server=mock_server) 30 | 31 | 32 | class TestWithStubbedServer: 33 | 34 | def test_no_artist(self, mock_server, alexa): 35 | details = {"title": ["BBC Radio 4"]} 36 | mock_server.get_track_details = MagicMock(return_value=details) 37 | 38 | resp = alexa.now_playing([], None) 39 | speech = speech_in(resp) 40 | assert "Currently playing: \"BBC Radio 4\"" in speech 41 | 42 | def test_no_title(self, mock_server, alexa): 43 | details = {"artist": ["Someone"]} 44 | mock_server.get_track_details = MagicMock(return_value=details) 45 | resp = alexa.now_playing([], None) 46 | speech = speech_in(resp) 47 | assert "Nothing playing." == speech 48 | 49 | def test_multi_artist(self, mock_server, alexa): 50 | details = {"artist": ["Someone", "Someone Else"], 51 | "title": ["Something"]} 52 | mock_server.get_track_details = MagicMock(return_value=details) 53 | resp = alexa.now_playing([], None) 54 | speech = speech_in(resp) 55 | assert "Currently playing: \"Something\", " \ 56 | "by Someone and Someone Else." == speech 57 | -------------------------------------------------------------------------------- /squeezealexa/alexa/intents.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | 14 | class Audio(object): 15 | (RESUME, PAUSE, 16 | NEXT, PREVIOUS, 17 | LOOP_ON, LOOP_OFF, 18 | SHUFFLE_ON, SHUFFLE_OFF) = ("AMAZON.%sIntent" % s 19 | for s in ["Resume", "Pause", 20 | "Next", "Previous", 21 | "LoopOn", "LoopOff", 22 | "ShuffleOn", "ShuffleOff"]) 23 | 24 | 25 | class Play(object): 26 | RANDOM_MIX, PLAYLIST = ("Play%sIntent" % s 27 | for s in ["RandomMix", "Playlist"]) 28 | 29 | 30 | class CustomAudio(object): 31 | LOOP_ON, LOOP_OFF, SHUFFLE_ON, SHUFFLE_OFF = ("%sIntent" % s for s in 32 | ["LoopOn", "LoopOff", 33 | "ShuffleOn", "ShuffleOff"]) 34 | 35 | 36 | class Power(object): 37 | (ALL_OFF, ALL_ON, 38 | PLAYER_OFF, PLAYER_ON) = ("%sIntent" % s 39 | for s in ["AllOff", "AllOn", 40 | "TurnOffPlayer", "TurnOnPlayer"]) 41 | 42 | 43 | class General(object): 44 | HELP, CANCEL, STOP = ("AMAZON.%sIntent" % s 45 | for s in ["Help", "Cancel", "Stop"]) 46 | 47 | 48 | class Custom(object): 49 | HELP, CANCEL, STOP = ("%sIntent" % s 50 | for s in ["Help", "Cancel", "Stop"]) 51 | INC_VOL, DEC_VOL = ("%sVolumeIntent" % s 52 | for s in ["Increase", "Decrease"]) 53 | SET_VOL, SET_VOL_PERCENT = ("%sIntent" % s 54 | for s in ["SetVolume", "SetVolumePercent"]) 55 | NOW_PLAYING, SELECT_PLAYER = ("%sIntent" % s 56 | for s in ["NowPlaying", "SelectPlayer"]) 57 | -------------------------------------------------------------------------------- /handler.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-18 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | from traceback import format_exc 14 | 15 | from squeezealexa.alexa.response import speech_response 16 | from squeezealexa.main import SqueezeAlexa 17 | from squeezealexa.settings import SKILL_SETTINGS, LMS_SETTINGS 18 | from squeezealexa.squeezebox.server import ServerFactory 19 | from squeezealexa.transport.factory import TransportFactory 20 | 21 | try: 22 | from squeezealexa.i18n import _ 23 | except ImportError: 24 | def _(s): 25 | return s 26 | 27 | ERROR_SPEECH = _("d'oh: " 28 | "{type} - {message}.") 29 | 30 | factory = ServerFactory(TransportFactory()) 31 | 32 | 33 | def get_server(): 34 | return factory.create(user=LMS_SETTINGS.username, 35 | password=LMS_SETTINGS.password, 36 | cur_player_id=LMS_SETTINGS.default_player, 37 | debug=LMS_SETTINGS.debug) 38 | 39 | 40 | def lambda_handler(event, context, server=None): 41 | """ Route the incoming request based on type (LaunchRequest, IntentRequest, 42 | etc.) The JSON body of the request is provided in the event parameter. 43 | """ 44 | try: 45 | sqa = SqueezeAlexa(server=server or get_server(), 46 | app_id=SKILL_SETTINGS.application_id) 47 | return sqa.handle(event, context) 48 | except Exception as e: 49 | if not SKILL_SETTINGS.use_spoken_errors: 50 | raise e 51 | # Work with AWS stack-trace log magic 52 | print(format_exc().replace('\n', '\r')) 53 | error = str(e.msg if hasattr(e, "msg") else e) 54 | speech = ERROR_SPEECH.format(type=type(e).__name__, message=error) 55 | return speech_response(title=_("All went wrong"), speech=speech, 56 | text=error, use_ssml="SSML") 57 | -------------------------------------------------------------------------------- /bin/local_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2017-18 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | import sys 14 | from os.path import dirname, realpath 15 | from traceback import print_exc 16 | 17 | sys.path.append(dirname(dirname(realpath(__file__)))) 18 | 19 | from squeezealexa.settings import * 20 | from squeezealexa.transport.factory import TransportFactory 21 | from squeezealexa.squeezebox.server import Server, people_from 22 | from squeezealexa.transport.base import Transport 23 | 24 | TEST_GENRES = ["Rock", "Latin", "Blues"] 25 | 26 | 27 | def run_diagnostics(transport: Transport): 28 | server = Server(transport=transport, 29 | debug=LMS_SETTINGS.DEBUG, 30 | cur_player_id=LMS_SETTINGS.DEFAULT_PLAYER, 31 | user=LMS_SETTINGS.USERNAME, 32 | password=LMS_SETTINGS.PASSWORD) 33 | assert server.genres 34 | assert server.playlists 35 | cur_play_details = server.get_track_details() 36 | if cur_play_details: 37 | print("Currently playing: \n >> %s" % 38 | "\n >> ".join("%s: %s" % (k, ", ".join(v)) 39 | for k, v in cur_play_details.items())) 40 | else: 41 | print("Nothing currently in playlist") 42 | 43 | d = server.get_track_details(offset=+1) 44 | print("Up next: %s >> %s >> %s" % (d.get('genre', ["Unknown genre"])[0], 45 | people_from(d, ["Unknown people"])[0], 46 | d.get('title', ['Unknown track'])[0])) 47 | del server 48 | 49 | 50 | def die(e): 51 | print_exc() 52 | print("\n>>>> Failed with %s: %s <<<<" % (type(e).__name__, e)) 53 | sys.exit(2) 54 | 55 | 56 | if __name__ == '__main__': 57 | try: 58 | transport = TransportFactory().create().start() 59 | run_diagnostics(transport) 60 | print("\n>>>> Looks good! <<<<") 61 | sys.exit(0) 62 | except Exception as e: 63 | die(e) 64 | -------------------------------------------------------------------------------- /squeezealexa/transport/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-18 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | import socket 14 | from socket import SHUT_RDWR 15 | from typing import List 16 | 17 | MAX_CONNECT_SECS = 3 18 | """Various connection timeouts""" 19 | 20 | 21 | class Error(Exception): 22 | 23 | def __init__(self, msg, e=None): 24 | super(Error, self).__init__(msg) 25 | self.message = msg 26 | self.__cause__ = e 27 | 28 | 29 | class Transport: 30 | """Communications transport 31 | for half-duplex / send-then-maybe-listen mode of communications""" 32 | 33 | def __init__(self) -> None: 34 | self.is_connected = False 35 | 36 | def communicate(self, data: str, wait=True) -> List[str]: 37 | """Send `data`, waiting if `wait` is True 38 | :param data: String to send. 39 | A final newlines will be added if not present 40 | :param wait: Block for response if True 41 | :return: response lines, if any""" 42 | raise NotImplementedError() 43 | 44 | @property 45 | def details(self): 46 | """Property for connection details""" 47 | raise NotImplementedError() 48 | 49 | def start(self) -> 'Transport': 50 | self.is_connected = True 51 | return self 52 | 53 | def stop(self) -> 'Transport': 54 | self.is_connected = False 55 | return self 56 | 57 | def __str__(self) -> str: 58 | return self.details 59 | 60 | def __del__(self) -> None: 61 | self.is_connected = False 62 | 63 | 64 | def check_listening(host, port, timeout=MAX_CONNECT_SECS, msg=""): 65 | """Checks a socket, then releases""" 66 | try: 67 | s = socket.create_connection((host, port), timeout=timeout) 68 | except socket.error as err: # noqa: F841 69 | raise Error("Couldn't find anything at all on {host}:{port} - " 70 | "{msg}({err})".format(**locals())) 71 | else: 72 | s.shutdown(SHUT_RDWR) 73 | s.close() 74 | -------------------------------------------------------------------------------- /docs/squeeze-alexa-networking.xml: -------------------------------------------------------------------------------- 1 | 7VtRb6M4EP41lW4fGmEbE3hs0ubuoSut1Ie7faoIOAlbgrNgNun++rODTQA7DSnOtpXaSBWMDYaZ+cbfjM0Vmq53f+fhZvWVxiS9gk68u0K3VxD67pj/F4LnShAgUAmWeRJXoobgIflNpNCR0jKJSdHqyChNWbJpCyOaZSRiLVmY53Tb7ragaXvUTbgkmuAhClNd+m8Ss1Ulxb57kP9DkuVKjuxDr2qYh9HTMqdlJoe7gmix/6ua16G6lXzPYhXGdNsQobsrNM0pZdXRejclqdCs0lp13exIa/3YOclYnwsCaadfYVrKVy9+loT8JtdcF7tQPiV7VophZMdvPFmxdcoFgB8WLKdPZEpTmnNJRjPec7JI0rQjCtNkmfHTiD8a4fLJL5KzhKv8RjaskzgWw0y2q4SRh00YiTG33L/EDWnGpJtgj59T3pww4V3cZapmNR7XOXbED4kW+X58LLI7qiRQq547NKFrwvJn3kVdgKV1pTMDF1fn24NrACBlq4ZbIGnlUHrjsr71wST8QFrFbKGxq1lIM8re4UgsDWJSX8NeDU3yZ26bSrgrFj/NrrzF2//ZUanyf6lRpM4bGsXYMWjUtaBSBdaGBknM4S9Pac5WdEmzML07SCdtHTf0SXYJ+48fOyMsz743Wr6RPOEPKBz+Vrgj10/+3OguTr8327oX/CCMPUt7hSWjwvfrB7yndNOCoQpWcH87FVgDkzmxK361OTXbcf3QMo+kgpAMwmG+JKqX7CZ096LNc5KGLPnVDq1DDIhOQyIOi9XeWk4nWq3Cjeiy3i3FxDUKtwUaRSktYx0KM+xjJDTEu8YJOUQYGdEOMchzXtKjhpSjsEDq1VSk0WEBXQMsALQAC8+yVleMbR43OWU0oqmuXQwDdzrro11sSbt+O+i4vq5dzxDFXc9CFMeXCDmgEXAO4UcPOc04cF48McULPdJ0pxRjXDgZYvyB0UNe+o0mfJADpFB78vY7SKkimrzoYNCbPA+fG902okNxfBg47sxoQYd4nejvOx13qh7g4Fy1Rnr5m28ZyyRaUR3C0MGTIDgOYQuYBUE7IiIXaaB1he9rsFUXDoJt8C6YAuL2bHKF0fgYW/goWFfcocknBtOHVwHX7Uy5MAAvAtf14Uv9ByNXacYadNNwPY/Dx0WZRSyh2fk0xwav8f02r/ECfeoNdAyP4XAMo4tMvWdiGLTxi98Ov7XvgL7UH7wl96fzH6K+A500nJO0iYcaMKgxsrT9a7EyL6Mn8YpdiNw5GPKJpwdXHVviqriTIY91rooMiYB7vob5qVTykWzZvwR++lHX1ye/A9Ndpf2mz/t/yOV1Czinp4ROAcxxPG86NRTZmNBZLb0XoPpGi6SaGW7nlDG65h3STkNdtNOqeM3KUhs00/2fsZLk+2DyApyU+DbJiZy0eFMujGsydQfJGWFbmj8Vo7Ig+eN6X9O1Asn2HFYnFk0m6umQtJKb+0CzuJVJDIGgBcQgOILEdziT2WGi0MBEMX4TJqqMrOoT3gkm6rjD+gPPKnOFPZjrZ5jSw9SWX56SonhclXM7kcp1u0kz0EIVAAa+DRwLsQp9+sGr/KAgUZmLcG3FBVAnrTUUO6FpgcWGA/RYsjqvPi/sv+AewMj5dWQbyoTtGmJdK2/V5Q3KdIcr0zWRPy8VScqcHyzFQWfJtmrlN647aOp/B8u4jTl7WG1BrbzWa4l6sDOtmdigZW6PNRPl0cl6v+egqfMh0W5S7zRoLt5Wew3QZD/YTbGp9kYIjIXqZJHs9kFGPs+tWKbhKrgRLw1nUZzBURLRbJFw7piPIj4inMUh4341E3JOYGaLnJAoK66jcl2mpZAIKjHDIMC+d+346PFrWSTRNYD+aJMtDXbfDz5pbpbQMmnX0uq922ZV2PV7uYeNhWa3Rxn+0z3e2D3a0cMzLLkiQ1aHLCwK4h5ESShgc/Q15fancK66O+e+PnTadSYMoE4VTTsxfAsKAD3w8ckUdaaYhcUjf6b9Y+oGx/2n0FNG9mzU34OjBEbYtmVt72dJVcN1sQ8IPPA4AG92h0ZFau6/PvC2vyT5KUjOX/hLg/1Ud39nBGiQuVSM0tmn0Xo2GA7WEWmj8HQ1dO3kYxSXVIBrFpcqzvini0tanA/cphto/UGXNp3b3/M7bjasuITRpx9a9kMr+22G+qEH7fqJ51zET3qu9n4Mb8BY9wYw1Bt6cz4THfjkfKcXs7KqPminaFJvdFRrAXiscQpTeRDaSHt6lAcvnvZ09oIiX9+Q4hs2leHAwvvjTwTYyXpOJP6Vox2l0YZM32RyZGEfITbVCW3kQA8P9+Klc7p7/hiZzzCTuXgEx4HjBWNv7LrA1xMhkwUBGo2hEyAHIg4+HyALIO4xi32oJY76wxoVEMd6FV0t7LXKpBYqBOMeVfTPgHh6wXDYF1kdRgANa8aXWjD0dUbwgERtJyzjhH55NyHMTL+HlZ87RAS4+qw0vlBtR6b0F8ypX/oopd7dHrQ/hHtPW4qMX3jZSL6H1oCO7+U8fDtcZc+Hz7PR3f8= -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2018 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | from os.path import dirname, join, realpath 14 | 15 | ROOT = dirname(dirname(__file__)) 16 | TEST_DATA_DIR = realpath(join(dirname(__file__), 'data')) 17 | 18 | GENRES = """ 19 | Blues 20 | Classic Rock 21 | Country 22 | Dance 23 | Disco 24 | Funk 25 | Grunge 26 | Hip Hop 27 | Jazz 28 | Metal 29 | New Age 30 | Oldies 31 | Pop 32 | R and B 33 | RNB 34 | Rap 35 | Reggae 36 | Rock 37 | Techno 38 | Industrial 39 | Alternative 40 | Ska 41 | Death Metal 42 | Soundtrack 43 | Ambient 44 | Trip Hop 45 | TripHop 46 | Vocal 47 | Jazz Funk 48 | Fusion 49 | Trance 50 | Classical 51 | Instrumental 52 | Acid 53 | House 54 | Game 55 | Sound Clip 56 | Gospel 57 | Noise 58 | Bass 59 | Soul 60 | Punk 61 | Space 62 | Meditative 63 | Instrumental Pop 64 | Instrumental Rock 65 | Ethnic 66 | Gothic 67 | Darkwave 68 | Electronic 69 | Eurodance 70 | Dream 71 | Southern Rock 72 | Comedy 73 | Cult 74 | Gangsta 75 | Top 40 76 | Christian Rap 77 | Jungle 78 | Native American 79 | Cabaret 80 | New Wave 81 | Psychadelic 82 | Rave 83 | Showtunes 84 | Trailer 85 | Tribal 86 | Acid Punk 87 | Acid Jazz 88 | Polka 89 | Retro 90 | Musical 91 | Rock and Roll 92 | Rock N Roll 93 | Hard Rock 94 | Folk 95 | Folk Rock 96 | National Folk 97 | Swing 98 | Fast Fusion 99 | Bebop 100 | Latin 101 | Revival 102 | Celtic 103 | Bluegrass 104 | Avantgarde 105 | Gothic Rock 106 | Progressive Rock 107 | Psychedelic Rock 108 | Symphonic Rock 109 | Slow Rock 110 | Big Band 111 | Chorus 112 | Easy Listening 113 | Acoustic 114 | Humour 115 | Speech 116 | Opera 117 | Chamber Music 118 | Sonata 119 | Symphony 120 | Booty Bass 121 | Primus 122 | Slow Jam 123 | Club 124 | Tango 125 | Samba 126 | Folklore 127 | Ballad 128 | Power Ballad 129 | Rhythmic Soul 130 | Freestyle 131 | Duet 132 | Punk Rock 133 | Drum Solo 134 | A capella 135 | Euro House 136 | Dance Hall 137 | Dubstep 138 | Trap 139 | Drum n Bass 140 | DNB 141 | Breakbeat 142 | Big Beat 143 | Breaks 144 | Hardcore 145 | Electro 146 | Garage 147 | UK Garage 148 | Dub 149 | Grime 150 | DJ Mix 151 | Mash up 152 | Flamenco 153 | Bossanova 154 | Pop Punk 155 | Soft Rock 156 | Alt Rock 157 | Rock Ballad 158 | Spoken 159 | Podcast 160 | Oldschool 161 | Oldschool Hardcore 162 | Acid House 163 | Old school Hiphop 164 | World 165 | Brit Rock 166 | Indie 167 | Psy Trance 168 | Baroque 169 | Romantic 170 | """.splitlines() 171 | -------------------------------------------------------------------------------- /tests/test_gettext.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2018 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | import os 14 | from squeezealexa.i18n import _, set_up_gettext, available_translations 15 | 16 | UNSUPPORTED_LOCALE = "ku.UTF-8" 17 | AN_UNTRANSLATED_STRING = 'foobar baz' 18 | REQUIRED_TRANSLATIONS = ['favorites', 19 | 'Currently playing: "{title}"', 20 | 'Playing mix of {genres}', 21 | 'Shuffle is now off'] 22 | 23 | 24 | def test_gettext_basic(): 25 | assert _(AN_UNTRANSLATED_STRING) == AN_UNTRANSLATED_STRING 26 | 27 | 28 | def test_binding_respects_language(): 29 | _ = set_up_gettext("en_US.UTF-8") 30 | assert _("favorites") == "favorites" 31 | 32 | 33 | def test_gettext_uses_fallback(): 34 | _ = set_up_gettext(UNSUPPORTED_LOCALE) 35 | assert _("favorites") == "favorites" 36 | 37 | 38 | def test_binding_uses_settings_locale(): 39 | with NewLocale("fr_FR"): 40 | _ = set_up_gettext("en_GB.UTF-8") 41 | assert _("favorites") == "favourites" 42 | 43 | 44 | def test_some_german_works(): 45 | _ = set_up_gettext("de_DE.UTF-8") 46 | assert _("favorites") == "Favoriten" 47 | assert _("Playing mix of {genres}") == "Spiele eine Mischung aus {genres}" 48 | 49 | 50 | def test_some_french_works(): 51 | _ = set_up_gettext("fr.UTF-8") 52 | assert _("favorites") == "favoris" 53 | french = "La lecture aléatoire est maintenant désactivée" 54 | assert _("Shuffle is now off") == french 55 | 56 | 57 | class TestTranslations: 58 | def test_all_langs(self): 59 | langs = available_translations() 60 | assert 'en_GB' in langs 61 | assert len(langs) >= 2 62 | 63 | def test_each_lang(self): 64 | langs = set(available_translations()) - {'en_GB'} 65 | for lang in langs: 66 | translate = set_up_gettext(lang) 67 | for text in REQUIRED_TRANSLATIONS: 68 | translated = translate(text) 69 | assert translated 70 | msg = "'{text}' is untranslated for {lang}".format(**locals()) 71 | assert translated != text, msg 72 | 73 | 74 | class NewLocale(object): 75 | 76 | def __init__(self, loc): 77 | self.loc = loc 78 | self.old = None 79 | 80 | def __enter__(self): 81 | self.old = os.environ["LANG"] 82 | os.environ["LANG"] = self.loc 83 | return self 84 | 85 | def __exit__(self, exc_type, exc_val, exc_tb): 86 | os.environ["LANG"] = self.old 87 | -------------------------------------------------------------------------------- /tests/data/cert-and-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC/zCCAeegAwIBAgIJANMeFYav1jgsMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV 3 | BAMMC3Rlc3Quc2VydmVyMB4XDTE3MDQyNzExNDAwOVoXDTI3MDQyNTExNDAwOVow 4 | FjEUMBIGA1UEAwwLdGVzdC5zZXJ2ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw 5 | ggEKAoIBAQC9clXuk9stIGarwt2oIUzmYSB7UiP1ktfxpYcqf/F1xN4PF9I7bByl 6 | 0q6g4IJYJMfNlNZG3cwOYyQJkoo5rpho//DpuTroAI7/Rf9a3CFuzeJOxA9HUx76 7 | FGZdoY2uPcY+WPe51vxtY1FnTOK4dsJPa3q6kWN550Msee1WCyAm8D2Ergx/GM7S 8 | J8PCmgD7nc8GPJv7TrncSMYQZuUV8qpHr5jswfHTB+04HFQBYG+1qlajQpC4JTjT 9 | 9crv8bRosWm0H5CwhH3hdxJtizgvITLhFLkp4rsDpe+lUuXPrYI1E3KzpO6/t5ng 10 | STyeMdREzztzjzOYZrQVEF9EG35P/00BAgMBAAGjUDBOMB0GA1UdDgQWBBQPXsnd 11 | XE7k9Symz+620h+t7woMMzAfBgNVHSMEGDAWgBQPXsndXE7k9Symz+620h+t7woM 12 | MzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBOhHBPEbQL8+fBxvLB 13 | sFeljL+1Io/Hy46iRRk9OcMeLRrueuQYE7TW5mS4XwtDS3YtCUIe7Mo2md9Mfbv5 14 | fG2feBeTMbu2Ry/Yh1AA2r3VoGK1nHlpnBeaAZhf6BlQpN6DWPqgS3aiGJPFTooj 15 | c660IN4dmGHsNK23n2JR0KsYYJtlGLV6GD1dngH+K1NHAqtClYj4NdZ2R1yPIYNA 16 | +35YKJxXMrmJVl0UkzVQ0b5J5IbGtVo5ICmHwWt/Rjf0qmXsC08xI2xFs6AYGYDu 17 | NjqC1Ol2Zz14ZzfH0F/x+QGMdNyKfDANDPQ+S3uOADDoF99eARAXYVVT8Boym08q 18 | pIK9 19 | -----END CERTIFICATE----- 20 | -----BEGIN PRIVATE KEY----- 21 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9clXuk9stIGar 22 | wt2oIUzmYSB7UiP1ktfxpYcqf/F1xN4PF9I7bByl0q6g4IJYJMfNlNZG3cwOYyQJ 23 | koo5rpho//DpuTroAI7/Rf9a3CFuzeJOxA9HUx76FGZdoY2uPcY+WPe51vxtY1Fn 24 | TOK4dsJPa3q6kWN550Msee1WCyAm8D2Ergx/GM7SJ8PCmgD7nc8GPJv7TrncSMYQ 25 | ZuUV8qpHr5jswfHTB+04HFQBYG+1qlajQpC4JTjT9crv8bRosWm0H5CwhH3hdxJt 26 | izgvITLhFLkp4rsDpe+lUuXPrYI1E3KzpO6/t5ngSTyeMdREzztzjzOYZrQVEF9E 27 | G35P/00BAgMBAAECggEAZQywk2JgfVCUEzgM3Vpt11Clb8IfliSudKGTWHMIS4Yn 28 | 4CsXo0SGf1jCduNqAzQTMzAZn5E8/8uX9FcqzZu4hgFG8pQvvIJXxAgFLeTPHHhL 29 | JzJi5+uJUWFCPN0oYnFm5ei0snbfDEX2rMARCs2l1ZEB1LQqOGLHrNiYZNXoX3vI 30 | kMrXG2aVB9eW4t45aY2g/aGmzmYjRPbCCZGM2Srs/y+IuLwRGokQUEGq9E3kDeff 31 | blDVo0ozHSgqueUgyOfKuENajZiuRK7Q/wZMNTfoyfmgilZtlHVnfHwLOJJv+ONq 32 | MhIc5dapd4c3uxir9dN05A86vRFkKdXpKeD4UEZs8QKBgQDe6s44tsM9gumbIEae 33 | dGts2EEbIpfzAfWDNQXg1X/BwKcvszLsPaQDaHBaISQt65eb8LSz4Xm+YeuQm8xs 34 | 7qR6lrnwMnG4DJBhzsi/rbsS08kmqZoyMkYF51Xd2qyGOvArSJH7ULcxsqFBpczK 35 | oSN5G0P6eRDw0M9+x6Wght8DFQKBgQDZj+Wkh+2h5qeK8iBxK0KQYVueQagy05ou 36 | CNKtoG/REfde1MUXPi7z075gwOwSXYGK8ONTYS8AYr1TsFMpc3hRbfPajyRmojVw 37 | hUeg1pyeIWMig4Nc7EXwEf1p/yRLDTCuEo4ktlsF5CPaDqHuKoLw9g0WKKvWxFCs 38 | WI+KVcWNPQKBgQDQtoYpAa2hvR3eOYUFPTmLqpqivmwIgdAObim3zg4VKb0fYygN 39 | mtUiv7laGeeW+xtzTRbTyQvE3kfBmK35XCMyCEfFhmWFDnZsrUC2fwJF6XdPtMKD 40 | tyBqzKNP2jtoXmyaChNse7FaXcawAX3SRrdA3+9w58OdxdRQ/rqrpzvM1QKBgHGM 41 | vgGShD9U4CsP1kjamEI83hGKGRx1/ml6Z2MBcnq/Esnm6PsJlH3kDN/4sP2g2gTH 42 | Vw5kkaB06HWueKkQwEJXzcdLoGcE0DlrBoh2moWZzWDtHPm5w4LaENZquOmG99pS 43 | iue0WWuIuAGOU4u3mmHjOc9P6OgNzEE3c6tyusnNAoGALAYca18gtE93BkWnChvq 44 | wyzobgaxPyr+av788S+FwGje3DKnoiMSrPVIZPY/tIX/JPoF8m87cJgtK0aCSzKU 45 | rLLdLgdaJ0HVLiEvKHB+QtqEhqaVZdr0xFJ2yOtHXu9AJLgdFTOLe4511II2dYFS 46 | FI5FvfQnZ4wgvCSEBPbTZ9Y= 47 | -----END PRIVATE KEY----- 48 | -------------------------------------------------------------------------------- /tests/data/bad-hostname.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDATCCAemgAwIBAgIJAIQ9YEdkePBEMA0GCSqGSIb3DQEBCwUAMBcxFTATBgNV 3 | BAMMDGJhZC1ob3N0bmFtZTAeFw0xNzAzMjgyMjE4NTJaFw0yNzAzMjYyMjE4NTJa 4 | MBcxFTATBgNVBAMMDGJhZC1ob3N0bmFtZTCCASIwDQYJKoZIhvcNAQEBBQADggEP 5 | ADCCAQoCggEBAL2WMhV2x+LXOHCORnSAUUDuy0A3xtuzRkzqkEPFEC8iwqnTClHq 6 | mGIlwFmBBLopbCVwrZH019HhCe/O6AzYseJle456vqJd1WrhIJjSv9K0M86rtdjg 7 | x9rBCKIJaMu2LzTBYKDnhbRRk7tDf7IZTm4C/ANbS635J0nQ0AYRs1eSBn3OvQRw 8 | 2RLJKmyZ5rioDstDxkTRtheA3vcErYbVsUk6UaPIhX3A116IJVk+Is9D5nYF/n4h 9 | HKcfvL2DVJQhwYIY2KuSi60YZiSZD7sAxM5NVB5zdME4wAA/iXtNnOyQEocTXpiA 10 | sAHCm1G4JZbClOz94JtKfCM4z+k5UTbWAy0CAwEAAaNQME4wHQYDVR0OBBYEFGkk 11 | R2Tk+9YURIzfsoYCqZJ2HldOMB8GA1UdIwQYMBaAFGkkR2Tk+9YURIzfsoYCqZJ2 12 | HldOMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAAiT9b4e1R+1HEcE 13 | pIODFCZvnQrwyehrqvUG2kz34dJIZRk5j1LocyOZ6qP2wUeO+ShDsRKnVL5smmG4 14 | 0VX2lEVh7A3oQR6AUrSwXPZplK8agY4SilAb6RVtJl3wi/hWMbG74lk9h+8f2oUI 15 | zsT6DHql6son5XFmghAXuL359sYOaQeuXLucuEhWeSNTFpwi00E6U1z+pD7y5nu8 16 | dt30qKTEbRyVkZLhJGZOt7S4155KKRAzKxVZ0xjLa6thxzX+1eKlqUNzU4Ph6Gsy 17 | QEhhK4BHbeGynOuIO/27MtXjw/A/VJo+bxCTPKcv8hGXQFo8uMJY5nXFMaBgouQ/ 18 | vXHQOSI= 19 | -----END CERTIFICATE----- 20 | -----BEGIN PRIVATE KEY----- 21 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9ljIVdsfi1zhw 22 | jkZ0gFFA7stAN8bbs0ZM6pBDxRAvIsKp0wpR6phiJcBZgQS6KWwlcK2R9NfR4Qnv 23 | zugM2LHiZXuOer6iXdVq4SCY0r/StDPOq7XY4MfawQiiCWjLti80wWCg54W0UZO7 24 | Q3+yGU5uAvwDW0ut+SdJ0NAGEbNXkgZ9zr0EcNkSySpsmea4qA7LQ8ZE0bYXgN73 25 | BK2G1bFJOlGjyIV9wNdeiCVZPiLPQ+Z2Bf5+IRynH7y9g1SUIcGCGNirkoutGGYk 26 | mQ+7AMTOTVQec3TBOMAAP4l7TZzskBKHE16YgLABwptRuCWWwpTs/eCbSnwjOM/p 27 | OVE21gMtAgMBAAECggEAEvB1MmW6VDPx5HSiHzNOarEwRssLp5kCNd7c6JhAJKVR 28 | UwvNJR/Nd0iULZzQ7xQCRL8756/Q+5uClZ1S2y2un5JJxJMIhknfbxzsV7f+B7uO 29 | zV+j+/WAoZ7VEPLlsCwUDS4gTBK07a2Ul4mHsTAMALt1l5RAPDH+tcYRcfnEs8Or 30 | T0ne7iwy+AWJP3BLzXsNFSYhaQk1WLqMW2CKknKJQidTxa9vsKAsHs1N7qZoRlRb 31 | Ol9BzQfK+2BShzRvPR7MrXGCkwI8OnAdnNA0no5mwmLvqYJxuSOD8RUfnDbEEyNI 32 | xWRa8UcXbJzZGse95K+RlViCZnVqOA7C0trtMI2iFQKBgQD7z3g5V6hUpg5I9yUn 33 | LBRksVm78vCtKnpBvahfHDmWsdsZtbl99aOP19lDNqN188A1lPMXacIsCTH1P1fZ 34 | 9DKwpADNJdb9a+3uO/heN/IHzS5HbmT8BezJ9gVbcyIQOhTeSHOVBGWhr1fuKlX3 35 | uY2hIqu8nhVCFpkPGMXfjUPNDwKBgQDAvbKrPozNnWRgdnOQ1MO83MAj7NDK6arM 36 | sliD8oYP2bR2phpktYRcpGJLDBt60T67hoi1uMVi57PBfVmkFxNmQa3X90C6N8Sb 37 | JGvtlO0jL5YS4zKXt3SVtWmpWJaFY49EVAIJkEW6Tba9l1aUI0xnJOqR09hpWBdo 38 | 2ZxNv4OkAwKBgBKenrcuwiWwObIvhDHHUqK3cnkxV5aWY3/k9EREJAX+lMLr6xFj 39 | TnrebRRJQAECyx2rK+Z5TYBQFb+atXZKVk01c1G4EJOgnXFWQeFFpcHp7BggE5uH 40 | HKE6bd77PnOQt+ZP2SwVrSYtfwT/YGf28oANvkEuN0gxv5OcM1V6bp8FAoGAXnUZ 41 | ON9Atgvqg3PJkzMScl5lM7X9ZVQ6xnKo+pFCrkXtMTCaQrzKj3RoT4sxdHFToyd9 42 | nEFfSuduqua5pbdBZJ3PisW7zQdDrRyeYCLaTKInlD4QL1ZTJoNvsvSdX+W48q2R 43 | CgrQgUtQwX2HirX53Zqz8p+1sXrgjhwHg/rVku0CgYEAvdiih8uKzLYtHjAo761h 44 | crkEDlIHDGW5cW6QAGoFeSI9DSLIhlVFvAB35GkvbDSaE5JDcCr7skSylTimn1tX 45 | Ko68PX9G6c+z5CbxbMs0PqSDiK54sI8tPGDfh+7JlgjGQm70F0Vi7vU8NAcIAByp 46 | Mu0Wzgi5TsZsDxDMUGOqT3k= 47 | -----END PRIVATE KEY----- 48 | -------------------------------------------------------------------------------- /tests/data/broker-certificate.pem.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDCTCCAfGgAwIBAgIUIOZ0KXxEsxP0LuNmzXbc5HFXmtowDQYJKoZIhvcNAQEL 3 | BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTE4MTIzMTE2MDcyM1oXDTI4MTIy 4 | ODE2MDcyM1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF 5 | AAOCAQ8AMIIBCgKCAQEAthKDqx582kbucx/HgBceHxPloyHa2CWpcedFmE/YxLcA 6 | 7cli48Uyk72awtd/jeclD0ef0AHd6QwWwcvt0/la/zXwMCEj5OU15aIN6v65tdPE 7 | 4efAfnBsjG7qjspN8DuJUURkm9OeOtLQ4uDTSl5X5beFPOS0zHYV4rkbuaqlZO9U 8 | hX05jKkw1gHY78u+uCNYoSlA59nI/06GlDj9MPbYBnaU84mME3PnISeUa2sWiEHr 9 | X4dHsvjJ2EusLE9ENrhWpRZAMeG+/WagsqqMzZoPwnbZTFvOzxAXz9KddmNJXiA0 10 | 204j0lNJ4yX7/R0hgDzVOJSPQ0Hg+RJbjyHdUOpVWwIDAQABo1MwUTAdBgNVHQ4E 11 | FgQUJVWACsLu7sS+FY1Zcv205G+p+lMwHwYDVR0jBBgwFoAUJVWACsLu7sS+FY1Z 12 | cv205G+p+lMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAc1bg 13 | NNQIMjS+W8rsTO8I9sZHubxXQsG70GJSYIhUD4UrMdtelAGCdxmY9apGWc31ZLtC 14 | +8OtRWXaH27zR79Y6hnU9Sd5e24U8SNxFv0nlRPJNHLcekgMRVm8CQ0ninoL+kF1 15 | pdXZNeUzZAsrqAOBNyyLUuYwhs/f0GEs7zOzPiLppC2Bj2atBhH3tLrXK0rT3+57 16 | f1bYmn+Z37fzQ3FgNwRtt7VW7tPSiAm2J2X3gcNW5L9qgNNqpMXg+wf+JbCaj0Za 17 | isukJM4j6L1lF0rdqzX1uYYJheh7IehfimSDOWdMb8dNnlUO1kXEFgRjs+zu7raK 18 | ehEKaVjUJmyqWT5zgw== 19 | -----END CERTIFICATE----- 20 | -----BEGIN PRIVATE KEY----- 21 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC2EoOrHnzaRu5z 22 | H8eAFx4fE+WjIdrYJalx50WYT9jEtwDtyWLjxTKTvZrC13+N5yUPR5/QAd3pDBbB 23 | y+3T+Vr/NfAwISPk5TXlog3q/rm108Th58B+cGyMbuqOyk3wO4lRRGSb05460tDi 24 | 4NNKXlflt4U85LTMdhXiuRu5qqVk71SFfTmMqTDWAdjvy764I1ihKUDn2cj/ToaU 25 | OP0w9tgGdpTziYwTc+chJ5RraxaIQetfh0ey+MnYS6wsT0Q2uFalFkAx4b79ZqCy 26 | qozNmg/CdtlMW87PEBfP0p12Y0leIDTbTiPSU0njJfv9HSGAPNU4lI9DQeD5EluP 27 | Id1Q6lVbAgMBAAECggEAVs8nBhyILM5q/GrnhNQA4ZJsB8apgCscyhkZnpBbaxdS 28 | Ew3U34JzJevd8Q4sW+0cR1fAA74QHwBLjWT85PdcApimB1yVr5n1g6IrfqKqyt3I 29 | XmlP5zkVGDP+E8yzMlpAk4XHbmCMbcF8JOBK/YdT40wH88ubxGx87NeTN4f8Sc3f 30 | TtyofRaWqq29nhO/IBkRE8sU4lsc+vKeTH0UT+5q6k9nlvyiCc3nnlWxVrGWmHty 31 | qgfqlddTXFXxkyRqRCGB58And9oRJpN6CRl1CM10YhlWrfWh23CIFYdXKFRINXc8 32 | 188pSxzCp7mVvmNohZ9CLYgTSOwgcCU4OKdkbNoYCQKBgQDxvP2qmR/aWgCBNMgF 33 | OJ7p3ScVgEQb+e+EInaIu+rgan/JDVZ6ic7e2tHBSh5vBD+HpHDEtZgYQP4SBYcp 34 | kUzTIi+dE9/vqa8uustp5EQF+QKhqRZEbcNTSBzCcddL9UyRwZL9+Fny6b08BSFX 35 | A2/jwzeegvgetwNzPRa9cUTZ3wKBgQDA0GFIftRgqFuPKhbgr3wInGc67z/BXxGt 36 | AlWoBcQu68Rxxn3YB2eGjm/6kSXgIqukpYL2dfwE484MpR1QXJ7oIg2y1tDIwmNG 37 | kxR2TcBFxzYgQuW4HqxGYImEzLFo/bPOPDK0UEcCXx7jWGlSMUwx+F0W43kHOxr4 38 | 8++jFlhsBQKBgAsRxWffdSbxgh5ohVQ/4H8DAnwokHXbfw1E6rqeKEY4ejuBodkg 39 | oFlyGbLJKxWYUzRp4kQPsLRDnZ7DpAnSPntYkGeX9mQqF/yCzze9HSRM38L5VCyz 40 | /gK9RZvdzKcMx4HRJGL+0VefPmwWLA2o+aGrWcunYac+aCkJvhXUrvtvAoGBAJul 41 | m2KSP47nWUHwkBdAkbfByfNhPu4yeGpZABxqyzu1RxcxTFUfZQrR4MM8eH6+fiCj 42 | G10pURABdUvv3gIJQp3RH43GqzPk9475HAOEMDoj3iWc2yQOXrNRKrHxKyW953AM 43 | WEIGq8vWTXDo5dxbv053V24qooCbzeI5yLC2URqhAoGBANIuu1ZitzomXZixrrta 44 | t5QzL/sbBe4sSXGGKB4ZKjl1IbbqKDSw9gHz8CO7KhrN0YphmrGsEC3ioV8Mm0E/ 45 | WHb9e7YQs4P4xGx0gMA71U//v1t0sFqNVYzH5I2yoyPHFmwE4LO5oAk69J0o2qGB 46 | 2WcmG8m6bI7Mt2C4mZyjQL6F 47 | -----END PRIVATE KEY----- 48 | -------------------------------------------------------------------------------- /tests/transport/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-2019 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | import ssl 14 | import threading 15 | from socket import SHUT_RDWR 16 | from os.path import join 17 | from socketserver import TCPServer, BaseRequestHandler 18 | 19 | from tests.utils import TEST_DATA_DIR 20 | 21 | from logging import getLogger 22 | log = getLogger(__name__) 23 | 24 | 25 | class CertFiles: 26 | CERT_AND_KEY = join(TEST_DATA_DIR, 'cert-and-key.pem') 27 | LOCALHOST_CERT_AND_KEY = join(TEST_DATA_DIR, 'broker-certificate.pem.crt') 28 | BAD_HOSTNAME = join(TEST_DATA_DIR, 'bad-hostname.pem') 29 | CERT_ONLY = join(TEST_DATA_DIR, 'cert-only.pem') 30 | 31 | 32 | def response_for(request): 33 | return "%s, or something!\n" % request.strip() 34 | 35 | 36 | class FakeRequestHandler(BaseRequestHandler): 37 | def handle(self): 38 | try: 39 | data = self.request.recv(1024).decode('utf-8') 40 | except UnicodeDecodeError: 41 | data = "(invalid)" 42 | response = response_for(data) 43 | log.debug('"%s" -> "%s"', data.strip(), response.replace('\n', '\\n')) 44 | self.request.sendall(response.encode('utf-8')) 45 | 46 | 47 | class ServerResource(TCPServer, object): 48 | 49 | def __init__(self, tls=True): 50 | super(ServerResource, self).__init__(('', 0), FakeRequestHandler) 51 | if tls: 52 | self.socket = ssl.wrap_socket(self.socket, 53 | cert_reqs=ssl.CERT_REQUIRED, 54 | certfile=CertFiles.CERT_AND_KEY, 55 | ca_certs=CertFiles.CERT_AND_KEY, 56 | server_side=True) 57 | self.socket.settimeout(5) 58 | 59 | @property 60 | def port(self): 61 | return self.socket.getsockname()[1] 62 | 63 | def __enter__(self): 64 | self.socket.settimeout(3) 65 | log.info("Starting test TCP server") 66 | self.thread = threading.Thread(target=self.serve_forever) 67 | self.thread.start() 68 | return self 69 | 70 | def __exit__(self, exc_type, exc_val, exc_tb): 71 | self.socket.shutdown(SHUT_RDWR) 72 | self.socket.close() 73 | self.shutdown() 74 | log.info("Destroyed test server") 75 | self.thread.join(1) 76 | 77 | 78 | class TimeoutServer(ServerResource): 79 | 80 | def __init__(self): 81 | super(TimeoutServer, self).__init__() 82 | 83 | def __enter__(self): 84 | return self 85 | 86 | def __exit__(self, exc_type, exc_val, exc_tb): 87 | pass 88 | -------------------------------------------------------------------------------- /squeezealexa/alexa/response.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-18 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | from squeezealexa.settings import SkillSettings 14 | from squeezealexa.utils import print_d 15 | 16 | 17 | def speech_fragment(speech, title=None, reprompt_text=None, end=True, 18 | text=None, use_ssml=False): 19 | text_type = 'SSML' if use_ssml else 'PlainText' 20 | text_key = 'ssml' if use_ssml else 'text' 21 | output = { 22 | 'outputSpeech': { 23 | 'type': text_type, 24 | text_key: speech 25 | }, 26 | 'shouldEndSession': end 27 | } 28 | if title: 29 | output['card'] = { 30 | 'type': 'Simple', 31 | 'title': title, 32 | 'content': text or speech 33 | } 34 | if reprompt_text: 35 | output['reprompt'] = { 36 | 'outputSpeech': { 37 | 'type': text_type, 38 | text_key: reprompt_text 39 | } 40 | } 41 | return output 42 | 43 | 44 | def audio_response(speech=None, text=None, title=None): 45 | output = { 46 | 'directives': [ 47 | { 48 | 'type': 'AudioPlayer.Play', 49 | 'playBehavior': 'REPLACE_ALL', 50 | 'audioItem': { 51 | 'stream': { 52 | 'token': 'beep', 53 | 'url': SkillSettings.RESPONSE_AUDIO_FILE_URL, 54 | 'offsetInMilliseconds': 0 55 | } 56 | } 57 | } 58 | ], 59 | 'shouldEndSession': True 60 | } 61 | if speech: 62 | output['outputSpeech'] = {'type': 'PlainText', 63 | 'text': speech} 64 | if text: 65 | card = {'type': 'Simple', 'content': text} 66 | if title: 67 | card['title'] = title 68 | output['card'] = card 69 | 70 | return _build_response(output) 71 | 72 | 73 | def speech_response(title=None, speech=None, reprompt_text=None, end=True, 74 | store=None, text=None, use_ssml=False): 75 | speechlet_response = speech_fragment(speech=speech or title, title=title, 76 | reprompt_text=reprompt_text, 77 | text=text, end=end, 78 | use_ssml=use_ssml) 79 | print_d("Returning {response}", response=speechlet_response) 80 | return _build_response(speechlet_response, store=store) 81 | 82 | 83 | def _build_response(speechlet_response, store=None): 84 | return { 85 | 'version': '1.0', 86 | 'sessionAttributes': store or {}, 87 | 'response': speechlet_response 88 | } 89 | -------------------------------------------------------------------------------- /squeezealexa/alexa/handlers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | from squeezealexa.alexa.requests import Request 14 | from squeezealexa.utils import print_w 15 | 16 | 17 | class AlexaHandler(object): 18 | 19 | def __init__(self, app_id=None): 20 | self.app_id = app_id 21 | 22 | def on_session_ended(self, session_ended_request, session): 23 | """ Called when the user ends the session. 24 | Is not called when the skill returns should_end_session=true 25 | """ 26 | 27 | def on_session_started(self, request, session): 28 | """Called when the session starts """ 29 | 30 | def on_launch(self, launch_request, session): 31 | """Called when the user launches the skill 32 | without specifying what they want""" 33 | 34 | def on_intent(self, intent_request, session): 35 | """Called when the user specifies an intent for this skill""" 36 | 37 | def handle(self, event, context=None): 38 | """The main entry point for Alexa requests""" 39 | request = event['request'] 40 | req_type = request['type'] 41 | session = self._verified_app_session(event) 42 | 43 | if session and session['new']: 44 | self.on_session_started(request, session) 45 | 46 | if req_type == Request.LAUNCH: 47 | return self.on_launch(request, session) 48 | elif req_type == Request.INTENT: 49 | return self.on_intent(request, session) 50 | elif req_type == Request.SESSION_ENDED: 51 | return self.on_session_ended(request, session) 52 | elif req_type == Request.EXCEPTION: 53 | print_w("ERROR callback received (\"%s\"). Full event: %s" 54 | % (request['error'].get('message', "?"), event)) 55 | else: 56 | raise ValueError("Unknown request type %s" % req_type) 57 | 58 | def _verified_app_session(self, event): 59 | if 'session' not in event: 60 | # Probably an exception message 61 | return None 62 | session = event['session'] 63 | app = session['application'] 64 | if self.app_id and app['applicationId'] != self.app_id: 65 | raise ValueError("Invalid application (%s)" % app) 66 | return session 67 | 68 | 69 | class IntentHandler(object): 70 | 71 | def __init__(self): 72 | self._handlers = {} 73 | 74 | def for_name(self, name): 75 | """Returns the handler for the given intent, or `None`""" 76 | return self._handlers.get(name, None) 77 | 78 | def handle(cls, name): 79 | """Registers a handler function for the given intent""" 80 | 81 | def _handler(func): 82 | cls._handlers[name] = func 83 | return func 84 | 85 | return _handler 86 | -------------------------------------------------------------------------------- /metadata/intents/v0/locale/de_DE/utterances.txt: -------------------------------------------------------------------------------- 1 | IncreaseVolumeIntent lauter 2 | IncreaseVolumeIntent {Player} lauter 3 | IncreaseVolumeIntent lauter in {Player} 4 | IncreaseVolumeIntent lauter im {Player} 5 | IncreaseVolumeIntent lautstärke rauf in {Player} 6 | IncreaseVolumeIntent lautstärke rauf im {Player} 7 | IncreaseVolumeIntent lautstärke erhöhen in {Player} 8 | DecreaseVolumeIntent leiser 9 | DecreaseVolumeIntent leiser in {Player} 10 | DecreaseVolumeIntent leiser im {Player} 11 | DecreaseVolumeIntent {Player} leiser 12 | DecreaseVolumeIntent lautstärke runter in {Player} 13 | DecreaseVolumeIntent lautstärke runter im {Player} 14 | SetVolumeIntent lautstärke auf {Volume} 15 | SetVolumeIntent lautstärke in {Player} auf {Volume} 16 | SetVolumeIntent lautstärke auf {Volume} in {Player} 17 | SetVolumeIntent lautstärke {Volume} in {Player} 18 | NowPlayingIntent was ist das für ein Lied 19 | NowPlayingIntent was spielt gerade 20 | NowPlayingIntent was spielt gerade in {Player} 21 | NowPlayingIntent was hören wir in {Player} 22 | SelectPlayerIntent {Player} auswählen 23 | SelectPlayerIntent wähle {Player} aus 24 | SelectPlayerIntent gehe auf {Player} 25 | TurnOnPlayerIntent schalte {Player} an 26 | TurnOnPlayerIntent schalte {Player} ein 27 | TurnOnPlayerIntent {Player} anschalten 28 | TurnOnPlayerIntent {Player} einschalten 29 | TurnOffPlayerIntent schalte {Player} aus 30 | TurnOffPlayerIntent {Player} ausschaltem 31 | PlayRandomMixIntent spiele {Genre} musik 32 | PlayRandomMixIntent spiele {Genre} und {SecondaryGenre} musik 33 | PlayRandomMixIntent spiele {Genre} musik und {SecondaryGenre} musik 34 | PlayRandomMixIntent spiele {Genre} und {SecondaryGenre} und {TertiaryGenre} musik 35 | PlayRandomMixIntent spiele eine mischung aus {Genre} und {SecondaryGenre} 36 | PlayRandomMixIntent spiele eine mischung aus {Genre} {SecondaryGenre} und {TertiaryGenre} 37 | PlayPlaylistIntent spiele meine {Playlist} wiedergabeliste 38 | PlayPlaylistIntent spiele wiedergabeliste {Playlist} 39 | PlayPlaylistIntent spiele wiedergabeliste {Playlist} in {Player} 40 | PlayPlaylistIntent spiele meine {Playlist} wiedergabelist in {Player} 41 | ShuffleOnIntent zufall an 42 | ShuffleOnIntent zufall ein 43 | ShuffleOnIntent zufallswiedergabe an 44 | ShuffleOnIntent zufallswiedergabe ein 45 | ShuffleOnIntent zufallswiedergabe anschalten 46 | ShuffleOnIntent zufallswiedergabe einschalten 47 | ShuffleOnIntent zufällige wiedergabe an 48 | ShuffleOnIntent zufällige wiedergabe ein 49 | ShuffleOnIntent zufällige wiedergabe anschalten 50 | ShuffleOnIntent zufällige wiedergabe einschalten 51 | ShuffleOffIntent zufall aus 52 | ShuffleOffIntent zufall ausschalten 53 | ShuffleOffIntent zufallswiedergabe aus 54 | ShuffleOffIntent zufallswiedergabe ausschalten 55 | ShuffleOffIntent zufällige wiedergabe aus 56 | ShuffleOffIntent zufällige wiedergabe ausschalten 57 | LoopOnIntent wiederholen an 58 | LoopOnIntent wiederholen ein 59 | LoopOnIntent wiederholen anschalten 60 | LoopOnIntent wiederholen einschalten 61 | LoopOnIntent alle wiederholen 62 | LoopOffIntent wiederholen aus 63 | LoopOffIntent wiederholen ausschalten 64 | LoopOffIntent alles nur einmal spielen 65 | LoopOffIntent spiele alles nur einmal 66 | AllOffIntent ausschalten 67 | AllOffIntent alles aus 68 | AllOffIntent alles ausschalten 69 | AllOnIntent anschalten 70 | AllOnIntent alles an 71 | AllOnIntent alles anschalten 72 | -------------------------------------------------------------------------------- /tests/intents_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-18 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | from logging import getLogger 13 | from unittest import TestCase 14 | 15 | import os 16 | 17 | import json 18 | 19 | from squeezealexa.alexa.intents import Audio, Power 20 | from squeezealexa.main import handler, SqueezeAlexa 21 | from squeezealexa.squeezebox.server import Server 22 | from tests.transport.fake_transport import FakeTransport 23 | from tests.utils import ROOT 24 | 25 | log = getLogger(__name__) 26 | 27 | INTENTS_V0_PATH = os.path.join(ROOT, 28 | 'metadata/intents/v0/intents.json') 29 | 30 | INTENTS_V1_EN_GB_PATH = os.path.join(ROOT, 31 | 'metadata/intents/v1/locale' 32 | '/en_GB/intents.json') 33 | 34 | INTENTS_V1_EN_US_PATH = os.path.join(ROOT, 35 | 'metadata/intents/v1/locale' 36 | '/en_US/intents.json') 37 | 38 | 39 | def intents_from(j): 40 | lang_model = j["interactionModel"]["languageModel"] 41 | return {i["name"]: i for i in lang_model["intents"]} 42 | 43 | 44 | def enum_values_from(cls): 45 | for k, v in cls.__dict__.items(): 46 | if not k.startswith("_"): 47 | yield v 48 | 49 | 50 | class AllIntentHandlingTest(TestCase): 51 | """Makes sure all registered handlers are behaving at least vaguely well""" 52 | 53 | def test_all_handler(self): 54 | fake_output = FakeTransport().start() 55 | server = Server(transport=fake_output) 56 | alexa = SqueezeAlexa(server=server) 57 | for name, func in handler._handlers.items(): 58 | log.info(">>> Testing %s <<<", func.__name__) 59 | session = {'sessionId': None} 60 | intent = {'requestId': 'abcd', 'slots': {}} 61 | raw = func(alexa, intent, session, None) 62 | response = raw['response'] 63 | assert 'directives' in response or 'outputSpeech' in response 64 | assert 'shouldEndSession' in response 65 | 66 | def test_intents_v0_json(self): 67 | with open(INTENTS_V0_PATH) as f: 68 | j = json.load(f) 69 | assert j["intents"] 70 | 71 | def test_intents_v1_en_gb_json(self): 72 | with open(INTENTS_V1_EN_GB_PATH) as f: 73 | j = json.load(f) 74 | intents = intents_from(j) 75 | assert intents, "No intents found" 76 | for i in enum_values_from(Power): 77 | assert intents[i] 78 | for i in enum_values_from(Audio): 79 | assert intents[i] 80 | 81 | def test_intents_v1_en_us_json(self): 82 | with open(INTENTS_V1_EN_US_PATH) as f: 83 | j = json.load(f) 84 | intents = intents_from(j) 85 | for i in enum_values_from(Power): 86 | assert intents[i] 87 | for i in enum_values_from(Audio): 88 | assert intents[i] 89 | -------------------------------------------------------------------------------- /tests/transport/fake_transport.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-18 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | from logging import getLogger 14 | 15 | from squeezealexa.transport.base import Transport 16 | 17 | log = getLogger(__name__) 18 | 19 | REAL_FAVES = """title%3AFavorites id%3A0 20 | name%3AChilled%20Jazz type%3Aaudio 21 | url%3Afile%3A%2F%2F%2Fvolume1%2Fplaylists%2FChilled%2520Jazz.m3u isaudio%3A1 22 | hasitems%3A0 id%3A1 23 | name%3APiano-friendly type%3Aaudio 24 | url%3Afile%3A%2F%2F%2Fvolume1%2Fplaylists%2FPiano-friendly.m3u isaudio%3A1 25 | hasitems%3A0 26 | count%3A18""".replace('\n', ' ') 27 | 28 | A_REAL_STATUS = """ 29 | player_name%3AStudy player_connected%3A1 player_ip%3A' 30 | 192.168.1.35%3A51196 power%3A1 signalstrength%3A0 mode%3Aplay 31 | time%3A23.9624403781891 rate%3A1 duration%3A358.852 can_seek%3A1 32 | sync_master%3A00%3A04%3A20%3A17%3A5c%3A94 33 | mixer%20volume%3A98 playlist%20repeat%3A0 34 | playlist%20shuffle%3A1 playlist%20mode%3Aoff 35 | seq_no%3A0 playlist_cur_index%3A20 36 | playlist_timestamp%3A1493318044.34369 playlist_tracks%3A62 37 | digital_volume_control%3A1 playlist%20index%3A20 id%3A134146 38 | title%3AConcerto%20No.%2023%20in%20A%20Major%2C%20K.%20488%3A%20Adagio 39 | genre%3AJazz artist%3AJacques%20Loussier%20Trio 40 | album%3AMozart%20Piano%20Concertos%2020%2F23 41 | duration%3A358.852 playlist%20index%3A21 id%3A134174 42 | title%3AI%20Think%2C%20I%20Love genre%3AJazz 43 | artist%3AJamie%20Cullum album%3AThe%20Pursuit duration%3A255.906 44 | """.lstrip().replace('\n', '') 45 | 46 | FAKE_LENGTH = 358.852 47 | 48 | 49 | class FakeTransport(Transport): 50 | 51 | def __init__(self, fake_name='fake', fake_id='12:34', 52 | fake_status=A_REAL_STATUS, fake_server_status=None): 53 | self.hostname = 'localhost' 54 | self.port = 0 55 | self.failures = 0 56 | self.is_connected = False 57 | self.player_name = fake_name 58 | self.player_id = fake_id 59 | self.all_input = "" 60 | self._status = fake_status 61 | self._server_status = fake_server_status 62 | 63 | def communicate(self, data, wait=True): 64 | self.all_input += data 65 | stripped = data.rstrip('\n') 66 | if data.startswith('serverstatus'): 67 | if self._server_status: 68 | return self._server_status 69 | else: 70 | return ('{orig} player%20count:1 playerid:{pid} connected:1 ' 71 | 'name:{name}\n' 72 | .format(orig=stripped, name=self.player_name, 73 | pid=self.player_id)) 74 | 75 | elif ' status ' in stripped: 76 | log.debug("Faking player status.") 77 | return stripped + self._status 78 | elif 'login ' in stripped: 79 | return 'login %s ******' % stripped.split()[1].replace(' ', '%20') 80 | elif ' time ' in data: 81 | return '%s %.3f' % (stripped.rstrip('?'), FAKE_LENGTH) 82 | elif 'favorites items ' in data: 83 | return stripped + REAL_FAVES 84 | return stripped + ' OK\n' 85 | 86 | @property 87 | def details(self): 88 | return "{hostname}:{port}".format(**self.__dict__) 89 | -------------------------------------------------------------------------------- /mqtt_squeeze.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2018-19 Nick Boultbee 5 | # This file is part of squeeze-alexa. 6 | # 7 | # squeeze-alexa is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # See LICENSE for full license 13 | 14 | 15 | import socket 16 | import sys 17 | import telnetlib 18 | from logging import getLogger, basicConfig, DEBUG, INFO 19 | from os.path import dirname, abspath 20 | import paho 21 | import paho.mqtt.client as mqtt 22 | 23 | # Sort out running directly 24 | path = dirname(dirname(abspath(__file__))) 25 | sys.path.append(path) 26 | 27 | basicConfig(level=INFO, format="[{levelname:7s}] {message}", style="{") 28 | logger = getLogger(__name__) 29 | logger.setLevel(DEBUG) 30 | 31 | from squeezealexa.settings import MQTT_SETTINGS, LMS_SETTINGS 32 | from squeezealexa.transport.mqtt import CustomClient 33 | 34 | telnet = None 35 | 36 | 37 | def on_connect(client, data, flags, rc): 38 | logger.info("Connection status: %s", mqtt.error_string(rc)) 39 | client.subscribe(MQTT_SETTINGS.topic_req, qos=1) 40 | 41 | 42 | def on_subscribe(client, data, mid, granted_qos): 43 | logger.info("Subscribed to %s @ QOS %s. Ready to go!", 44 | MQTT_SETTINGS.topic_req, granted_qos[0]) 45 | 46 | 47 | def on_message(client, userdata, message): 48 | num_lines = message.payload.count(b'\n') 49 | msg = message.payload.decode('utf-8') 50 | if MQTT_SETTINGS.debug: 51 | logger.debug(">>> %s (@QoS %s)", msg.strip(), message.qos) 52 | telnet.write(message.payload.strip() + b'\n') 53 | resp_lines = [] 54 | while len(resp_lines) < num_lines: 55 | resp_lines.append(telnet.read_until(b'\n').strip()) 56 | 57 | rsp = b'\n'.join(resp_lines) 58 | if rsp: 59 | if MQTT_SETTINGS.debug: 60 | logger.debug("<<< %s", rsp.decode('utf-8')) 61 | client.publish(MQTT_SETTINGS.topic_resp, rsp, qos=1) 62 | else: 63 | logger.warning("No reply") 64 | 65 | 66 | def connect_cli(): 67 | global telnet 68 | telnet = telnetlib.Telnet(host=MQTT_SETTINGS.internal_server_hostname, 69 | port=LMS_SETTINGS.cli_port, timeout=5) 70 | logger.info("Connected to the LMS CLI.") 71 | return telnet 72 | 73 | 74 | if __name__ == "__main__": 75 | logger.debug("paho-mqtt %s", paho.mqtt.__version__) 76 | logger.debug("Checking MQTT configuration") 77 | if not MQTT_SETTINGS.configured: 78 | logger.error("MQTT transport not configured. Check your settings") 79 | exit(1) 80 | try: 81 | telnet = connect_cli() 82 | except socket.timeout as e: 83 | logger.error("Couldn't connect to LMS CLI using %s (%s)", 84 | MQTT_SETTINGS, e) 85 | exit(3) 86 | else: 87 | client = CustomClient(MQTT_SETTINGS) 88 | client.enable_logger() 89 | client.on_connect = on_connect 90 | client.on_subscribe = on_subscribe 91 | client.on_message = on_message 92 | logger.debug("Connecting to MQTT endpoint") 93 | client.connect() 94 | logger.debug("Starting MQTT client loop") 95 | # Continue the network loop 96 | client.loop_forever(retry_first_connection=False) 97 | finally: 98 | if telnet: 99 | telnet.close() 100 | logger.info("Exiting") 101 | -------------------------------------------------------------------------------- /metadata/intents/v0/intents.json: -------------------------------------------------------------------------------- 1 | { 2 | "intents": [ 3 | { 4 | "intent": "AMAZON.ResumeIntent" 5 | }, 6 | { 7 | "intent": "AMAZON.PauseIntent" 8 | }, 9 | { 10 | "intent": "AMAZON.NextIntent" 11 | }, 12 | { 13 | "intent": "AMAZON.PreviousIntent" 14 | }, 15 | { 16 | "intent": "AMAZON.LoopOnIntent" 17 | }, 18 | { 19 | "intent": "AMAZON.LoopOffIntent" 20 | }, 21 | { 22 | "intent": "AMAZON.ShuffleOnIntent" 23 | }, 24 | { 25 | "intent": "AMAZON.ShuffleOffIntent" 26 | }, 27 | { 28 | "intent": "IncreaseVolumeIntent", 29 | "slots": [ 30 | { 31 | "name": "Player", 32 | "type": "PLAYER" 33 | } 34 | ] 35 | }, 36 | { 37 | "intent": "DecreaseVolumeIntent", 38 | "slots": [ 39 | { 40 | "name": "Player", 41 | "type": "PLAYER" 42 | } 43 | ] 44 | }, 45 | { 46 | "intent": "SetVolumeIntent", 47 | "slots": [ 48 | { 49 | "name": "Player", 50 | "type": "PLAYER" 51 | }, 52 | { 53 | "name": "Volume", 54 | "type": "AMAZON.NUMBER" 55 | } 56 | ] 57 | }, 58 | { 59 | "intent": "SetVolumePercentIntent", 60 | "slots": [ 61 | { 62 | "name": "Player", 63 | "type": "PLAYER" 64 | }, 65 | { 66 | "name": "Volume", 67 | "type": "AMAZON.NUMBER" 68 | } 69 | ] 70 | }, 71 | { 72 | "intent": "NowPlayingIntent", 73 | "slots": [ 74 | { 75 | "name": "Player", 76 | "type": "PLAYER" 77 | } 78 | ] 79 | }, 80 | { 81 | "intent": "SelectPlayerIntent", 82 | "slots": [ 83 | { 84 | "name": "Player", 85 | "type": "PLAYER" 86 | } 87 | ] 88 | }, 89 | { 90 | "intent": "TurnOnPlayerIntent", 91 | "slots": [ 92 | { 93 | "name": "Player", 94 | "type": "PLAYER" 95 | } 96 | ] 97 | }, 98 | { 99 | "intent": "TurnOffPlayerIntent", 100 | "slots": [ 101 | { 102 | "name": "Player", 103 | "type": "PLAYER" 104 | } 105 | ] 106 | }, 107 | { 108 | "intent": "PlayRandomMixIntent", 109 | "slots": [ 110 | { 111 | "name": "Player", 112 | "type": "PLAYER" 113 | }, 114 | { 115 | "name": "Genre", 116 | "type": "GENRE" 117 | }, 118 | { 119 | "name": "SecondaryGenre", 120 | "type": "GENRE" 121 | }, 122 | { 123 | "name": "TertiaryGenre", 124 | "type": "GENRE" 125 | } 126 | ] 127 | }, 128 | { 129 | "intent": "PlayPlaylistIntent", 130 | "slots": [ 131 | { 132 | "name": "Player", 133 | "type": "PLAYER" 134 | }, 135 | { 136 | "name": "Playlist", 137 | "type": "PLAYLIST" 138 | } 139 | ] 140 | }, 141 | { 142 | "intent": "ShuffleOnIntent" 143 | }, 144 | { 145 | "intent": "ShuffleOffIntent" 146 | }, 147 | { 148 | "intent": "LoopOnIntent" 149 | }, 150 | { 151 | "intent": "LoopOffIntent" 152 | }, 153 | { 154 | "intent": "AllOffIntent" 155 | }, 156 | { 157 | "intent": "AllOnIntent" 158 | }, 159 | { 160 | "intent": "AMAZON.HelpIntent" 161 | } 162 | ] 163 | } 164 | -------------------------------------------------------------------------------- /squeezealexa/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-18 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | import random 14 | import re 15 | import sys 16 | import unicodedata 17 | from time import sleep, perf_counter 18 | from typing import Dict, Iterable, Union 19 | 20 | from squeezealexa.i18n import _ 21 | 22 | 23 | def print_d(template, *args, **kwargs): 24 | if args and not kwargs: 25 | raise ValueError("Use only named parameters please") 26 | text = template.format(*args, **kwargs) 27 | print(text) 28 | return text 29 | 30 | 31 | print_w = print_d 32 | 33 | 34 | def human_join(items: Iterable, final: str =_("and")) -> str: 35 | """Like join, but in English (no Oxford commas...) 36 | Kinda works in some other languages (French, German)""" 37 | items = list(filter(None, items or [])) 38 | most = ", ".join(items[0:-1]) 39 | sep = " %s " % final.strip() 40 | return sep.join(filter(None, [most] + items[-1:])) 41 | 42 | 43 | _SPACIFIES = {i: u' ' for i in range(sys.maxunicode) 44 | if unicodedata.category(chr(i)).startswith('P')} 45 | 46 | _REMOVALS = {ord(i): None for i in ['\'', '!']} 47 | 48 | _SANITISE = {'&': ' N ', 49 | '+': ' N ', 50 | '$': 's'} 51 | 52 | 53 | def remove_punctuation(text): 54 | return text.translate(_REMOVALS).translate(_SPACIFIES) 55 | 56 | 57 | def sanitise_text(text): 58 | """Makes a genre / playlist / artist name safer for Alexa output""" 59 | if not text: 60 | return "" 61 | safer = text 62 | for (bad, good) in _SANITISE.items(): 63 | safer = safer.replace(bad, good) 64 | no_punc = remove_punctuation(safer) 65 | return re.sub(r'\s{2,}', ' ', no_punc) 66 | 67 | 68 | def with_example(template: str, collection) -> str: 69 | """Takes a template string with `{num}` in it and gives a length 70 | and an example, if possible.""" 71 | if "{num}" not in template: 72 | raise ValueError("Need {num} in the template") 73 | total = len(collection) 74 | msg = template.format(num=total) 75 | if collection: 76 | extra = ' ({eg}"{item}")'.format(eg='e.g. ' if total > 1 else '', 77 | item=random.choice(list(collection))) 78 | msg += extra 79 | return msg 80 | 81 | 82 | def stronger(k: str, v: str, extra_bools=None): 83 | """Return a stronger-typed version of a value if possible""" 84 | prefixes = set(extra_bools or []) 85 | prefixes.update({'has', 'is', 'can'}) 86 | try: 87 | for prefix in prefixes: 88 | if k.startswith(prefix): 89 | return bool(int(v)) 90 | try: 91 | return int(v) 92 | except ValueError: 93 | return float(v) 94 | except ValueError: 95 | return None if not v else v 96 | 97 | 98 | def wait_for(func, timeout=3, what=None, context=None, exc_cls=Exception): 99 | nt = t = perf_counter() 100 | while not func(context): 101 | sleep(0.05) 102 | nt = perf_counter() 103 | if nt - t > timeout: 104 | msg = _("Failed \"{task}\", " 105 | "after {secs:.1f} seconds").format(task=what, 106 | context=str(context), 107 | secs=nt - t) 108 | raise exc_cls(msg) 109 | print_d("Task \"{task}\" took < {duration:.2f} seconds", task=what, 110 | duration=nt - t) 111 | 112 | 113 | def first_of(details: Dict, tags: Iterable[str], default=None)\ 114 | -> Union[str, None]: 115 | """Gets the first non-null value from the list of tags""" 116 | for tag in tags: 117 | if tag in details: 118 | return details[tag] 119 | return default 120 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | squeeze-alexa 2 | ============= 3 | 4 | [![Build Status](https://circleci.com/gh/declension/squeeze-alexa.svg?style=svg)](https://circleci.com/gh/declension/squeeze-alexa) 5 | [![Coverage Status](https://coveralls.io/repos/github/declension/squeeze-alexa/badge.svg?branch=master)](https://coveralls.io/github/declension/squeeze-alexa?branch=master) 6 | [![Join the chat at https://gitter.im/squeeze-alexa/Lobby](https://badges.gitter.im/squeeze-alexa/Lobby.svg)](https://gitter.im/squeeze-alexa/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 7 | :gb: :us: :de: :fr: 8 | 9 | `squeeze-alexa` is a codebase for creating your own Amazon Alexa Skill to communicate with Logitech Media Server ("squeezebox") using a NAS or home server. 10 | See the original [announcement blog post](http://declension.net/posts/2016-11-30-alexa-meets-squeezebox/), 11 | and the [follow-up with videos](http://declension.net/posts/2017-01-03-squeeze-alexa-demos/). 12 | 13 | 14 | Aims & Features 15 | --------------- 16 | 17 | * Intuitive voice control over common music scenarios 18 | * Low latency (given that it's a cloud service), i.e. fast at reacting to your commands. 19 | * Decent security, remaining under your own control 20 | * Extensive support for choosing songs by (multiple) genres, and playlists 21 | * Up-to-date with (some) changes and new features from Alexa and Amazon. 22 | * Helpful, conversational responses / interaction in several languages. 23 | * :new: Support a variety of networks including restrictive firewalls (or 3G / 4G broadband with CGNAT) 24 | 25 | ### Things it is not 26 | 27 | * Full coverage of all LMS features, plugins or use cases - but it aims to be good at what it does. 28 | * A public / multi-user skill. This means **you will need Alexa and AWS developer accounts**. 29 | * A native LMS (Squeezeserver) plugin. So whilst this would be cool, at least there's no need to touch your LMS. 30 | * Easy to set up :scream: (it's getting easier though with more setup automation) 31 | * Funded or officially supported by anyone - it's an open-source project, 32 | so please [help out](#contributing) if think you can. 33 | 34 | ### Supported Languages 35 | * British English :gb: 36 | * American English :us: 37 | * German :de: 38 | * :new: French :fr: 39 | 40 | 41 | Commands 42 | -------- 43 | 44 | ### In English 45 | These should all work (usually) in the current version. 46 | Most commands can take a player name, or will remember the default / last player if not specified. 47 | 48 | #### Playback 49 | * _Alexa, tell Squeezebox to play / pause_ (or just _Alexa, play / pause!_) 50 | * _Alexa, tell Squeezebox next / previous_ (or just _Alexa, next / previous!_) 51 | * _Alexa, tell Squeezebox to skip_ (or just _Alexa, skip!_) 52 | * _Alexa, tell Squeezebox to turn shuffle on / off_ (or just _Alexa, Shuffle On/Off_) 53 | 54 | #### Control 55 | * _Alexa, tell Squeezebox to select the Bedroom player_ 56 | * _Alexa, tell Squeezebox to turn it down in the Living Room_ 57 | * _Alexa, ask Squeezebox to pump it up!_ 58 | * _Alexa, tell Squeezebox to turn everything off_ 59 | 60 | 61 | #### Selecting Music 62 | * _Alexa, tell Squeezebox to play some Blues and some Jazz_ 63 | * _Alexa, tell Squeezebox to play a mix of Jungle, Dubstep and Hip-Hop_ 64 | * _Alexa, ask Squeezebox to play my Sunday Morning playlist_ 65 | * _Alexa, tell Squeezebox to play the Bad-Ass Metal playlist!_ 66 | 67 | #### Feedback 68 | * _Alexa, ask Squeezebox what's playing \[in the Kitchen\]_ 69 | 70 | ### Auf Deutsch 71 | * _Alexa, frage Squeezebox was ist das für ein Lied?_ 72 | * _Alexa, frage Squeezebox alles ausschalten_ 73 | * etc... 74 | 75 | ### En français 76 | 77 | * _Alexa, demande à Squeezebox de baisser le volume!_ 78 | * _Alexa, demande à Squeezebox qu'est ce qu'on écoute dans le salon_ 79 | 80 | 81 | I want! 82 | ------- 83 | See the [HOWTO](docs/HOWTO.md) for the full details of installing and configuring your own squeeze-alexa instance, or [TROUBLESHOOTING](docs/TROUBLESHOOTING.md) if you're getting stuck. 84 | 85 | 86 | Contributing 87 | ------------- 88 | `squeeze-alexa` is an open source project licensed under GPLv3 (or later). 89 | If you'd like to help test, translate, or develop it, see the [Github issues](https://github.com/declension/squeeze-alexa/issues) and read [CONTRIBUTING](docs/CONTRIBUTING.md). 90 | -------------------------------------------------------------------------------- /metadata/intents/v0/locale/fr_FR/utterances.txt: -------------------------------------------------------------------------------- 1 | 2 | IncreaseVolumeIntent augmente le volume 3 | IncreaseVolumeIntent augmente le volume de {Player} 4 | IncreaseVolumeIntent augmente le volume dans {Player} 5 | IncreaseVolumeIntent monte le volume dans {Player} 6 | IncreaseVolumeIntent monte le volume de {Player} 7 | IncreaseVolumeIntent monte le volume 8 | 9 | DecreaseVolumeIntent diminue le volume 10 | DecreaseVolumeIntent diminue le volume de {Player} 11 | DecreaseVolumeIntent diminue le volume dans {Player} 12 | DecreaseVolumeIntent baisse le volume dans {Player} 13 | DecreaseVolumeIntent baisse le volume de {Player} 14 | DecreaseVolumeIntent baisse le volume 15 | 16 | SetVolumeIntent régle le volume à {Volume} 17 | SetVolumeIntent régle le volume de {Player} à {Volume} 18 | SetVolumeIntent régle le volume dans {Player} à {Volume} 19 | SetVolumeIntent régle le volume à {Volume} dans {Player} 20 | 21 | SetVolumePercentIntent régle le volume à {Volume} pourcent 22 | SetVolumePercentIntent régle le volume de {Player} à {Volume} pourcent 23 | SetVolumePercentIntent régle le volume dans {Player} à {Volume} pourcent 24 | SetVolumePercentIntent régle le volume à {Volume} pourcent dans {Player} 25 | 26 | NowPlayingIntent quelle est cette chanson 27 | NowPlayingIntent quelle chanson est-ce 28 | NowPlayingIntent qu'est ce qui ce joue 29 | NowPlayingIntent qu'est ce qui est en train de se jouer 30 | NowPlayingIntent qu'est ce qui ce joue dans {Player} 31 | NowPlayingIntent qu'est ce qui ce joue dans le lecteur {Player} 32 | NowPlayingIntent qu'est ce qui est en train de ce jouer dans le lecteur {Player} 33 | NowPlayingIntent qu'est ce qu'on écoute dans {Player} 34 | NowPlayingIntent qu'est ce que nous écoutons dans {Player} 35 | 36 | 37 | SelectPlayerIntent séléctionne {Player} 38 | SelectPlayerIntent séléctionne le lecteur {Player} 39 | SelectPlayerIntent choisie {Player} 40 | SelectPlayerIntent choisie le lecteur {Player} 41 | SelectPlayerIntent select {Player} player 42 | SelectPlayerIntent select player {Player} 43 | 44 | ShuffleOnIntent aléatoire activé 45 | ShuffleOnIntent active l'aléatoire 46 | ShuffleOnIntent aléatoire 47 | ShuffleOnIntent active le mode aléatoire 48 | ShuffleOffIntent aléatoire désactivé 49 | ShuffleOffIntent désactive l'aléatoire 50 | ShuffleOffIntent désactive le mode aléatoire 51 | 52 | LoopOnIntent répétition activée 53 | LoopOnIntent active le mode répétition 54 | LoopOnIntent active la répétition 55 | 56 | LoopOffIntent répétition désactivé 57 | LoopOffIntent désactive le mode répétition 58 | LoopOffIntent désactive la répétition 59 | 60 | TurnOffPlayerIntent éteint le lecteur {Player} 61 | TurnOffPlayerIntent éteint lecteur {Player} 62 | TurnOffPlayerIntent éteint {Player} 63 | 64 | 65 | TurnOnPlayerIntent allume {Player} 66 | TurnOnPlayerIntent allume le lecteur {Player} 67 | TurnOnPlayerIntent allume lecteur {Player} 68 | 69 | AllOffIntent éteint tout 70 | AllOffIntent éteint tous les lecteurs 71 | AllOffIntent éteint les lecteurs 72 | 73 | AllOnIntent allume tout 74 | AllOnIntent allume tous les lecteurs 75 | AllOnIntent allume les lecteurs 76 | 77 | PlayRandomMixIntent joue du {Genre} 78 | PlayRandomMixIntent joue de la {Genre} 79 | PlayRandomMixIntent joue de la {Genre} et du {SecondaryGenre} 80 | PlayRandomMixIntent joue de la {Genre} et de la {SecondaryGenre} 81 | PlayRandomMixIntent joue du {Genre} et du {SecondaryGenre} 82 | PlayRandomMixIntent joue du {Genre} et de la {SecondaryGenre} 83 | PlayRandomMixIntent joue {Genre} et {SecondaryGenre} 84 | 85 | PlayRandomMixIntent joue de la {Genre} de la {SecondaryGenre} et de la {TertiaryGenre} 86 | PlayRandomMixIntent joue du {Genre} du {SecondaryGenre} et du {TertiaryGenre} 87 | PlayRandomMixIntent joue du {Genre} du {SecondaryGenre} et de la {TertiaryGenre} 88 | PlayRandomMixIntent joue du {Genre} de la {SecondaryGenre} et de la {TertiaryGenre} 89 | 90 | PlayRandomMixIntent joue un mélange de {Genre} et de {SecondaryGenre} 91 | PlayRandomMixIntent joue un mélange de {Genre} et {SecondaryGenre} 92 | PlayRandomMixIntent joue une mélange de {Genre} {SecondaryGenre} et {TertiaryGenre} 93 | PlayRandomMixIntent joue une mélange de {Genre} {SecondaryGenre} et de {TertiaryGenre} 94 | 95 | PlayPlaylistIntent joue ma liste de lecture {Playlist} 96 | PlayPlaylistIntent joue ma liste de lecture {Playlist} sur {Player} 97 | PlayPlaylistIntent joue ma liste de lecteur {Playlist} dans {Player} 98 | 99 | PlayPlaylistIntent joue la liste de lecture {Playlist} 100 | PlayPlaylistIntent joue la liste de lecture {Playlist} sur {Player} 101 | PlayPlaylistIntent joue la liste de lecture {Playlist} dans {Player} 102 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-18 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | from time import sleep 13 | from unittest import TestCase 14 | 15 | from pytest import raises 16 | 17 | from squeezealexa import Settings 18 | from squeezealexa.utils import human_join, sanitise_text, with_example, \ 19 | stronger, print_d, print_w, wait_for 20 | 21 | LOTS = ['foo', 'bar', 'baz', 'quux'] 22 | 23 | 24 | class TestEnglishJoin(TestCase): 25 | def test_basics(self): 26 | assert human_join([]) == '' 27 | assert human_join(['foo']) == 'foo' 28 | assert human_join(['foo', 'bar']) == 'foo and bar' 29 | assert human_join(LOTS[:-1]) == 'foo, bar and baz' 30 | assert human_join(LOTS) == 'foo, bar, baz and quux' 31 | 32 | def test_alternate_join_works(self): 33 | assert human_join(['foo', 'bar'], 'or') == 'foo or bar' 34 | 35 | def test_tuples_ok(self): 36 | assert human_join(('foo', 'bar'), 'or') == 'foo or bar' 37 | 38 | def test_skips_falsey(self): 39 | assert human_join(['foo', None, 'bar', '']) == 'foo and bar' 40 | 41 | def test_nothing(self): 42 | assert human_join(None) == '' 43 | 44 | 45 | class TestSanitise(TestCase): 46 | def test_nothing(self): 47 | assert sanitise_text("") == "" 48 | 49 | def test_ands(self): 50 | assert sanitise_text('Drum & Bass') == 'Drum N Bass' 51 | assert sanitise_text('Drum&Bass') == 'Drum N Bass' 52 | assert sanitise_text('R&B') == 'R N B' 53 | assert sanitise_text('Jazz+Funk') == 'Jazz N Funk' 54 | 55 | def test_punctuation(self): 56 | assert sanitise_text('Alt. Rock') == 'Alt Rock' 57 | assert sanitise_text('Alt.Rock') == 'Alt Rock' 58 | assert sanitise_text('Trip-hop') == 'Trip hop' 59 | assert sanitise_text('Pop/Funk') == 'Pop Funk' 60 | 61 | def test_apostrophes(self): 62 | assert sanitise_text("10's pop") == '10s pop' 63 | 64 | def test_playlists(self): 65 | assert sanitise_text("My bad-a$$ playlist") == 'My bad ass playlist' 66 | 67 | 68 | class TestWithExample(TestCase): 69 | def test_with_example_zero(self): 70 | assert with_example("{num} words", []) == "0 words" 71 | 72 | def test_with_example(self): 73 | output = with_example("{num} words", ['one', 'two']) 74 | assert output.startswith('2 words (e.g. "') 75 | 76 | def test_with_example_dict(self): 77 | assert with_example("{num} words", {1: 'one'}) == '1 words ("1")' 78 | 79 | def test_missing_num_raises(self): 80 | with raises(ValueError): 81 | with_example("Nothing {there}", [2]) 82 | 83 | 84 | class TestStrong(TestCase): 85 | def test_full(self): 86 | for (k, v), exp in [(('canpoweroff', '1'), True), 87 | (('hasitems', '0'), False), 88 | (('duration', '0.0'), 0.0), 89 | (('foo', 'bar'), 'bar')]: 90 | assert stronger(k, v) == exp 91 | 92 | 93 | class TestLogging(TestCase): 94 | def test_print_d(self): 95 | actual = print_d("{foo} - {num:.1f}", foo="bar", num=3.1415) 96 | assert actual == "bar - 3.1" 97 | 98 | def test_print_d_rejects_positional(self): 99 | with raises(ValueError): 100 | print_d("This is not cool: {}", "bar") 101 | 102 | def test_print_w(self): 103 | assert "Exception" in print_w("{ex!r}", ex=Exception("bar")) 104 | 105 | 106 | class FakeSettings(Settings): 107 | foo = "bar" 108 | _private = 1234 109 | 110 | 111 | class TestSettings: 112 | def test_str(self): 113 | s = FakeSettings() 114 | assert "_private" not in str(s) 115 | assert str(s) == "{'foo': 'bar'}" 116 | 117 | def test_configured(self): 118 | assert FakeSettings().configured 119 | assert Settings().configured 120 | 121 | 122 | class TestWaitFor: 123 | 124 | def test_timeout_raises_nicely(self): 125 | context = FakeSettings() 126 | with raises(Exception) as e: 127 | wait_for(lambda x: sleep(1.1), 1, "Doing things", context) 128 | assert "Failed \"Doing things\"" in str(e) 129 | # assert str(context) in str(e) 130 | assert "after 1.2 seconds" in str(e) 131 | -------------------------------------------------------------------------------- /docs/TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | Troubleshooting 2 | =============== 3 | 4 | Using the diagnostic tool 5 | ------------------------- 6 | From your `squeezealexa` directory, 7 | ```bash 8 | bin/local_test.py 9 | ``` 10 | 11 | This assumes you have Python. You can run this more explicitly (e.g. on Windows): 12 | 13 | python bin/local_test.py 14 | 15 | Note, you may need to specify `python3` on systems with both Python 2 and 3 installed. 16 | 17 | `local_test.py` can now diagnose _some_ common connection problems :smile: 18 | 19 | This should connect with your settings as per `settings.py`. The latest diagnostics can help you find the root cause of many common connection / certificate problems (but not 100% accurate). 20 | Some examples of how this can happen are included in the [tests](../tests/). 21 | 22 | 23 | The skill is installed, but erroring when invoked 24 | ------------------------------------------------- 25 | 26 | ### Spoken errors 27 | :new: Note that in versions 1.2+, `squeeze-alexa` will attempt to get Alexa to respond in semi-English text / cards. 28 | A bit _HAL-9000_, but it's quicker than checking Cloudwatch logs :smile: 29 | 30 | You can disable this by changing `USE_SPOKEN_ERRORS` to `False` in your settings. 31 | 32 | ### Log-based errors 33 | 34 | If everything is installed and the connectivity working, but your Echo is saying "there was a problem with your skill" or similar, try checking the [Cloudwatch logs](https://console.aws.amazon.com/cloudwatch/) (note there's a delay in getting the latest logs). 35 | The squeeze-alexa logs are designed to be quite readable, and should help track down the problem. 36 | 37 | If you think it's the speech, try using the test input page on the Amazon dev account portal. 38 | 39 | If all else fails, raise an issue here... 40 | 41 | ### Strange IOErrors 42 | If you're getting permission denied `IOErrors` reported in the logs, 43 | make sure you cert file has world read (i.e. run `chmod 644 squeeze-alexa.pem`) 44 | 45 | Checking certificate problems 46 | ----------------------------- 47 | 48 | ### Examine your local certificate 49 | ```bash 50 | openssl x509 -in squeeze-alexa.pem -text -noout 51 | ``` 52 | 53 | If you just want to check the expiry date: 54 | ```bash 55 | openssl x509 -in squeeze-alexa.pem -enddate -noout 56 | ``` 57 | 58 | 59 | Debugging SSL connection problems 60 | --------------------------------- 61 | 62 | For `$MY_HOSTNAME` and `$MY_PORT` you can substitute your home IP / domain name (as used above). It also assumes your client cert is called `squeeze-alexa.pem`: 63 | 64 | ```bash 65 | openssl s_client -connect $MY_HOSTNAME:$MY_PORT -cert squeeze-alexa.pem | openssl x509 66 | ``` 67 | Type Ctrld to exit. 68 | If successful, this should give you a PEM-style certificate block with some info about your cert). 69 | 70 | ### That didn't work 71 | OK let's try seeing the problem: 72 | ```bash 73 | openssl s_client -connect $MY_HOSTNAME:$MY_PORT -cert squeeze-alexa.pem 74 | ``` 75 | Note especially towards the end, the _Verify return code_. This is very helpful getting to the bottom of connection problems. 76 | 77 | ### Connecting 78 | For more debugging: 79 | ```bash 80 | openssl s_client -connect $MY_HOSTNAME:$MY_PORT -quiet -cert squeeze-alexa.pem 81 | ``` 82 | Type `status`, and if a successful end-to-end connection is made you should see some gibberish that looks a bit like: 83 | `...status player_name%3AUpstairs...player_connected%3A1 player_ip%3A192.168.1...` 84 | 85 | Checking your LMS CLI is actually working 86 | ----------------------------------------- 87 | 88 | ### Remotely 89 | Assuming your LMS IP restrictions allow it (check the LMS GUI security settings), and that you are using the standard 9090 CLI port, you can normally telnet from your computer: 90 | 91 | ```bash 92 | telnet $LMS 9090 93 | ``` 94 | where `LMS` is the address of your Squeezebox server - usually this will be the same as `$MY_HOSTNAME` (though you might use the local name). 95 | Then type `status`, or some other command, and see if you get an encoded response. If not, you **need** to fix this first. 96 | 97 | ### From your server 98 | You can also try it directly on the LMS box if you think there's some networking problem. Use `netcat` (e.g. `opkg install netcat`) if you have it: 99 | 100 | ```bash 101 | echo "status" | netcat $LMS 9090 102 | ``` 103 | 104 | (and try `localhost` if that's not working. If still no joy, your DNS setup might be confused). 105 | 106 | ### Resilience / performance testing the SSL connection 107 | For the hardcore amongst you, you can check performance (and that there are no TLS bugs / obvious holes): 108 | 109 | ```bash 110 | openssl s_time -bugs -connect $MY_HOSTNAME:$MY_PORT -cert squeeze-alexa.pem -verify 4 111 | ``` 112 | -------------------------------------------------------------------------------- /tests/alexa/alexa_handlers_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | import traceback 14 | import uuid 15 | from collections import defaultdict 16 | from unittest import TestCase 17 | 18 | import pytest 19 | 20 | from squeezealexa.alexa.handlers import AlexaHandler 21 | from squeezealexa.alexa.requests import Request 22 | from squeezealexa.main import SqueezeAlexa, handler 23 | from squeezealexa.squeezebox.server import Server 24 | from tests.intents_test import FakeTransport 25 | 26 | SOME_SESSION = {'new': False, 27 | 'sessionId': uuid.uuid4(), 28 | 'application': 'my-app-id'} 29 | 30 | NO_SESSION = {'new': True, 31 | 'sessionId': uuid.uuid4(), 32 | 'application': 'abc-123'} 33 | 34 | 35 | class SpyAlexaHandler(AlexaHandler): 36 | """Spy on AlexaHandler base class""" 37 | 38 | def __init__(self): 39 | super(SpyAlexaHandler, self).__init__() 40 | self.called = defaultdict(int) 41 | self.delegate = AlexaHandler() 42 | 43 | def on_session_ended(self, session_ended_request, session): 44 | return self.__record('on_session_ended') 45 | 46 | def on_session_started(self, request, session): 47 | return self.__record('on_session_started') 48 | 49 | def on_intent(self, intent_request, session): 50 | return self.__record('on_intent') 51 | 52 | def on_launch(self, launch_request, session): 53 | return self.__record('on_launch') 54 | 55 | def __record(self, name, *args, **kwargs): 56 | call = traceback.extract_stack(limit=5)[3] 57 | # 4-tuple (filename, line number, function name, text) 58 | self.called[call[2]] += 1 59 | return getattr(self.delegate, name)(args, kwargs) 60 | 61 | 62 | class AlexaHandlerTest(TestCase): 63 | 64 | def test_throws_for_unknown_type(self): 65 | ah = AlexaHandler() 66 | with pytest.raises(ValueError) as excinfo: 67 | ah.handle(self.request_of('InvalidType')) 68 | assert 'unknown request type' in str(excinfo.value).lower() 69 | 70 | def test_launch(self): 71 | tah = SpyAlexaHandler() 72 | tah.handle(self.request_of(Request.LAUNCH)) 73 | assert tah.called['on_launch'], tah.called 74 | 75 | def test_end(self): 76 | tah = SpyAlexaHandler() 77 | tah.handle(self.request_of(Request.SESSION_ENDED)) 78 | assert tah.called['on_session_ended'], tah.called 79 | 80 | def request_of(self, type): 81 | return {'request': {'requestId': '1234', 82 | 'type': type}} 83 | 84 | 85 | class SqueezeAlexaTest(TestCase): 86 | 87 | def test_ignores_audio_callbacks(self): 88 | sqa = SqueezeAlexa(server=None) 89 | sqa.handle({'request': {'requestId': '1234', 90 | 'type': 'AudioPlayerStarted'}}) 91 | 92 | def test_handling_all_intents(self): 93 | fake_output = FakeTransport().start() 94 | server = Server(transport=fake_output) 95 | alexa = SqueezeAlexa(server=server) 96 | for name, func in handler._handlers.items(): 97 | intent = {'name': name, 98 | 'slots': {'Player': {'name': 'Player', 'value': 'fake'}, 99 | 'Volume': {'name': 'Volume', 'value': '5'}}} 100 | output = alexa.handle(self.request_for(intent, SOME_SESSION)) 101 | self.validate_response(name, output) 102 | 103 | def test_handling_all_intents_without_session_or_slots(self): 104 | server = Server(transport=(FakeTransport().start())) 105 | alexa = SqueezeAlexa(server=server) 106 | for name, func in handler._handlers.items(): 107 | request = self.request_for({'name': name, 'slots': {}}, NO_SESSION) 108 | output = alexa.handle(request, None) 109 | self.validate_response(name, output) 110 | 111 | def validate_response(self, name, output): 112 | response = output['response'] 113 | assert 'directives' in response or 'outputSpeech' in response, \ 114 | "%s handling failed (%s)" % (name, output) 115 | 116 | def request_for(self, intent, session): 117 | return {'request': {'requestId': uuid.uuid4(), 118 | 'type': Request.INTENT, 119 | 'intent': intent}, 120 | 'session': session} 121 | -------------------------------------------------------------------------------- /tests/transport/test_mqtt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2018-19 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | from datetime import datetime 13 | 14 | import pytest 15 | from paho.mqtt.client import MQTT_ERR_SUCCESS, MQTTMessage, MQTTMessageInfo 16 | 17 | from squeezealexa.settings import MqttSettings 18 | from squeezealexa.transport.base import Error 19 | from squeezealexa.transport.mqtt import MqttTransport, CustomClient 20 | 21 | 22 | class NoTlsCustomClient(CustomClient): 23 | def _configure_tls(self): 24 | pass 25 | 26 | 27 | class EchoingFakeClient(NoTlsCustomClient): 28 | PREFIX = "OK: " 29 | 30 | def __init__(self, settings: MqttSettings): 31 | super().__init__(settings) 32 | self.subscribed = [] 33 | self.unsubscribed = [] 34 | 35 | def connect(self, host=None, port=None, keepalive=30, bind_address=""): 36 | if self.on_connect: 37 | self.on_connect(self, None, None, 1) 38 | return MQTT_ERR_SUCCESS, 1 39 | 40 | def subscribe(self, topic, qos=0): 41 | self.subscribed.append(topic) 42 | if self.on_subscribe: 43 | self.on_subscribe(self, None, 123, (qos,)) 44 | return MQTT_ERR_SUCCESS, 2 45 | 46 | def publish(self, topic, payload=None, qos=0, retain=False): 47 | if self.on_publish: 48 | self.on_publish(self, None, 123) 49 | self.react_to_msg(payload) 50 | info = MQTTMessageInfo(123) 51 | info._published = True 52 | return info 53 | 54 | def unsubscribe(self, topic): 55 | self.unsubscribed.append(topic) 56 | return super().unsubscribe(topic) 57 | 58 | def react_to_msg(self, payload): 59 | """Fake the round trip entirely""" 60 | msg = MQTTMessage(topic=self.settings.topic_resp) 61 | msg.payload = b"%s%s" % (self.PREFIX.encode('utf-8'), payload) 62 | self.on_message(self, None, msg) 63 | 64 | def __str__(self) -> str: 65 | return "" 66 | 67 | def reconnect(self): 68 | return MQTT_ERR_SUCCESS 69 | 70 | 71 | @pytest.fixture 72 | def fake_client(): 73 | c = EchoingFakeClient(MqttSettings()) 74 | c.connect() 75 | yield c 76 | c.disconnect() 77 | del c 78 | 79 | 80 | class TestMqttTransport: 81 | def test_communicate(self, fake_client): 82 | """Ensure that the communication we get back is the echo server's""" 83 | t = MqttTransport(fake_client, req_topic="foo", resp_topic="bar") 84 | t.start() 85 | assert fake_client.subscribed == ["bar"] 86 | msg = "TEST MESSAGE at %s" % datetime.now() 87 | ret = t.communicate(msg) 88 | assert ret == fake_client.PREFIX + msg 89 | del t 90 | 91 | def test_lazy_communicate(self, fake_client): 92 | t = MqttTransport(fake_client, req_topic="foo", resp_topic="bar") 93 | t.start() 94 | assert not t.communicate("FIRE AND FORGET", wait=False) 95 | 96 | def test_multiline_communicate(self, fake_client): 97 | """Ensure that the communication we get back is the echo server's""" 98 | t = MqttTransport(fake_client, req_topic="foo", resp_topic="bar") 99 | t.start() 100 | msg = "TEST MESSAGE at %s\nAND ANOTHER\nLAST" % datetime.now() 101 | ret = t.communicate(msg) 102 | assert ret == fake_client.PREFIX + msg 103 | 104 | def test_details(self, fake_client): 105 | """Ensure that the communication we get back is the echo server's""" 106 | t = MqttTransport(fake_client, req_topic="foo", resp_topic="bar") 107 | s = str(t) 108 | assert "MQTT " in s 109 | assert str(fake_client) in s 110 | 111 | def test_stop(self, fake_client): 112 | t = MqttTransport(fake_client, req_topic="foo", resp_topic="bar") 113 | t.stop() 114 | # We no longer unsubscribe on stop 115 | # assert fake_client.unsubscribed == ['bar' 116 | assert not t.is_connected 117 | 118 | 119 | class TestCustomClient: 120 | def test_get_conf_file(self): 121 | c = NoTlsCustomClient(MqttSettings()) 122 | assert c._conf_file_of("*.md") 123 | 124 | def test_get_conf_file_raises(self): 125 | c = NoTlsCustomClient(MqttSettings()) 126 | with pytest.raises(Error) as e: 127 | c._conf_file_of("*.py") 128 | assert "Can't find" in str(e) 129 | 130 | 131 | class TestMqttSettings: 132 | def test_configured(self): 133 | m = MqttSettings() 134 | m.hostname = None 135 | c = m.configured 136 | assert not c 137 | -------------------------------------------------------------------------------- /squeezealexa/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-18 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | from os.path import join 14 | 15 | from squeezealexa import ROOT_DIR, Settings 16 | 17 | """ 18 | This file contains settings with everything set to the defaults 19 | At the very least you need to set SERVER_HOSTNAME, SERVER_SSL_PORT. 20 | """ 21 | 22 | 23 | # --------------------------- App (Skill) Settings ---------------------------- 24 | 25 | class SkillSettings(Settings): 26 | APPLICATION_ID = None 27 | """The Skill's Amazon application ID (e.g. "amznl.ask.skill.xyz") 28 | A value of None means verification of the request's Skill will be disabled. 29 | """ 30 | 31 | LOCALE = 'en_US' 32 | """The locale (language & region) to use for your app, 33 | e.g. en_GB.UTF-8, or de_DE""" 34 | 35 | RESPONSE_AUDIO_FILE_URL = \ 36 | 'https://s3.amazonaws.com/declension-alexa-media/silence.mp3' 37 | """Change this to your own HTTPS MP3, which must be accessible to Alexa""" 38 | 39 | USE_SPOKEN_ERRORS = True 40 | """If True, Alexa will response with squeeze-alexa error information. 41 | Sometimes this is useful, sometimes it's definitely not what you want""" 42 | 43 | CERT_DIR = join(ROOT_DIR, "etc", "certs") 44 | """The directory that certs can be found in""" 45 | 46 | 47 | # ----------------------- LMS (SqueezeServer) Settings ------------------------ 48 | 49 | class LmsSettings(Settings): 50 | CLI_PORT = 9090 51 | """The LAN-side port for your Squeezeserver CLI, defaults to 9090""" 52 | 53 | USERNAME = None 54 | """A string containing the CLI username, or None if not required.""" 55 | 56 | PASSWORD = None 57 | """A string containing the CLI password, or None if not required.""" 58 | 59 | DEFAULT_PLAYER = None 60 | """The default player ID (long MAC-like string) to use""" 61 | 62 | DEBUG = False 63 | """Dump LMS CLI communication to log if True""" 64 | 65 | 66 | # -------------------------- SSL Transport Settings --------------------------- 67 | 68 | class SslSettings(Settings): 69 | 70 | SERVER_HOSTNAME = 'my-squeezebox-cli-proxy.example.com' 71 | """The public hostname / IP of your Squeezebox server CLI proxy""" 72 | 73 | PORT = 19090 74 | """The above proxy server's listening port (that accepts TLS connections). 75 | For stunnel, this will be the same as `accept = ...`""" 76 | 77 | CERT_FILE = 'squeeze-alexa.pem' 78 | """The PEM-format certificate filename for TLS verification, 79 | or None to disable""" 80 | 81 | CERT_FILE_PATH = (join(SkillSettings.CERT_DIR, CERT_FILE) 82 | if CERT_FILE else None) 83 | """The full path to the certificate file, usually under etc/""" 84 | 85 | CA_FILE_PATH = CERT_FILE_PATH 86 | """The certificate authority file, in .pem. 87 | This can be the same as the CERT_FILE_PATH if you're self-certifying.""" 88 | 89 | VERIFY_SERVER_HOSTNAME = bool(CERT_FILE_PATH) 90 | """Whether to verify the server's TLS certificate hostname. 91 | Override to False if your certificate is for a different domain from your 92 | SERVER_HOSTNAME.""" 93 | 94 | 95 | # -------------------------- MQTT Transport Settings -------------------------- 96 | 97 | class MqttSettings(Settings): 98 | 99 | HOSTNAME = '' 100 | """The hostname for the Internet MQTT server (for MQTT mode) 101 | e.g. "xxxxxxxxxxxxx.iot.eu-west-1.amazonaws.com 102 | Leaving this blank will disable MQTT mode""" 103 | 104 | PORT = 8883 105 | """The (TLS) port the above server is listening on. 8883 is default""" 106 | 107 | CERT_DIR = SkillSettings.CERT_DIR 108 | """Where the AWS IoT certificate / key files are kept""" 109 | 110 | INTERNAL_SERVER_HOSTNAME = '192.168.1.9' 111 | """The LAN-side hostname for your Squeezeserver 112 | e.g. my-nas or 192.168.1.100""" 113 | 114 | TOPIC_REQ = 'squeeze-req' 115 | """The MQTT topic for incoming messages (from squeeze-alexa Lambda)""" 116 | 117 | TOPIC_RESP = 'squeeze-resp' 118 | """The MQTT topic for outgoing messages (back to squeeze-alexa Lambda)""" 119 | 120 | DEBUG = False 121 | """Whether to log all MQTT traffic (warning: will contain passwords etc)""" 122 | 123 | def __init__(self, hostname=HOSTNAME, port=PORT, cert_dir=CERT_DIR, 124 | internal_server_hostname=INTERNAL_SERVER_HOSTNAME, 125 | topic_req=TOPIC_REQ, topic_resp=TOPIC_RESP, debug=DEBUG): 126 | # Do these explicitly to allow us to override by name 127 | self.hostname = hostname 128 | self.cert_dir = cert_dir 129 | self.port = port 130 | self.internal_server_hostname = internal_server_hostname 131 | self.topic_req = topic_req 132 | self.topic_resp = topic_resp 133 | self.debug = debug 134 | 135 | @property 136 | def configured(self): 137 | """Whether the settings are configured""" 138 | return bool(self.hostname and self.port and 139 | self.topic_req and self.topic_resp) 140 | 141 | 142 | # Singletons for lazy^W easy importing 143 | 144 | SSL_SETTINGS = SslSettings() 145 | 146 | SKILL_SETTINGS = SkillSettings() 147 | 148 | LMS_SETTINGS = LmsSettings() 149 | 150 | MQTT_SETTINGS = MqttSettings() 151 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributors' Guide 2 | =================== 3 | 4 | [Translating to your own language](#translation) is perhaps the most useful thing you can do for the project currently, 5 | as Amazon rolls out more and more language support for Alexa. 6 | 7 | Developing 8 | ---------- 9 | 10 | ### Where to start 11 | Generally, have a look at tickets marked [help wanted](https://github.com/declension/squeeze-alexa/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) 12 | or [good first issue](https://github.com/declension/squeeze-alexa/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). 13 | 14 | Generally pull requests are accepted if they: 15 | * Address a Github issue where the approach has been discussed 16 | * Pass all automated tests and linting 17 | * Don't reduce the test coverage 18 | * Are clearly written, and in a Pythonic way 19 | * Use the current (ever changing...) best practices for Alexa skills 20 | * :new: use Python 3.5+ features where appropriate (in particular typing) 21 | 3.6 features can't currently be used for mqtt-squeeze. 22 | 23 | 24 | ### Building 25 | 26 | The project is now Python 3.6+ only, and we use ~~Tox~~ ~~Pipenv~~ [Poetry](https://poetry.eustace.io/docs/) (see [#114](https://github.com/declension/squeeze-alexa/issues/114)). 27 | 28 | 29 | ### Testing 30 | We use PyTest and plugins for testing. You can run tests with 31 | 32 | ```bash 33 | poetry run pytest 34 | ``` 35 | 36 | Testing is very important in this project, and coverage is high. 37 | Please respect this! 38 | 39 | Coverage is reported [in Coveralls](https://coveralls.io/github/declension/squeeze-alexa). 40 | 41 | 42 | ### Code Quality 43 | 44 | ```bash 45 | poetry run flake8 --statistics . 46 | ``` 47 | No output / error code means everything is good... 48 | 49 | ### Releasing 50 | 51 | This is mostly automated now: 52 | ``` 53 | bin/build.sh 54 | bin/release.sh 3.0 55 | ``` 56 | will create `releases/squeeze-alexa-3.0.zip` (hopefully) suitable for upload to Github etc. 57 | 58 | 59 | 60 | Translation 61 | ----------- 62 | 63 | squeeze-alexa uses [GNU gettext](https://www.gnu.org/software/gettext/) for its _output_. 64 | It's a little old-fashioned / troublesome at first, but it serves its purposes well. 65 | 66 | 67 | ### I have a new language 68 | Great! Follow these steps (imagine you are choosing Brazilian Portuguese, `pt_BR`): 69 | 70 | #### Create a directory 71 | 72 | ```bash 73 | cd locale 74 | LOCALE=pt_BR 75 | mkdir -p $LOCALE/LC_MESSAGES 76 | ``` 77 | 78 | #### Generate a blank PO file 79 | ```bash 80 | DOMAIN=squeeze-alexa 81 | touch $LOCALE/LC_MESSAGES/$DOMAIN.po 82 | ``` 83 | 84 | #### Update translations from source 85 | This re-scans the source and recreates a master `.pot` file, before then updating the translations files (`.po`s). 86 | 87 | ```bash 88 | bin/update-translations 89 | ``` 90 | 91 | #### Translate your .po 92 | You can edit the using any text editor, or use PoEdit, or any other gettext tools e.g. 93 | 94 | * [PoEdit](https://poedit.net/) 95 | * [GTranslator](https://wiki.gnome.org/Apps/Gtranslator) 96 | * [Virtaal](http://virtaal.translatehouse.org/download.html) 97 | 98 | ### Compile translations 99 | This takes the `.po`s and makes binary `.mo`s that are necessary for gettext to work. 100 | ```bash 101 | bin/compile-translations 102 | ``` 103 | 104 | ### Add utterances and slots 105 | Amazon changed the way they handle interaction. The original way (v0) used separate input for slots and utterances. 106 | In [interaction model v1](https://developer.amazon.com/docs/smapi/interaction-model-schema.html#sample-interaction-model-schema), among other changes, they've merged this into one big JSON which is probably easier in the long run. 107 | 108 | squeeze-alexa (documentation) now "supports" both 109 | 110 | #### v0 schema 111 | * Refer to the v0 [intents.json](../metadata/intents/v0/intents.json). 112 | * Add an `utterances.txt` in the right locale directory e.g. `metadata/intents/v0/locale/pt_BR/utterances.txt` (see [German example](https://github.com/declension/squeeze-alexa/blob/master/metadata/intents/v0/locale/de_DE/utterances.txt)) 113 | * Optional: do the same for the SLOT (genres, playlist names, player names etc) 114 | 115 | #### ...or v1 schema 116 | * Create a new locale directory, e.g. `pt_BR` under `metadata/v1/locale`. 117 | * Translate the whole English file [v1 intents.json](../metadata/intents/v1/locale/en_US) to a copy of the same name under that new directory. 118 | 119 | 120 | #### Submit the translations 121 | * Hopefully you opened a Github issue - if not, do this. 122 | * Either 123 | * attach the updated `.po` and utterances / intents files, or 124 | * create a fork in Github, branch, commit your new file(s) in Git, then make a Pull Request, mentioning the ticket number. 125 | 126 | 127 | ### Translation FAQ 128 | 129 | #### Everything's still in US English 130 | * Make sure you've set `LOCALE` in `settings.py`. 131 | * Make sure the directory is setup as above and you've definitely compiled it (i.e. there's a `.mo` file) 132 | * New versions of `squeeze-alexa` default to the source language (`en_US`) if there is no translation found. 133 | 134 | #### What if I don't translate some strings? 135 | No problem. They'll come out in the source language (`en` here). 136 | 137 | #### I'm getting "invalid multibyte sequence" errors 138 | This `.po` header is probably missing: 139 | 140 | msgid "" 141 | msgstr "" 142 | "Content-Type: text/plain; charset=UTF-8\n" 143 | 144 | #### There are newlines I didn't expect 145 | `xgettext` reformats source files to a maximum line width according to its settings. 146 | See [`update-translations`](../bin/update-translations) for the setup. 147 | -------------------------------------------------------------------------------- /docs/MQTT.md: -------------------------------------------------------------------------------- 1 | squeeze-alexa with MQTT 2 | ======================= 3 | 4 | MQTT is a lightweight pub/sub messaging protocol with similarities to AMQ, used a lot in IoT situations. 5 | Amazon AWS now has good (and mostly free) support for this via [AWS IoT](https://aws.amazon.com/iot/). 6 | 7 | With MQTT transport, we'll be using this to act as the interface between your Alexa skill and your LMS server CLI. 8 | 9 | squeeze-alexa needs a tunnel (conceptually similar to stunnel or HAProxy for SSL) to relay traffic. 10 | For this you can use [mqtt-squeeze](../mqtt_squeeze.py). 11 | 12 | 13 | Set up mqtt-squeeze 14 | ------------------- 15 | * This needs to run on a server (typically the same as your LMS one). 16 | * Needs Python 3.6 (or 3.5 maybe), just like the skill (in fact it shares some code). 17 | * Admin access to your server (e.g. via SSH). 18 | 19 | ### Set up new Python 3 20 | Many new linux install will have Python 3.6 or at least 3.5. 21 | For those that don't, it's a bit harder 22 | :information_source: RPI users can install [Berryconda](https://github.com/jjhelmus/berryconda) (choose `Berryconda3`) to get Python 3.6 easily ([other ways here too](https://raspberrypi.stackexchange.com/questions/59381/how-do-i-update-my-rpi3-to-python-3-6)). 23 | You can read general docs on [Python on Raspbian](https://www.raspberrypi.org/documentation/linux/software/python.md). 24 | 25 | ### Copy the files 26 | * You'll need the 27 | * the script, `mqtt_squeeze.py` 28 | * the `etc/` directory with your certificates (see below) 29 | * the [`squeezealexa` directory](../squeezealexa) 30 | 31 | :new: Use the scripting to do this bit for you: 32 | ```bash 33 | bin/deploy.py mqtt 34 | ``` 35 | This creates an `mqtt-squeeze.tgz` file for you to copy to your server. 36 | 37 | Create a new directory somewhere on your server and copy this there. 38 | Maybe `/opt`, or `/usr/local/bin` on standard linux. 39 | For Synology, I've chosen `/volume1/mqtt-squeeze` 40 | Make sure `mqtt_squeeze.py` is executable (`chmod +x mqtt_squeeze.py`). 41 | Then just `tar -xf mqtt-squeeze.tgz`. 42 | 43 | ### Create a service 44 | * To start and stop this, it's best to use your OS's service manager. 45 | On Linux, this might be SysV (traditional), [Upstart](https://en.wikipedia.org/wiki/Upstart), or [systemd](https://en.wikipedia.org/wiki/Systemd) (most modern Linux). 46 | 47 | #### Using Upstart on Synology 48 | ##### Installing 49 | For convenience find [an Upstart script suitable for Synology](example-config/upstart/mqtt-squeeze.conf), 50 | which you can copy (or better: symlink) to `/etc/init/mqtt-squeeze.conf`: 51 | 52 | ```bash 53 | cd mqtt-squeeze/etc/conf 54 | ln -s mqtt-squeeze.conf /etc/init/mqtt-squeeze.conf 55 | ``` 56 | 57 | :warning: This file will be overwritten when Synology DSM is upgraded. 58 | 59 | ##### Using Upstart 60 | You can then reload the daemon: `sudo initctl reload-configuration` to pick up these config changes. 61 | 62 | You can then start it with: 63 | `sudo start mqtt-squeeze` 64 | 65 | And the status with: 66 | `sudo initctl status mqtt-squeeze` 67 | 68 | #### Using systemd 69 | :information_source: For Raspberry PI intro, see [systemd on Raspbian](https://www.raspberrypi.org/documentation/linux/usage/systemd.md). 70 | 71 | For convenience find [a systemd script](example-config/systemd/mqtt-squeeze.service), 72 | which you should edit and copy to `/etc/systemd/system/mqtt-squeeze.service`. 73 | 74 | To start the new `mqtt-squeeze` service 75 | ```bash 76 | sudo systemctl start mqtt-squeeze.service 77 | ``` 78 | 79 | To look at logs: 80 | ```bash 81 | sudo journalctl -u mqtt-squeeze.service 82 | ``` 83 | 84 | #### ...or manually 85 | You can just do it oldschool and run `nohup mqtt_squeeze.py &`, 86 | but you'll have to do this every time your server starts, 87 | and it doesn't take care of connections dying like Upstart etc. 88 | 89 | 90 | 91 | Set up MQTT with Amazon IOT 92 | --------------------------- 93 | 94 | ### Create a new certificate 95 | 96 | This convenient AWS CLI command will create the certs in the right place (assuming you're in the project root, e.g. `~/workspace/squeeze-alexa/`). 97 | You'll need to be logged in first, as with all the other aws commands. 98 | Use `--profile` if you've got lots of accounts. 99 | 100 | ```bash 101 | aws iot create-keys-and-certificate --set-as-active --certificate-pem-outfile etc/certs/iot-certificate.pem.crt --private-key-outfile etc/certs/iot-private.pem.key 102 | ``` 103 | 104 | 105 | ### Set up permissions for MQTT 106 | 107 | Go to the [AWS IoT section](https://console.aws.amazon.com/iot/) (make sure to select the right region), and you start the setup. 108 | 109 | Once you've created a certificate as above (which is used to authenticate, i.e. prove `mqtt-squeeze`'s _identity_ to AWS), 110 | you need to make sure it has _authorisation_ to do things we want to do. 111 | 112 | 113 | So here you'll need 114 | * an IAM policy to grant the right MQTT access to this cert (i.e. for `mqtt-squeeze`) 115 | * an IAM policy to grant the right MQTT access to the squeeze-alexa Lambda (I _think_, some stuff is default now. If you get errors in the lambda logs, you'll know... :thinking:) 116 | 117 | **Luckily** these can actually be the _same_ policy JSON (it's two-way communication), and even better, here's one I made earlier: 118 | [example mqtt-squeeze IAM policy](example-config/iot-iam-policy.json). 119 | Remember to make sure these match your own MQTT settings though if you're not using defaults. 120 | 121 | :beta: You can even use `aws iot attach-policy` on the command line if you prefer not using the AWS IOT GUI. 122 | 123 | Test 124 | ---- 125 | 126 | You can use `local_test.py` to test once your settings are configured. 127 | You can also debug using [AWS IoT test console](https://console.aws.amazon.com/iot/home#/test). 128 | 129 | 130 | Troubleshooting 131 | --------------- 132 | 133 | TODO: Add troubleshooting 134 | -------------------------------------------------------------------------------- /locale/en_GB/LC_MESSAGES/squeeze-alexa.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Language: en_GB\n" 4 | "Content-Type: text/plain; charset=UTF-8\n" 5 | 6 | #: squeezealexa/main.py:75 7 | msgid "Squeezebox is online. Please try some commands." 8 | msgstr "" 9 | 10 | #: squeezealexa/main.py:76 11 | msgid "Try resume, pause, next, previous, play some jazz, or ask Squeezebox to turn it up or down" 12 | msgstr "" 13 | 14 | #: squeezealexa/main.py:91 15 | #, python-brace-format 16 | msgid "Sorry, I don't know how to process a \"{intent}\"" 17 | msgstr "" 18 | 19 | #: squeezealexa/main.py:93 20 | #, python-brace-format 21 | msgid "Unknown intent: '{intent}'" 22 | msgstr "" 23 | 24 | #: squeezealexa/main.py:109 25 | msgid "Rewind!" 26 | msgstr "" 27 | 28 | #: squeezealexa/main.py:114 29 | msgid "Yep, pretty lame." 30 | msgstr "" 31 | 32 | #: squeezealexa/main.py:122 33 | #, python-brace-format 34 | msgid "Currently playing: \"{title}\"" 35 | msgstr "" 36 | 37 | #: squeezealexa/main.py:124 38 | #, python-brace-format 39 | msgid ", by {artists}." 40 | msgstr "" 41 | 42 | #: squeezealexa/main.py:126 43 | #, python-brace-format 44 | msgid "Now playing: \"{title}\"" 45 | msgstr "" 46 | 47 | #: squeezealexa/main.py:128 48 | msgid "Nothing playing." 49 | msgstr "" 50 | 51 | #: squeezealexa/main.py:139 squeezealexa/main.py:143 52 | msgid "Select a volume value between 0 and 10" 53 | msgstr "" 54 | 55 | #: squeezealexa/main.py:140 56 | msgid "Invalid volume value" 57 | msgstr "" 58 | 59 | #: squeezealexa/main.py:144 60 | #, python-brace-format 61 | msgid "Volume value out of range: {volume}" 62 | msgstr "" 63 | 64 | #: squeezealexa/main.py:150 65 | #, python-brace-format 66 | msgid "Set volume to {volume}" 67 | msgstr "" 68 | 69 | #: squeezealexa/main.py:162 70 | msgid "Select a volume between 0 and 100 percent" 71 | msgstr "" 72 | 73 | #: squeezealexa/main.py:163 74 | msgid "Invalid volume" 75 | msgstr "" 76 | 77 | #: squeezealexa/main.py:166 78 | msgid "Select a volume value between 0 and 100 percent" 79 | msgstr "" 80 | 81 | #: squeezealexa/main.py:167 82 | #, python-brace-format 83 | msgid "Volume value out of range: {volume} percent" 84 | msgstr "" 85 | 86 | #: squeezealexa/main.py:171 87 | msgid "OK" 88 | msgstr "" 89 | 90 | #: squeezealexa/main.py:172 91 | #, python-brace-format 92 | msgid "Set volume to {percent} percent" 93 | msgstr "" 94 | 95 | #: squeezealexa/main.py:178 96 | msgid "Increase Volume" 97 | msgstr "" 98 | 99 | #: squeezealexa/main.py:179 100 | msgid "Pumped it up." 101 | msgstr "" 102 | 103 | #: squeezealexa/main.py:184 104 | msgid "Decrease Volume" 105 | msgstr "" 106 | 107 | #: squeezealexa/main.py:185 108 | msgid "OK, quieter now." 109 | msgstr "" 110 | 111 | #: squeezealexa/main.py:197 112 | #, python-brace-format 113 | msgid "Selected {player}" 114 | msgstr "" 115 | 116 | #: squeezealexa/main.py:198 117 | #, python-brace-format 118 | msgid "Selected player {player}" 119 | msgstr "" 120 | 121 | #: squeezealexa/main.py:201 122 | #, python-brace-format 123 | msgid "I only found these players: {players}. Could you try again?" 124 | msgstr "" 125 | 126 | #: squeezealexa/main.py:204 127 | #, python-brace-format 128 | msgid "You can select a player by saying \"{utterance}\" and then the player name." 129 | msgstr "" 130 | 131 | #: squeezealexa/main.py:209 132 | #, python-brace-format 133 | msgid "No player called \"{name}\"" 134 | msgstr "" 135 | 136 | #: squeezealexa/main.py:219 137 | msgid "Shuffle on" 138 | msgstr "" 139 | 140 | #: squeezealexa/main.py:220 141 | msgid "Shuffle is now on" 142 | msgstr "" 143 | 144 | #: squeezealexa/main.py:226 145 | msgid "Shuffle off" 146 | msgstr "" 147 | 148 | #: squeezealexa/main.py:227 149 | msgid "Shuffle is now off" 150 | msgstr "" 151 | 152 | #: squeezealexa/main.py:233 153 | msgid "Repeat on" 154 | msgstr "" 155 | 156 | #: squeezealexa/main.py:234 157 | msgid "Repeat is now on" 158 | msgstr "" 159 | 160 | #: squeezealexa/main.py:240 161 | msgid "Repeat Off" 162 | msgstr "" 163 | 164 | #: squeezealexa/main.py:241 165 | msgid "Repeat is now off" 166 | msgstr "" 167 | 168 | #: squeezealexa/main.py:250 169 | #, python-brace-format 170 | msgid "Switched {player} off" 171 | msgstr "" 172 | 173 | #: squeezealexa/main.py:251 174 | #, python-brace-format 175 | msgid "{player} is now off" 176 | msgstr "" 177 | 178 | #: squeezealexa/main.py:265 179 | #, python-brace-format 180 | msgid "Switched {player} on" 181 | msgstr "" 182 | 183 | #: squeezealexa/main.py:271 184 | msgid "Players all off" 185 | msgstr "" 186 | 187 | #: squeezealexa/main.py:272 188 | msgid "Silence." 189 | msgstr "" 190 | 191 | #: squeezealexa/main.py:277 192 | msgid "All On." 193 | msgstr "" 194 | 195 | #: squeezealexa/main.py:278 196 | msgid "Ready to rock" 197 | msgstr "" 198 | 199 | #: squeezealexa/main.py:289 200 | msgid "There are no playlists" 201 | msgstr "" 202 | 203 | #: squeezealexa/main.py:291 204 | #, python-brace-format 205 | msgid "Didn't hear a playlist there. You could try the \"{name}\" playlist?" 206 | msgstr "" 207 | 208 | #: squeezealexa/main.py:297 209 | msgid "No Squeezebox playlists found" 210 | msgstr "" 211 | 212 | #: squeezealexa/main.py:306 squeezealexa/main.py:307 213 | #, python-brace-format 214 | msgid "Playing \"{name}\" playlist" 215 | msgstr "" 216 | 217 | #: squeezealexa/main.py:309 218 | #, python-brace-format 219 | msgid "Couldn't find a playlist matching \"{name}\".How about the \"{suggestion}\" playlist?" 220 | msgstr "" 221 | 222 | #: squeezealexa/main.py:327 223 | #, python-brace-format 224 | msgid "Playing mix of {genres}" 225 | msgstr "" 226 | 227 | #: squeezealexa/main.py:330 228 | msgid "or" 229 | msgstr "" 230 | 231 | #: squeezealexa/main.py:331 232 | #, python-brace-format 233 | msgid "Don't understand requested genres {genres}" 234 | msgstr "" 235 | 236 | #: squeezealexa/main.py:333 237 | #, python-brace-format 238 | msgid "Can't find genres: {genres}" 239 | msgstr "" 240 | 241 | #: squeezealexa/main.py:384 242 | msgid "Hasta la vista. Baby." 243 | msgstr "" 244 | 245 | #: squeezealexa/utils.py:34 246 | msgid "and" 247 | msgstr "" 248 | 249 | #: squeezealexa/utils.py:104 250 | #, python-brace-format 251 | msgid "Failed \"{task}\", after {secs:.1f} seconds" 252 | msgstr "" 253 | 254 | #: squeezealexa/i18n.py:47 255 | msgid "favorites" 256 | msgstr "favourites" 257 | 258 | #: squeezealexa/squeezebox/server.py:102 259 | msgid "Uh-oh. No connected players found." 260 | msgstr "" 261 | 262 | #: squeezealexa/squeezebox/server.py:240 263 | msgid "Unknown player" 264 | msgstr "" 265 | -------------------------------------------------------------------------------- /tests/transport/test_ssl.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-19 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | from logging import getLogger 14 | from socket import error as SocketError 15 | from socket import socket 16 | from unittest import TestCase 17 | 18 | import pytest 19 | 20 | from squeezealexa.transport.base import Error as TransportError 21 | from squeezealexa.transport.ssl_wrap import SslSocketTransport 22 | from tests.transport.base import ServerResource, TimeoutServer, CertFiles, \ 23 | response_for 24 | 25 | log = getLogger("tests") 26 | 27 | 28 | class FailingSocket(socket): 29 | 30 | def __init__(self, *args, **kwargs): 31 | super().__init__(*args, **kwargs) 32 | self.was_closed = False 33 | 34 | def sendall(self, data, flags=None): 35 | log.info("Failing send") 36 | raise SocketError() 37 | 38 | def close(self): 39 | super().close() 40 | self.was_closed = True 41 | 42 | 43 | class TestSslTransport(TestCase): 44 | 45 | def _working_transport(self, server): 46 | return SslSocketTransport('', port=server.port, 47 | cert_file=CertFiles.CERT_AND_KEY, 48 | ca_file=CertFiles.CERT_AND_KEY) 49 | 50 | def test_with_real_server(self): 51 | with ServerResource() as server: 52 | sslw = self._working_transport(server) 53 | assert not sslw.is_connected 54 | sslw.start() 55 | assert sslw.is_connected 56 | response = sslw.communicate('HELLO') 57 | assert response == response_for("HELLO") 58 | 59 | def test_stop_real_server(self): 60 | with ServerResource() as server: 61 | t = self._working_transport(server) 62 | t.start() 63 | assert not t._ssl_sock._closed, "Shouldn't have closed socket" 64 | log.info("Started transport") 65 | t.stop() 66 | assert not t.is_connected 67 | assert t._ssl_sock._closed, "Should have closed socket" 68 | log.info("Finished test") 69 | 70 | def test_with_real_server_no_wait(self): 71 | with ServerResource() as server: 72 | sslw = self._working_transport(server) 73 | sslw.start() 74 | assert sslw.communicate('HELLO', wait=False) is None 75 | 76 | def test_with_real_server_failing_socket(self): 77 | with ServerResource() as server: 78 | transport = self._working_transport(server).start() 79 | transport._ssl_sock = FailingSocket() 80 | assert transport.is_connected 81 | assert not transport.communicate('HELLO') 82 | 83 | def test_failing_socket_raises_eventually(self): 84 | with ServerResource() as server: 85 | transport = self._working_transport(server).start() 86 | transport._ssl_sock = FailingSocket() 87 | assert transport.is_connected 88 | assert transport._MAX_FAILURES == 3 89 | assert not transport.communicate('HELLO') 90 | assert not transport.communicate('HELLO?') 91 | with pytest.raises(TransportError) as e: 92 | transport.communicate('HELLO??') 93 | assert "Too many Squeezebox failures" in str(e) 94 | assert transport._ssl_sock.was_closed 95 | 96 | def test_no_ca(self): 97 | with ServerResource() as server: 98 | with pytest.raises(TransportError) as exc: 99 | SslSocketTransport('', port=server.port, 100 | cert_file=CertFiles.CERT_AND_KEY).start() 101 | assert 'cert not trusted' in exc.value.message.lower() 102 | 103 | def test_cert_no_key(self): 104 | with pytest.raises(TransportError) as exc: 105 | t = SslSocketTransport('', port=0, cert_file=CertFiles.CERT_ONLY) 106 | t.start() 107 | assert 'include the private key' in exc.value.message.lower() 108 | 109 | def test_missing_cert(self): 110 | with pytest.raises(TransportError) as exc: 111 | SslSocketTransport('', port=0, cert_file="not.there", 112 | ca_file='ca.not.there').start() 113 | assert "ca 'ca.not.there'" in exc.value.message.lower() 114 | 115 | def test_bad_hostname(self): 116 | with pytest.raises(TransportError) as exc: 117 | SslSocketTransport('zzz.qqq', port=0).start() 118 | msg = exc.value.message.lower() 119 | assert "unknown host" in msg 120 | assert "zzz.qqq" in msg 121 | 122 | def test_cert_bad_hostname(self): 123 | with ServerResource() as server: 124 | with pytest.raises(TransportError) as exc: 125 | SslSocketTransport('', port=server.port, 126 | cert_file=CertFiles.BAD_HOSTNAME).start() 127 | assert 'right hostname' in exc.value.message.lower() 128 | 129 | def test_wrong_port(self): 130 | with ServerResource(tls=False) as server: 131 | with pytest.raises(TransportError) as exc: 132 | SslSocketTransport('localhost', port=server.port).start() 133 | msg = exc.value.message.lower() 134 | assert ('not tls on port %d' % server.port) in msg 135 | 136 | def test_bad_port(self): 137 | with pytest.raises(TransportError) as exc: 138 | SslSocketTransport('localhost', port=12345, 139 | cert_file=CertFiles.CERT_AND_KEY).start() 140 | message = exc.value.message.lower() 141 | assert 'nothing listening on localhost:12345' in message 142 | 143 | def test_timeout(self): 144 | with TimeoutServer() as server: 145 | with pytest.raises(TransportError) as exc: 146 | SslSocketTransport('localhost', port=server.port, 147 | cert_file=CertFiles.CERT_AND_KEY, 148 | ca_file=CertFiles.CERT_AND_KEY, 149 | timeout=1).start() 150 | assert "check the server setup and the firewall" in str(exc) 151 | -------------------------------------------------------------------------------- /tests/transport/mqtt_integration_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-19 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | import asyncio 14 | from _ssl import PROTOCOL_TLSv1_2 15 | from asyncio import ensure_future, IncompleteReadError 16 | from datetime import datetime 17 | from logging import getLogger 18 | from threading import Thread 19 | from time import time 20 | 21 | import pytest 22 | from hbmqtt.broker import Broker 23 | from paho.mqtt.client import MQTT_ERR_INVAL, MQTTMessage, Client 24 | 25 | from squeezealexa.settings import MqttSettings 26 | from squeezealexa.transport.factory import TransportFactory 27 | from squeezealexa.transport.mqtt import CustomClient 28 | from squeezealexa.utils import wait_for 29 | from tests.transport.base import CertFiles 30 | from tests.utils import TEST_DATA_DIR 31 | 32 | TEST_MSG = "TEST MESSAGE at %s" % datetime.now() 33 | MQTT_LISTEN_PORT = 8883 34 | BROKER_CONFIG = { 35 | 'listeners': { 36 | 'default': { 37 | 'type': 'tcp', 38 | 'ssl': 'on', 39 | 'capath': CertFiles.LOCALHOST_CERT_AND_KEY, 40 | 'certfile': CertFiles.LOCALHOST_CERT_AND_KEY, 41 | 'keyfile': CertFiles.LOCALHOST_CERT_AND_KEY, 42 | 'bind': '0.0.0.0:%d' % MQTT_LISTEN_PORT, 43 | } 44 | } 45 | } 46 | 47 | log = getLogger("tests") 48 | 49 | 50 | class CustomTlsCustomClient(CustomClient): 51 | 52 | def __init__(self, settings: MqttSettings, 53 | on_publish=None, on_connect=None, on_subscribe=None, 54 | on_message=None): 55 | super().__init__(settings) 56 | self.on_connect = on_connect 57 | self.on_subscribe = on_subscribe 58 | self.on_publish = on_publish 59 | self.on_message = on_message 60 | self.connections = 0 61 | 62 | def _configure_tls(self): 63 | self.tls_set(ca_certs=CertFiles.LOCALHOST_CERT_AND_KEY, 64 | tls_version=PROTOCOL_TLSv1_2) 65 | 66 | def connect(self, host=None, port=None, keepalive=30, bind_address=""): 67 | self.connections += 1 68 | return super().connect(host, port, keepalive, bind_address) 69 | 70 | 71 | class QuietBroker(Broker): 72 | @asyncio.coroutine 73 | def stream_connected(self, reader, writer, listener_name): 74 | try: 75 | yield from super().stream_connected(reader, writer, listener_name) 76 | except IncompleteReadError as e: 77 | log.warning("Broker says: %s" % e) 78 | # It's annoying. https://github.com/beerfactory/hbmqtt/issues/119 79 | 80 | 81 | class BrokerThread(Thread): 82 | """Thread to manage the asyncio-based HBMQTT MQTT Broker""" 83 | def __init__(self, broker, loop): 84 | super().__init__() 85 | self.loop = loop 86 | self.broker = broker 87 | 88 | async def run_loop(self): 89 | await self.broker.start() 90 | log.info("Started broker: %s", self.broker.config) 91 | 92 | def run(self): 93 | """Switch to new event loop and run forever""" 94 | log.info("Starting threaded event loop") 95 | asyncio.set_event_loop(self.loop) 96 | future = ensure_future(self.run_loop(), loop=self.loop) 97 | self.loop.create_task(future) 98 | self.loop.run_forever() 99 | 100 | def stop(self): 101 | self.loop.stop() 102 | 103 | def join(self, timeout=None): 104 | log.info("Stopping thread...") 105 | self.broker.shutdown() 106 | self.loop.stop() 107 | super().join(timeout) 108 | 109 | 110 | @pytest.fixture 111 | def mqtt_settings() -> MqttSettings: 112 | uid = time() 113 | return MqttSettings( 114 | hostname='localhost', port=MQTT_LISTEN_PORT, 115 | cert_dir=TEST_DATA_DIR, 116 | topic_req="squeeze-req-%s" % uid, 117 | topic_resp="squeeze-resp-%s" % uid) 118 | 119 | 120 | @pytest.fixture 121 | def client(mqtt_settings): 122 | client = CustomTlsCustomClient(mqtt_settings) 123 | yield client 124 | client.loop_stop() 125 | 126 | 127 | @pytest.fixture 128 | def transport(mqtt_settings, client): 129 | log.info("Creating transport for %s", client) 130 | factory = TransportFactory(ssl_config=None, mqtt_settings=mqtt_settings) 131 | transport = factory.create(mqtt_client=client) 132 | yield transport 133 | transport.stop() 134 | 135 | 136 | @pytest.fixture(scope="module") 137 | def broker(): 138 | worker_loop = asyncio.new_event_loop() 139 | broker = QuietBroker(BROKER_CONFIG, plugin_namespace='tests', 140 | loop=worker_loop) 141 | worker = BrokerThread(broker, worker_loop) 142 | worker.start() 143 | yield broker 144 | worker.stop() 145 | worker.join() 146 | 147 | 148 | class TestLiveMqttTransport: 149 | 150 | def test_real_publishing(self, mqtt_settings, client, broker, transport): 151 | log.info("Broker running: %s", broker) 152 | self.published = [] 153 | self.subscribed = False 154 | 155 | def on_message(client: Client, userdata, msg: MQTTMessage): 156 | msg = msg.payload.decode('utf-8').strip() 157 | client.publish(mqtt_settings.topic_resp, 158 | "GOT: {m}".format(m=msg).encode('utf-8')) 159 | 160 | def on_subscribe(client, data, mid, granted_qos): 161 | self.subscribed = True 162 | 163 | def on_publish(client, userdata, mid): 164 | self.published.append(mid) 165 | 166 | client.on_publish = on_publish 167 | replier = CustomTlsCustomClient(mqtt_settings, 168 | on_subscribe=on_subscribe, 169 | on_message=on_message) 170 | replier.connect() 171 | transport.start() 172 | replier.subscribe(mqtt_settings.topic_req) 173 | assert replier.loop_start() != MQTT_ERR_INVAL 174 | wait_for(lambda x: self.subscribed, 175 | what="confirming subscription", timeout=3) 176 | reply = transport.communicate(TEST_MSG, timeout=3) 177 | wait_for(lambda x: self.published, 178 | what="confirming publish", timeout=3) 179 | assert len(self.published) == 1 180 | log.debug("Received reply: %s", reply) 181 | assert reply == "GOT: {msg}".format(msg=TEST_MSG) 182 | 183 | def test_over_connect(self, broker, client, transport): 184 | transport.start() 185 | wait_for(lambda t: t.is_connected, context=transport) 186 | transport.start() 187 | transport.start() 188 | assert client.connections == 1, "Over connected to MQTT" 189 | -------------------------------------------------------------------------------- /squeezealexa/transport/mqtt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2018-19 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | import os 14 | import ssl 15 | from _ssl import PROTOCOL_TLSv1_2 16 | from glob import glob 17 | from os.path import dirname, realpath, join 18 | from typing import Union 19 | 20 | from paho.mqtt.client import Client, MQTT_ERR_SUCCESS, error_string, \ 21 | MQTT_ERR_INVAL, MQTT_ERR_NO_CONN 22 | 23 | from squeezealexa.settings import MqttSettings 24 | from squeezealexa.transport.base import Transport, Error, check_listening 25 | from squeezealexa.utils import print_d, wait_for 26 | 27 | BASE = realpath(join(dirname(__file__), "..", "..")) 28 | 29 | 30 | class CustomClient(Client): 31 | """Opinionated Client subclass that configures from passed settings, 32 | and also does some safety checks""" 33 | 34 | def __init__(self, settings: MqttSettings): 35 | super().__init__() 36 | # self._keepalive = 5 37 | self.settings = settings 38 | self._host = settings.hostname 39 | self._port = settings.port 40 | self._configure_tls() 41 | self.connected = False 42 | 43 | def _configure_tls(self): 44 | self.tls_set(certfile=self._conf_file_of("*-certificate.pem.crt"), 45 | keyfile=self._conf_file_of("*-private.pem.key"), 46 | tls_version=PROTOCOL_TLSv1_2) 47 | 48 | def connect(self, host=None, port=None, keepalive=30, bind_address=""): 49 | print_d("Connecting {client}...", client=self) 50 | host = host or self._host 51 | port = port or self._port 52 | 53 | check_listening(host, port, msg="check your MQTT settings") 54 | print_d("Remote socket is listening, let's continue.") 55 | try: 56 | ret = super().connect(host=host, 57 | port=port, 58 | keepalive=keepalive, 59 | bind_address=bind_address) 60 | except ssl.SSLError as e: 61 | if 'SSLV3_ALERT_CERTIFICATE_UNKNOWN' in str(e): 62 | raise Error("Certificate problem with MQTT. " 63 | "Is the certificate enabled in AWS?") 64 | else: 65 | if ret == MQTT_ERR_SUCCESS: 66 | print_d("Connected to {settings}", settings=self.settings) 67 | self.connected = True 68 | return ret 69 | raise Error("Couldn't connect to {settings}".format( 70 | settings=self.settings)) 71 | 72 | def disconnect(self): 73 | ret = super().disconnect() 74 | self.connected = False 75 | if ret != MQTT_ERR_SUCCESS and ret != MQTT_ERR_NO_CONN: 76 | raise Error("Failed to disconnect (%s)" % error_string(ret)) 77 | return ret 78 | 79 | def _conf_file_of(self, rel_glob: str) -> str: 80 | full_glob = os.path.join(self.settings.cert_dir, rel_glob) 81 | results = glob(full_glob) 82 | try: 83 | return results[0] 84 | except IndexError: 85 | raise Error("Can't find {glob} within dir {base}".format( 86 | base=self.settings.cert_dir, glob=rel_glob)) 87 | 88 | def __del__(self): 89 | print_d("Disconnecting {what}", what=self) 90 | self.disconnect() 91 | self.loop_stop() 92 | 93 | def __str__(self) -> str: 94 | return "client to {host}:{port}".format(host=self._host, 95 | port=self._port) 96 | 97 | 98 | class MqttTransport(Transport): 99 | """Transport over TLS-encrypted MQTT""" 100 | 101 | def __init__(self, client: CustomClient, req_topic: str, resp_topic: str): 102 | def subscribed(client, userdata, mind, granted_qos): 103 | self.is_connected = True 104 | print_d("MQTT/TLS transport to {client} initialised. (@QoS {qos})", 105 | client=client, qos=granted_qos) 106 | 107 | super().__init__() 108 | self.client = client 109 | self.req_topic = req_topic 110 | self.resp_topic = resp_topic 111 | self.client.on_subscribe = subscribed 112 | self.client.on_message = self._on_message 113 | self.message = [] 114 | print_d("Created transport: {self!r}", self=self) 115 | 116 | def start(self): 117 | def connected(client, userdata, flags, rc): 118 | print_d("Connected to {client}. Subscribing to {topic}", 119 | client=self.client, topic=self.resp_topic) 120 | result, mid = self.client.subscribe(self.resp_topic, qos=1) 121 | if result != MQTT_ERR_SUCCESS: 122 | raise Error("Couldn't subscribe to '{topic}'", self.resp_topic) 123 | 124 | def disconnected(client, userdata, rc): 125 | print_d("Disconnected from {client}", client=self.client) 126 | self.is_connected = False 127 | 128 | self.is_connected = self.client.connected 129 | if self.is_connected: 130 | print_d("Already connected, great!") 131 | return 132 | self.client.on_connect = connected 133 | self.client.on_disconnect = disconnected 134 | assert self.client.loop_start() != MQTT_ERR_INVAL 135 | self.client.connect() 136 | wait_for(lambda s: s.is_connected, what="connection", context=self) 137 | return self 138 | 139 | def _on_message(self, client, userdata, message): 140 | self.response_lines += message.payload.splitlines() 141 | 142 | @property 143 | def details(self): 144 | return "MQTT to {client}".format(client=self.client) 145 | 146 | def communicate(self, raw: str, wait=True, timeout=5) -> Union[str, None]: 147 | data = raw.strip() + '\n' 148 | num_lines = data.count('\n') 149 | self._clear() 150 | ret = self.client.publish(self.req_topic, data.encode('utf-8'), 151 | qos=1 if wait else 0) 152 | if not wait: 153 | return None 154 | ret.wait_for_publish() 155 | if ret.rc != MQTT_ERR_SUCCESS: 156 | msg = "Error publishing message: {err}".format( 157 | err=error_string(ret.rc)) 158 | raise Error(msg) 159 | print_d("Published to '{topic}' OK. Waiting for {num} line(s).", 160 | topic=self.req_topic, num=num_lines) 161 | 162 | wait_for(lambda s: len(s.response_lines) >= num_lines, context=self, 163 | what="response from mqtt-squeeze", timeout=timeout, 164 | exc_cls=Error) 165 | return "\n".join(m.decode('utf-8') for m in self.response_lines) 166 | 167 | def _clear(self): 168 | self.response_lines = [] 169 | 170 | def stop(self): 171 | print_d("Killing {what}.", what=self) 172 | self.client.on_message = None 173 | self.client.on_subscribe = None 174 | # Don't unsubscribe-and-run (disconnect). Causes issues with brokers. 175 | # self.client.unsubscribe(self.resp_topic) 176 | self.client.disconnect() 177 | return super().stop() 178 | 179 | def __del__(self): 180 | self.stop() 181 | -------------------------------------------------------------------------------- /squeezealexa/transport/ssl_wrap.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-2018 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | import socket 14 | 15 | import ssl 16 | import _ssl 17 | from typing import Optional 18 | 19 | from squeezealexa.transport.base import Error, Transport 20 | from squeezealexa.utils import print_d, print_w 21 | 22 | 23 | class SslSocketTransport(Transport): 24 | _MAX_FAILURES = 3 25 | 26 | def __init__(self, hostname, port=9090, ca_file=None, cert_file=None, 27 | verify_hostname=False, timeout=5): 28 | 29 | super().__init__() 30 | self.hostname = hostname 31 | self.port = port 32 | self.timeout = timeout 33 | self.failures = 0 34 | context = ssl.SSLContext(_ssl.PROTOCOL_TLS_CLIENT) 35 | self.__harden_context(context) 36 | try: 37 | if ca_file: 38 | context.load_verify_locations(ca_file) 39 | if cert_file: 40 | context.verify_mode = ssl.CERT_REQUIRED 41 | context.check_hostname = verify_hostname 42 | context.load_cert_chain(cert_file) 43 | except ssl.SSLError as e: 44 | self._die("Problem with Cert / CA (+key) files ({cert} / {ca}). " 45 | "Does it include the private key? ({reason})", 46 | cert=cert_file, ca=ca_file, reason=e.reason, err=e) 47 | except IOError as e: 48 | if 'No such file or directory' in e.strerror: 49 | self._die("Can't find cert '{cert_file}' or CA '{ca_file}'. " 50 | "Check CERT_FILE / CA_FILE_PATH in settings", 51 | ca_file=ca_file, cert_file=cert_file) 52 | self._die("could be mismatched certificate files, " 53 | "or wrong hostname in cert." 54 | "Check CERT_FILE and certs on server too.", e) 55 | 56 | sock = socket.socket() 57 | sock.settimeout(self.timeout) 58 | self._ssl_sock = context.wrap_socket(sock, server_hostname=hostname) 59 | 60 | def start(self): 61 | print_d("Connecting to port {port} on {hostname}", 62 | port=self.port, hostname=self.hostname or '(localhost)') 63 | try: 64 | self._ssl_sock.connect((self.hostname, self.port)) 65 | except socket.gaierror as e: 66 | if "Name or service not know" in e.strerror: 67 | self._die("unknown host ({host}) - check SERVER_HOSTNAME", 68 | host=self.hostname, err=e) 69 | self._die("Couldn't connect to {host} with TLS", host=self, err=e) 70 | except IOError as e: 71 | err_str = e.strerror or str(e) 72 | if 'Connection refused' in err_str: 73 | self._die("nothing listening on {this}. " 74 | "Check settings, or (re)start server.", this=self) 75 | elif ('WRONG_VERSION_NUMBER' in err_str or 76 | 'unknown_protocol' in err_str): 77 | self._die('probably not TLS on port {port} - ' 78 | 'wrong SERVER_PORT maybe?', port=self.port, err=e) 79 | elif 'Connection reset by peer' in err_str: 80 | self._die("server killed the connection - handshake error " 81 | "(e.g. unsupported TLS protocol)? " 82 | "Check the SSL tunnel logs") 83 | elif 'CERTIFICATE_VERIFY_FAILED' in err_str: 84 | self._die("Cert not trusted by / from server. " 85 | "Is your CA correct? Is the cert expired? " 86 | "Is the cert for the right hostname ({host})?", 87 | host=self.hostname, err=e) 88 | elif 'timed out' in err_str: 89 | self._die("Couldn't connect to port {port} on {host} - " 90 | "check the server setup and the firewall.", 91 | host=self.hostname, port=self.port) 92 | self._die("Connection problem ({type}: {text})", 93 | type=type(e).__name__, text=err_str) 94 | 95 | peer_cert = self._ssl_sock.getpeercert() 96 | if peer_cert is None: 97 | self._die("No certificate configured at {details}", details=self) 98 | elif not peer_cert: 99 | print_w("Unvalidated server cert at {details}", details=self) 100 | else: 101 | subject_data = peer_cert['subject'] 102 | try: 103 | data = {k: v for d in subject_data for k, v in d} 104 | except Exception: 105 | data = subject_data 106 | print_d("Validated cert for {data}", data=data) 107 | self.is_connected = True 108 | return self 109 | 110 | def _die(self, msg, err=None, **kwargs): 111 | raise Error(msg.format(**kwargs), err) 112 | 113 | @staticmethod 114 | def __harden_context(context): 115 | # disallow ciphers with known vulnerabilities 116 | context.set_ciphers(ssl._RESTRICTED_SERVER_CIPHERS) 117 | # Prefer the server's ciphers by default so that we get stronger 118 | # encryption 119 | context.options |= _ssl.OP_CIPHER_SERVER_PREFERENCE 120 | # Use single use keys in order to improve forward secrecy 121 | context.options |= _ssl.OP_SINGLE_DH_USE 122 | context.options |= _ssl.OP_SINGLE_ECDH_USE 123 | # Deny outdated protocols 124 | context.options |= _ssl.OP_NO_SSLv2 125 | context.options |= _ssl.OP_NO_SSLv3 126 | context.options |= _ssl.OP_NO_TLSv1 127 | 128 | def communicate(self, raw: str, wait=True) -> Optional[str]: 129 | eof = False 130 | response = '' 131 | data = raw.strip() + '\n' 132 | num_lines = data.count('\n') 133 | try: 134 | self._ssl_sock.sendall(data.encode('utf-8')) 135 | if not wait: 136 | return None 137 | while not eof: 138 | response += self._ssl_sock.recv().decode('utf-8') 139 | eof = response.count("\n") == num_lines or not response 140 | return response 141 | except socket.error as e: 142 | if 'read operation timed out' in str(e): 143 | raise Error("Timed out waiting for CLI response. " 144 | "Perhaps the tunnel endpoint is incorrect, " 145 | "or the LMS CLI is down?") 146 | else: 147 | print_d("Couldn't communicate with Squeezebox ({error!r})", 148 | error=e) 149 | self.failures += 1 150 | if self.failures >= self._MAX_FAILURES: 151 | self.stop() 152 | raise Error("Too many Squeezebox failures. Disconnecting") 153 | return None 154 | 155 | @property 156 | def details(self): 157 | return "{hostname}:{port} over SSL".format(**self.__dict__) 158 | 159 | def stop(self): 160 | if hasattr(self, '_ssl_sock') and not self._ssl_sock._closed: 161 | sock = self._ssl_sock 162 | print_d("Shutting down {who} ({sock})", who=self, sock=sock) 163 | try: 164 | # See https://stackoverflow.com/questions/409783 165 | sock.shutdown(socket.SHUT_RDWR) 166 | except OSError as e: 167 | print_w("Can't shut down socket: {err}", err=e) 168 | sock.close() 169 | return super().stop() 170 | 171 | def __del__(self): 172 | self.stop() 173 | -------------------------------------------------------------------------------- /tests/integration_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-18 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | from pprint import pprint 14 | from typing import Any, Dict 15 | from unittest import TestCase 16 | 17 | from squeezealexa.main import SqueezeAlexa 18 | from squeezealexa.squeezebox.server import Server 19 | from tests.transport.fake_transport import FakeTransport 20 | from tests.utils import GENRES 21 | 22 | MULTI_ARTIST_STATUS = """ tags%3AAlG player_name%3AStudy player_connected%3A1 23 | player_ip%3A192.168.1.40%3A50556 power%3A1 signalstrength%3A0 mode%3Aplay 24 | time%3A13.8465571918488 rate%3A1 duration%3A281.566 can_seek%3A1 25 | sync_master%3A40%3A16%3A7e%3Aad%3A87%3A07 sync_slaves%3A00%3A04%3A20%3A17%3A6f 26 | %3Ad1%2C00%3A04%3A20%3A17%3Ade%3Aa0%2C00%3A04%3A20%3A17%3A5c%3A94 27 | mixer%20volume%3A86 playlist%20repeat%3A0 playlist%20shuffle%3A2 28 | playlist%20mode%3Aoff seq_no%3A0 playlist_cur_index%3A0 29 | playlist_timestamp%3A1538824028.72799 playlist_tracks%3A1 30 | digital_volume_control%3A1 playlist%20index%3A0 id%3A12919 31 | title%3AShut%20'Em%20Down artist%3APublic%20Enemy%2C%20Pete%20Rock 32 | album%3ASingles%20N'%20Remixes%201987-1992 33 | genres%3AHip-Hop""".replace('\n', '') 34 | 35 | CLASSICAL_STATUS = """tags%3AAlG player_name%3AStudy player_connected%3A1 36 | player_ip%3A192.168.1.40%3A51878 power%3A1 signalstrength%3A0 mode%3Aplay 37 | time%3A19.720863161087 rate%3A1 duration%3A548 can_seek%3A1 38 | sync_master%3A40%3A16%3A7e%3Aad%3A87%3A07 39 | sync_slaves%3A00%3A04%3A20%3A17%3A6f%3Ad1%2C00%3A04%3A20%3A17%3Ade%3Aa0%2C00 40 | %3A04%3A20%3A17%3A5c%3A94 mixer%20volume%3A86 playlist%20repeat%3A0 41 | playlist%20shuffle%3A2 playlist%20mode%3Aoff seq_no%3A0 42 | playlist_cur_index%3A0 playlist_timestamp%3A1538824933.95403 43 | playlist_tracks%3A27 digital_volume_control%3A1 playlist%20index%3A0 44 | id%3A10083 title%3AKyrie%20Eleison artist%3ANo%20Artist 45 | composer%3AJohann%20Sebastian%20Bach conductor%3ADiego%20Fasolis 46 | album%3AMass%20in%20B%20minor%20BWV%20232 genres%3AClassical 47 | """.replace('\n', '') 48 | 49 | SOME_PID = "zz:zz:zz" 50 | FAKE_ID = "ab:cd:ef:gh" 51 | A_PLAYLIST = 'Moody Bluez' 52 | 53 | 54 | def resp(text: str, pid: str = FAKE_ID) -> str: 55 | return ' '.join([pid, text]) 56 | 57 | 58 | class FakeSqueeze(Server): 59 | 60 | def __init__(self): 61 | self.lines = [] 62 | self.players = {} 63 | self._debug = False 64 | self.cur_player_id = FAKE_ID 65 | self._genres = [] 66 | self._playlists = [] 67 | self.transport = FakeTransport() 68 | 69 | @property 70 | def genres(self): 71 | return self._genres 72 | 73 | @property 74 | def playlists(self): 75 | return self._playlists 76 | 77 | def _request(self, lines, raw=False, wait=True): 78 | if self._debug: 79 | pprint(lines) 80 | self.lines += lines 81 | return lines 82 | 83 | 84 | def one_slot_intent(slot: str, value: Any) -> Dict[str, Any]: 85 | return {'slots': {slot: {'name': slot, 86 | 'value': str(value), 87 | 'confirmationStatus': 'NONE'}}} 88 | 89 | 90 | def speech_in(response): 91 | return response['response']['outputSpeech']['text'] 92 | 93 | 94 | class IntegrationTests(TestCase): 95 | 96 | def setUp(self): 97 | super(IntegrationTests, self).setUp() 98 | self.stub = FakeSqueeze() 99 | self.alexa = SqueezeAlexa(server=self.stub) 100 | 101 | def test_on_pause_resume(self): 102 | intent = {} 103 | self.alexa.on_pause(intent, None) 104 | self.alexa.on_resume(intent, None) 105 | assert self.stub.lines == [resp('pause 1'), resp('pause 0 1')] 106 | 107 | def test_on_pause_resume_player_id(self): 108 | intent = {} 109 | self.alexa.on_pause(intent, None, pid=SOME_PID) 110 | self.alexa.on_resume(intent, None, pid=SOME_PID) 111 | assert self.stub.lines == [resp('pause 1', pid=SOME_PID), 112 | resp('pause 0 1', pid=SOME_PID)] 113 | 114 | def test_on_random_mix_trickier(self): 115 | self.stub._genres = GENRES 116 | intent = {'slots': {'primaryGenre': {'value': 'Jungle band Blues'}, 117 | 'secondaryGenre': {'value': 'House'}}} 118 | 119 | response = self.alexa.on_play_random_mix(intent, None) 120 | assert self.stub.lines[-1] == resp('play 2') 121 | content = response['response']['card']['content'] 122 | assert content.startswith('Playing mix of') 123 | assert 'Jungle' in content 124 | assert 'House' in content 125 | # 3 = reset genres, clear, play. 4 = 2 + 2 126 | assert len(self.stub.lines) <= 4 + 3 127 | 128 | def test_on_playlist_play_without_playlists(self): 129 | intent = one_slot_intent('Playlist', 'Moody Blues') 130 | response = self.alexa.on_play_playlist(intent, FAKE_ID) 131 | speech = speech_in(response) 132 | assert "No Squeezebox playlists" in speech 133 | 134 | def test_play_unknown_playlists(self): 135 | self.stub._playlists = ['Black Friday', A_PLAYLIST, 'Happy Mondays'] 136 | intent = one_slot_intent('Playlist', 'Not here') 137 | response = self.alexa.on_play_playlist(intent, FAKE_ID) 138 | speech = speech_in(response) 139 | assert "Couldn't find a playlist matching \"Not here\"" in speech 140 | 141 | def test_on_playlist_play(self): 142 | self.stub._playlists = ['Black Friday', A_PLAYLIST, 'Happy Mondays'] 143 | intent = one_slot_intent('Playlist', 'Mood Blues') 144 | 145 | response = self.alexa.on_play_playlist(intent, FAKE_ID) 146 | last_cmd = self.stub.lines[-1] 147 | assert last_cmd.startswith(resp('playlist resume %s' 148 | % A_PLAYLIST.replace(' ', '%20'))) 149 | content = response['response']['card']['content'] 150 | assert content.startswith('Playing "%s" playlist' % A_PLAYLIST) 151 | assert len(self.stub.lines) <= 4 + 3 152 | 153 | def test_set_invalid_volume(self): 154 | intent = one_slot_intent('Volume', 11) 155 | response = self.alexa.on_set_vol(intent, FAKE_ID) 156 | speech = speech_in(response) 157 | assert " between 0 and 10" in speech.lower() 158 | 159 | def test_set_invalid_percent_volume(self): 160 | intent = one_slot_intent('Volume', 999) 161 | response = self.alexa.on_set_vol_percent(intent, FAKE_ID) 162 | speech = speech_in(response) 163 | assert " between 0 and 100" in speech.lower() 164 | 165 | 166 | class TestNowPlaying(TestCase): 167 | 168 | def test_commas_in_title(self): 169 | fake_output = FakeTransport().start() 170 | server = Server(transport=fake_output) 171 | alexa = SqueezeAlexa(server=server) 172 | resp = alexa.now_playing([], None) 173 | speech = speech_in(resp) 174 | assert "I Think, I Love" in speech 175 | assert "by Jamie Cullum" in speech 176 | 177 | def test_multiple_artists(self): 178 | fake_output = FakeTransport(fake_status=MULTI_ARTIST_STATUS).start() 179 | server = Server(transport=fake_output) 180 | alexa = SqueezeAlexa(server=server) 181 | resp = alexa.now_playing([], None) 182 | speech = speech_in(resp) 183 | assert '"Shut \'Em Down"' in speech 184 | assert "by Public Enemy and Pete Rock" in speech 185 | 186 | def test_classical(self): 187 | fake_output = FakeTransport(fake_status=CLASSICAL_STATUS).start() 188 | server = Server(transport=fake_output) 189 | alexa = SqueezeAlexa(server=server) 190 | resp = alexa.now_playing([], None) 191 | speech = speech_in(resp) 192 | assert '"Kyrie Eleison"' in speech 193 | assert "by Johann Sebastian Bach" in speech 194 | -------------------------------------------------------------------------------- /locale/fr_FR/LC_MESSAGES/squeeze-alexa.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Language: fr_FR\n" 4 | "Content-Type: text/plain; charset=UTF-8\n" 5 | 6 | #: squeezealexa/main.py:75 7 | msgid "Squeezebox is online. Please try some commands." 8 | msgstr "Squeezebox est en ligne. Merci d'essayer quelques commandes." 9 | 10 | #: squeezealexa/main.py:76 11 | msgid "Try resume, pause, next, previous, play some jazz, or ask Squeezebox to turn it up or down" 12 | msgstr "" 13 | "Essayez reprise, pause, suivant, précédent, joue un peu de jazz ou demandez à Squeezebox de monter ou baisser le son" 14 | 15 | #: squeezealexa/main.py:91 16 | #, python-brace-format 17 | msgid "Sorry, I don't know how to process a \"{intent}\"" 18 | msgstr "Désolé, Je ne sais pas comment traiter un \"{intent}\"" 19 | 20 | #: squeezealexa/main.py:93 21 | #, python-brace-format 22 | msgid "Unknown intent: '{intent}'" 23 | msgstr "Intention inconnue: '{intent}'" 24 | 25 | #: squeezealexa/main.py:109 26 | msgid "Rewind!" 27 | msgstr "Rembobinage !" 28 | 29 | #: squeezealexa/main.py:114 30 | msgid "Yep, pretty lame." 31 | msgstr "Ouais, c'est plutôt nul." 32 | 33 | #: squeezealexa/main.py:122 34 | #, python-brace-format 35 | msgid "Currently playing: \"{title}\"" 36 | msgstr "En train de jouer : \"{title}\"" 37 | 38 | #: squeezealexa/main.py:124 39 | #, fuzzy, python-brace-format 40 | msgid ", by {artists}." 41 | msgstr ", par {artist}" 42 | 43 | #: squeezealexa/main.py:126 44 | #, python-brace-format 45 | msgid "Now playing: \"{title}\"" 46 | msgstr "En cours de lecture : \"{title}\"" 47 | 48 | #: squeezealexa/main.py:128 49 | msgid "Nothing playing." 50 | msgstr "Aucune lecture en cours." 51 | 52 | #: squeezealexa/main.py:139 squeezealexa/main.py:143 53 | msgid "Select a volume value between 0 and 10" 54 | msgstr "Sélectionnez une valeur de volume entre 0 et 10" 55 | 56 | #: squeezealexa/main.py:140 57 | msgid "Invalid volume value" 58 | msgstr "Valeur de volume invalide" 59 | 60 | #: squeezealexa/main.py:144 61 | #, python-brace-format 62 | msgid "Volume value out of range: {volume}" 63 | msgstr "Valeur de volume hors plage : {volume}" 64 | 65 | #: squeezealexa/main.py:150 66 | #, python-brace-format 67 | msgid "Set volume to {volume}" 68 | msgstr "Régler le volume sur {volume}" 69 | 70 | #: squeezealexa/main.py:162 71 | msgid "Select a volume between 0 and 100 percent" 72 | msgstr "Sélectionnez un volume entre 0 et 100 pourcent" 73 | 74 | #: squeezealexa/main.py:163 75 | msgid "Invalid volume" 76 | msgstr "Volume invalide" 77 | 78 | #: squeezealexa/main.py:166 79 | msgid "Select a volume value between 0 and 100 percent" 80 | msgstr "Sélectionnez une valeur de volume entre 0 et 100 pourcent" 81 | 82 | #: squeezealexa/main.py:167 83 | #, python-brace-format 84 | msgid "Volume value out of range: {volume} percent" 85 | msgstr "Valeur de volume hors plage : {volume} pourcent" 86 | 87 | #: squeezealexa/main.py:171 88 | msgid "OK" 89 | msgstr "OK" 90 | 91 | #: squeezealexa/main.py:172 92 | #, python-brace-format 93 | msgid "Set volume to {percent} percent" 94 | msgstr "Régler le volume sur {percent} pourcent" 95 | 96 | #: squeezealexa/main.py:178 97 | msgid "Increase Volume" 98 | msgstr "Augmente le volume" 99 | 100 | #: squeezealexa/main.py:179 101 | msgid "Pumped it up." 102 | msgstr "Augmente le." 103 | 104 | #: squeezealexa/main.py:184 105 | msgid "Decrease Volume" 106 | msgstr "Baisse le volume" 107 | 108 | #: squeezealexa/main.py:185 109 | msgid "OK, quieter now." 110 | msgstr "OK, plus faible maintenant." 111 | 112 | #: squeezealexa/main.py:197 113 | #, python-brace-format 114 | msgid "Selected {player}" 115 | msgstr "{player} sélectionné" 116 | 117 | #: squeezealexa/main.py:198 118 | #, python-brace-format 119 | msgid "Selected player {player}" 120 | msgstr "Lecteur {player} sélectionné" 121 | 122 | #: squeezealexa/main.py:201 123 | #, python-brace-format 124 | msgid "I only found these players: {players}. Could you try again?" 125 | msgstr "Je n'ai trouvé que ces lecteurs : {players}. Pouvez-vous essayer à nouveau ?" 126 | 127 | #: squeezealexa/main.py:204 128 | #, python-brace-format 129 | msgid "You can select a player by saying \"{utterance}\" and then the player name." 130 | msgstr "Vous pouvez sélectionner un lecteur en disant \"{utterance}\" et le nom du lecteur." 131 | 132 | #: squeezealexa/main.py:209 133 | #, python-brace-format 134 | msgid "No player called \"{name}\"" 135 | msgstr "Aucun lecteur nommé \"{name}\"" 136 | 137 | #: squeezealexa/main.py:219 138 | msgid "Shuffle on" 139 | msgstr "Lecture aléatoire activée" 140 | 141 | #: squeezealexa/main.py:220 142 | msgid "Shuffle is now on" 143 | msgstr "La lecture aléatoire est maintenant activée" 144 | 145 | #: squeezealexa/main.py:226 146 | msgid "Shuffle off" 147 | msgstr "Lecture aléatoire désactivée" 148 | 149 | #: squeezealexa/main.py:227 150 | msgid "Shuffle is now off" 151 | msgstr "La lecture aléatoire est maintenant désactivée" 152 | 153 | #: squeezealexa/main.py:233 154 | msgid "Repeat on" 155 | msgstr "Répétition activée" 156 | 157 | #: squeezealexa/main.py:234 158 | msgid "Repeat is now on" 159 | msgstr "La répétition est maintenant activée" 160 | 161 | #: squeezealexa/main.py:240 162 | msgid "Repeat Off" 163 | msgstr "Répétition désactivée" 164 | 165 | #: squeezealexa/main.py:241 166 | msgid "Repeat is now off" 167 | msgstr "La répétition est maintenant désactivée" 168 | 169 | #: squeezealexa/main.py:250 170 | #, python-brace-format 171 | msgid "Switched {player} off" 172 | msgstr "Extinction de {player}" 173 | 174 | #: squeezealexa/main.py:251 175 | #, python-brace-format 176 | msgid "{player} is now off" 177 | msgstr "{player} est maintenant éteint" 178 | 179 | #: squeezealexa/main.py:265 180 | #, python-brace-format 181 | msgid "Switched {player} on" 182 | msgstr "Allumage de {player}" 183 | 184 | #: squeezealexa/main.py:271 185 | msgid "Players all off" 186 | msgstr "Les lecteurs sont tous éteints" 187 | 188 | #: squeezealexa/main.py:272 189 | msgid "Silence." 190 | msgstr "Silence." 191 | 192 | #: squeezealexa/main.py:277 193 | msgid "All On." 194 | msgstr "Tous allumés." 195 | 196 | #: squeezealexa/main.py:278 197 | msgid "Ready to rock" 198 | msgstr "Ready to rock" 199 | 200 | #: squeezealexa/main.py:289 201 | msgid "There are no playlists" 202 | msgstr "Il n'y a pas de listes de lecture" 203 | 204 | #: squeezealexa/main.py:291 205 | #, python-brace-format 206 | msgid "Didn't hear a playlist there. You could try the \"{name}\" playlist?" 207 | msgstr "Je n'ai pas entendu de liste de lecture. Voulez-vous essayer la liste de lecture \"{name}\" ?" 208 | 209 | #: squeezealexa/main.py:297 210 | msgid "No Squeezebox playlists found" 211 | msgstr "Aucune liste de lecture Squeezebox trouvée" 212 | 213 | #: squeezealexa/main.py:306 squeezealexa/main.py:307 214 | #, python-brace-format 215 | msgid "Playing \"{name}\" playlist" 216 | msgstr "Joue la liste de lecture \"{name}\"" 217 | 218 | #: squeezealexa/main.py:309 219 | #, python-brace-format 220 | msgid "Couldn't find a playlist matching \"{name}\".How about the \"{suggestion}\" playlist?" 221 | msgstr "" 222 | "Je n'ai pas trouvé de liste de lecture qui contient \"{name}\". Que pensez-vous de la liste de lecture " 223 | "\"{suggestion}\" ?" 224 | 225 | #: squeezealexa/main.py:327 226 | #, python-brace-format 227 | msgid "Playing mix of {genres}" 228 | msgstr "Joue un mélange de {genres}" 229 | 230 | #: squeezealexa/main.py:330 231 | msgid "or" 232 | msgstr "ou" 233 | 234 | #: squeezealexa/main.py:331 235 | #, python-brace-format 236 | msgid "Don't understand requested genres {genres}" 237 | msgstr "Je ne comprends pas la demande de genre {genres}" 238 | 239 | #: squeezealexa/main.py:333 240 | #, python-brace-format 241 | msgid "Can't find genres: {genres}" 242 | msgstr "Impossible de trouver les genres : {genres}" 243 | 244 | #: squeezealexa/main.py:384 245 | msgid "Hasta la vista. Baby." 246 | msgstr "Hasta la vista. Baby." 247 | 248 | #: squeezealexa/utils.py:34 249 | msgid "and" 250 | msgstr "et" 251 | 252 | #: squeezealexa/utils.py:104 253 | #, python-brace-format 254 | msgid "Failed \"{task}\", after {secs:.1f} seconds" 255 | msgstr "" 256 | 257 | #: squeezealexa/i18n.py:47 258 | msgid "favorites" 259 | msgstr "favoris" 260 | 261 | #: squeezealexa/squeezebox/server.py:102 262 | msgid "Uh-oh. No connected players found." 263 | msgstr "" 264 | 265 | #: squeezealexa/squeezebox/server.py:240 266 | msgid "Unknown player" 267 | msgstr "" 268 | -------------------------------------------------------------------------------- /locale/de_DE/LC_MESSAGES/squeeze-alexa.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: \n" 4 | "POT-Creation-Date: \n" 5 | "PO-Revision-Date: \n" 6 | "Last-Translator: \n" 7 | "Language-Team: \n" 8 | "Language: de_DE\n" 9 | "MIME-Version: 1.0\n" 10 | "Content-Type: text/plain; charset=UTF-8\n" 11 | "Content-Transfer-Encoding: 8bit\n" 12 | "X-Generator: Poedit 2.0.6\n" 13 | 14 | #: squeezealexa/main.py:75 15 | msgid "Squeezebox is online. Please try some commands." 16 | msgstr "Squeezebox ist bereit. Probier ein paar Kommandos aus." 17 | 18 | #: squeezealexa/main.py:76 19 | msgid "Try resume, pause, next, previous, play some jazz, or ask Squeezebox to turn it up or down" 20 | msgstr "Probier mal fortsetzen, Pause, nächstes, letztes, Spiel Jazz Musik oder schalte ein Abspielgerät ein" 21 | 22 | #: squeezealexa/main.py:91 23 | #, python-brace-format 24 | msgid "Sorry, I don't know how to process a \"{intent}\"" 25 | msgstr "Es tut mir Leid, ich weiß nicht wie ich auf \"{intent}\" reagieren soll" 26 | 27 | #: squeezealexa/main.py:93 28 | #, python-brace-format 29 | msgid "Unknown intent: '{intent}'" 30 | msgstr "Unbekannte Anfrage: '{intent}'" 31 | 32 | #: squeezealexa/main.py:109 33 | msgid "Rewind!" 34 | msgstr "Zurückgespult!" 35 | 36 | #: squeezealexa/main.py:114 37 | msgid "Yep, pretty lame." 38 | msgstr "Das mochte ich auch nicht." 39 | 40 | #: squeezealexa/main.py:122 41 | #, python-brace-format 42 | msgid "Currently playing: \"{title}\"" 43 | msgstr "Gerade läuft: \"{title}\"" 44 | 45 | #: squeezealexa/main.py:124 46 | #, python-brace-format 47 | msgid ", by {artists}." 48 | msgstr "" 49 | 50 | #: squeezealexa/main.py:126 51 | #, python-brace-format 52 | msgid "Now playing: \"{title}\"" 53 | msgstr "Es läuft: \"{title}\"" 54 | 55 | #: squeezealexa/main.py:128 56 | msgid "Nothing playing." 57 | msgstr "Es wird nicht abgespielt." 58 | 59 | #: squeezealexa/main.py:139 squeezealexa/main.py:143 60 | msgid "Select a volume value between 0 and 10" 61 | msgstr "Wähle eine Lautstärke zwischen 0 und 10" 62 | 63 | #: squeezealexa/main.py:140 64 | msgid "Invalid volume value" 65 | msgstr "Ungültige Lautstärke" 66 | 67 | #: squeezealexa/main.py:144 68 | #, python-brace-format 69 | msgid "Volume value out of range: {volume}" 70 | msgstr "Lautstärke außerhalb des gültigen Bereichs: {volume}" 71 | 72 | #: squeezealexa/main.py:150 73 | #, python-brace-format 74 | msgid "Set volume to {volume}" 75 | msgstr "Lautstärke auf {volume}" 76 | 77 | #: squeezealexa/main.py:162 78 | msgid "Select a volume between 0 and 100 percent" 79 | msgstr "Wähle eine Lautstärke zwischen 0 und 100 prozent" 80 | 81 | #: squeezealexa/main.py:163 82 | msgid "Invalid volume" 83 | msgstr "Ungültige Lautstärke" 84 | 85 | #: squeezealexa/main.py:166 86 | msgid "Select a volume value between 0 and 100 percent" 87 | msgstr "Wähle eine Lautstärke zwischen 0 und 100 prozent" 88 | 89 | #: squeezealexa/main.py:167 90 | #, python-brace-format 91 | msgid "Volume value out of range: {volume} percent" 92 | msgstr "Lautstärke außerhalb des gültigen Bereichs: {volume}" 93 | 94 | #: squeezealexa/main.py:171 95 | msgid "OK" 96 | msgstr "OK" 97 | 98 | #: squeezealexa/main.py:172 99 | #, python-brace-format 100 | msgid "Set volume to {percent} percent" 101 | msgstr "Lautstärke auf {percent} prozent" 102 | 103 | #: squeezealexa/main.py:178 104 | msgid "Increase Volume" 105 | msgstr "Lautstärke erhöht" 106 | 107 | #: squeezealexa/main.py:179 108 | msgid "Pumped it up." 109 | msgstr "Alles klar: lauter." 110 | 111 | #: squeezealexa/main.py:184 112 | msgid "Decrease Volume" 113 | msgstr "Lautstärke niedriger" 114 | 115 | #: squeezealexa/main.py:185 116 | msgid "OK, quieter now." 117 | msgstr "Jetzt ist es leiser." 118 | 119 | #: squeezealexa/main.py:197 120 | #, python-brace-format 121 | msgid "Selected {player}" 122 | msgstr "{player} ausgewählt" 123 | 124 | #: squeezealexa/main.py:198 125 | #, python-brace-format 126 | msgid "Selected player {player}" 127 | msgstr "Abspielgerät {player} ausgewählt" 128 | 129 | #: squeezealexa/main.py:201 130 | #, python-brace-format 131 | msgid "I only found these players: {players}. Could you try again?" 132 | msgstr "Ich kann nur die folgenden Geräte finden: {players}. Welches willst du?" 133 | 134 | #: squeezealexa/main.py:204 135 | #, python-brace-format 136 | msgid "You can select a player by saying \"{utterance}\" and then the player name." 137 | msgstr "Du kannst ein Gerät auswählen indem du \"{utterance}\" sagst und dann den Namen des Gerätes." 138 | 139 | #: squeezealexa/main.py:209 140 | #, python-brace-format 141 | msgid "No player called \"{name}\"" 142 | msgstr "Es gibt kein Gerät mit dem Namen \"{name}\"" 143 | 144 | #: squeezealexa/main.py:219 145 | msgid "Shuffle on" 146 | msgstr "Zufallswiedergabe an" 147 | 148 | #: squeezealexa/main.py:220 149 | msgid "Shuffle is now on" 150 | msgstr "Zufallswiedergabe ist jetzt eingeschaltet" 151 | 152 | #: squeezealexa/main.py:226 153 | msgid "Shuffle off" 154 | msgstr "Zufallswiedergabe aus" 155 | 156 | #: squeezealexa/main.py:227 157 | msgid "Shuffle is now off" 158 | msgstr "Zufallswiedergabe ist jetzt ausgeschaltet" 159 | 160 | #: squeezealexa/main.py:233 161 | msgid "Repeat on" 162 | msgstr "Wiederholung an" 163 | 164 | #: squeezealexa/main.py:234 165 | msgid "Repeat is now on" 166 | msgstr "Wiederholung ist jetzt eingeschaltet" 167 | 168 | #: squeezealexa/main.py:240 169 | msgid "Repeat Off" 170 | msgstr "Wiederholung aus" 171 | 172 | #: squeezealexa/main.py:241 173 | msgid "Repeat is now off" 174 | msgstr "Wiederholung ist jetzt ausgeschaltet" 175 | 176 | #: squeezealexa/main.py:250 177 | #, python-brace-format 178 | msgid "Switched {player} off" 179 | msgstr "{player} eingeschaltet" 180 | 181 | #: squeezealexa/main.py:251 182 | #, python-brace-format 183 | msgid "{player} is now off" 184 | msgstr "{player} ist jetzt ausgeschaltet" 185 | 186 | #: squeezealexa/main.py:265 187 | #, python-brace-format 188 | msgid "Switched {player} on" 189 | msgstr "{player} eingeschaltet" 190 | 191 | #: squeezealexa/main.py:271 192 | msgid "Players all off" 193 | msgstr "Alle Abspielgeräte aus" 194 | 195 | #: squeezealexa/main.py:272 196 | msgid "Silence." 197 | msgstr "Jetzt ist es still." 198 | 199 | #: squeezealexa/main.py:277 200 | msgid "All On." 201 | msgstr "Alle an." 202 | 203 | #: squeezealexa/main.py:278 204 | msgid "Ready to rock" 205 | msgstr "Lass uns losrocken" 206 | 207 | #: squeezealexa/main.py:289 208 | msgid "There are no playlists" 209 | msgstr "Es gibt keine Wiedergabelisten" 210 | 211 | #: squeezealexa/main.py:291 212 | #, python-brace-format 213 | msgid "Didn't hear a playlist there. You could try the \"{name}\" playlist?" 214 | msgstr "Ich habe keinen Gerätenamen verstanden. Versuchs doch mit \"{name}\"." 215 | 216 | #: squeezealexa/main.py:297 217 | msgid "No Squeezebox playlists found" 218 | msgstr "Keine Squeezbox-Wiedergabelisten gefunden" 219 | 220 | #: squeezealexa/main.py:306 squeezealexa/main.py:307 221 | #, python-brace-format 222 | msgid "Playing \"{name}\" playlist" 223 | msgstr "Spiele Wiedergabeliste \"{name}\"" 224 | 225 | #: squeezealexa/main.py:309 226 | #, python-brace-format 227 | msgid "Couldn't find a playlist matching \"{name}\".How about the \"{suggestion}\" playlist?" 228 | msgstr "Ich habe keine Liste \"{name}\" gefunden. Versuchs doch mit \"{suggestion}\"?" 229 | 230 | #: squeezealexa/main.py:327 231 | #, python-brace-format 232 | msgid "Playing mix of {genres}" 233 | msgstr "Spiele eine Mischung aus {genres}" 234 | 235 | #: squeezealexa/main.py:330 236 | msgid "or" 237 | msgstr "oder" 238 | 239 | #: squeezealexa/main.py:331 240 | #, python-brace-format 241 | msgid "Don't understand requested genres {genres}" 242 | msgstr "Ich habe das angefragte Genre {genres} nicht verstanden" 243 | 244 | #: squeezealexa/main.py:333 245 | #, python-brace-format 246 | msgid "Can't find genres: {genres}" 247 | msgstr "Kann das Genre nicht finden: {genres}" 248 | 249 | #: squeezealexa/main.py:384 250 | msgid "Hasta la vista. Baby." 251 | msgstr "Hasta la vista. Baby." 252 | 253 | #: squeezealexa/utils.py:34 254 | msgid "and" 255 | msgstr "und" 256 | 257 | #: squeezealexa/utils.py:104 258 | #, python-brace-format 259 | msgid "Failed \"{task}\", after {secs:.1f} seconds" 260 | msgstr "" 261 | 262 | #: squeezealexa/i18n.py:47 263 | msgid "favorites" 264 | msgstr "Favoriten" 265 | 266 | #: squeezealexa/squeezebox/server.py:102 267 | msgid "Uh-oh. No connected players found." 268 | msgstr "" 269 | 270 | #: squeezealexa/squeezebox/server.py:240 271 | msgid "Unknown player" 272 | msgstr "" 273 | -------------------------------------------------------------------------------- /docs/SSL.md: -------------------------------------------------------------------------------- 1 | SSL tunnel setup 2 | ================ 3 | 4 | This section details how to set up squeeze-alexa using the SSL Tunnel transport option. 5 | 6 | Networking Overview 7 | ------------------- 8 | ![Networking diagram](squeeze-alexa-networking.png) 9 | 10 | Note how the _arbitrary_ ports are not labelled - see [setting up ports](#configure-ports). 11 | 12 | 13 | Set up your networking 14 | ---------------------- 15 | 16 | ### Configure ports 17 | * Generally the connections go `lambda -> router:extport -> server:sslport -> lms:9090` (see diagram above). Most people will have `lms` and `server` on the same host (Synology / ReadyNAS / whatever). 18 | * Choose some values for `extport` and `sslport` e.g. `19090`. For sanity, it's probably easiest to use the same port externally as internally, i.e. `extport == sslport` 19 | * On your router, forward `extport` to the stunnel / haproxy /nginx port (`sslport`) on that server. 20 | 21 | ### Set up DDNS 22 | * This is recommended if you don't have fixed IP, so that there's a consistent address to reach your home... 23 | * Try www.dyndns.org or www.noip.com, or better still your NAS drive or router might be pre-configured with its own (Synology has their own dynamic DNS setup, for example). 24 | * Note down this _external_ (Internet) address (e.g. `bob-the-builder.noip.com`). We'll call it `MY_HOSTNAME` later. 25 | 26 | ### Optional: use your own domain 27 | * If you have your own domain name (e.g. `house.example.com`) available, I strongly suggest using the DDNS to forward to this (with `CNAME`) especially if on dynamic IP. Why? Because DNS takes too long to refresh, but DDNS is near immediate. 28 | * This will also allow you to create better-looking certificates against a meaningful _subject_ (domain name). It's then _this_ that will be your `MY_HOSTNAME` later. 29 | 30 | ### Create certificate(s) 31 | You can skip this step if you already have one, of course, so long as it's the same address used for `MY_HOSTNAME` above. 32 | This should be working on your _local_ network as well, i.e. make sure your server knows that it's the address at `MY_HOSTNAME`. 33 | 34 | It's worth reading up on OpenSSL, it's crazily powerful. 35 | If that's a bit TL;DR then here is a fairly secure setup, inspired largely by [this openssl SO post](https://stackoverflow.com/questions/10175812/how-to-create-a-self-signed-certificate-with-openssl) 36 | 37 | ```bash 38 | openssl req -x509 -newkey rsa:2048 -sha256 -nodes -keyout key.pem -out cert.pem -subj "/CN=$MY_HOSTNAME" -days 3650 39 | cat cert.pem key.pem > etc/certs/squeeze-alexa.pem && rm -f key.pem cert.pem 40 | ``` 41 | 42 | _TODO: document optional creation of separate server cert for ~~more complicated~~ better(ish) security._ 43 | 44 | ### Check routing 45 | 46 | From your server, check this works (substitute `MY-SERVER` as before) 47 | ```bash 48 | $ ping -c 4 localhost 49 | $ ping -c 4 MY-SERVER 50 | ``` 51 | If the latter fails, try using `localhost`, but it's better to set up your DNS to work internally, e.g. add this to your `/etc/hosts`: 52 | 53 | 127.0.0.1 localhost MY-SERVER 54 | 55 | See [TROUBLESHOOTING](TROUBLESHOOTING.md) for detailed diagnosis of connection problems. 56 | 57 | 58 | 59 | Tunnel the CLI 60 | -------------- 61 | 62 | ### Background 63 | When you open up your LMS to the world, well, you _don't really_ want do that, but for Alexa to work this generally* needs to happen. 64 | See [connecting remotely](http://wiki.slimdevices.com/index.php/Connecting_remotely) on the wiki, but it's more around Web than CLI (which is how `squeeze-alexa` works). 65 | 66 | You _could_ use the username / password auth LMS CLI provides, but for these problems: 67 | 68 | * It's in plain text, so everyone can see and log everything. This is pretty bad. 69 | * The credentials aren't rotated so once they're gone, they're good to go 70 | * Nor do they include a token (à la [CSRF](https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF))) or nonce - so replay attacks are easy too. 71 | * There is no rate limiting or banning in LMS, so brute-forcing the password is easy (though it does hang up IIRC). 72 | 73 | By mandating client-side TLS (aka SSL) with a private cert, `squeeze-alexa` avoids most of these problems. 74 | 75 | * Or you could [use MQTT](MQTT.md) of course. 76 | 77 | ### Tunnel Implementations 78 | 79 | There are quite a few ways to do this with existing open-source software. 80 | Pick your favourite! 81 | 82 | #### Some docs here 83 | * [stunnel](http://stunnel.org/). This is old but the most supported here (see details below), 84 | but other options should work here (**feedback wanted!**) 85 | * [nginx 1.9+ supports TCP Load Balancing](https://www.nginx.com/blog/tcp-load-balancing-with-nginx-1-9-0-and-nginx-plus-r6/) which can be used with the [nginx SSL module](https://nginx.org/en/docs/stream/ngx_stream_ssl_module.html) to do this. 86 | 87 | #### Other (undocumented) options 88 | * [HAProxy](https://www.haproxy.com) _does_ support TLS-wrapping of generic TCP. 89 | * Also, there's [ssl_wrapper](https://github.com/cesanta/ssl_wrapper) but I've not tried this. 90 | 91 | Whichever one of these you choose, make sure you configure a new (safe) TLS, **minimum TLS v1.2**. 92 | 93 | ### With stunnel 94 | #### On Synology 95 | ##### Using Entware and `opkg` 96 | Follow [this excellent Synology forum post](https://forum.synology.com/enu/viewtopic.php?f=40&t=95346) to install Entware if you don't have it. 97 | ```bash 98 | opkg install stunnel 99 | ``` 100 | Your config will live at `/Apps/opt/etc/stunnel/stunnel.conf`. 101 | 102 | #### Auto-starting stunnel 103 | There are various ways of getting a script to start up automatically on Synology. 104 | 105 | ##### Using Upstart 106 | You could "do this properly" and make it a system service, you can [create Upstart scripts](https://majikshoe.blogspot.co.uk/2014/12/starting-service-on-synology-dsm-5.html). 107 | 108 | ##### Or using Entware 109 | Just drop the script: 110 | ```bash 111 | #!/bin/sh 112 | 113 | if [ -n "`pidof stunnel`" ] ;then 114 | killall stunnel 2>/dev/null 115 | fi 116 | /Apps/opt/bin/stunnel 117 | ``` 118 | 119 | to `/Apps/opt/etc/init.d/S20stunnel`. Make sure it's executable: 120 | 121 | ```bash 122 | chmod +x /Apps/opt/etc/init.d/S20stunnel 123 | ``` 124 | You should try running it and checking the process is a live and logging where you expect (as per your `stunnel.conf`). 125 | 126 | ##### Or: scheduled tasks 127 | You could set the script above to run as a scheduled startup task in your Synology DSM GUI. 128 | 129 | 130 | #### On other servers 131 | I've tried this on Synology but it should be similar on Netgear ReadyNAS - [this forum posting](https://community.netgear.com/t5/Community-Add-ons/HowTo-Stunnel-on-the-Readynas/td-p/784170) seems helpful. 132 | Some other NAS drives can also use `ipkg` / `opkg`, in which case see above. 133 | Else, find a way of installing it (you can build from source if you know how). 134 | 135 | #### Raspberry Pi 136 | This should work (untested): 137 | ```bash 138 | sudo apt-get install stunnel4 openssl -y 139 | ``` 140 | See a great [stunnel on RPi article](https://emtunc.org/blog/07/2016/reverse-ssh-tunnelling-ssl-raspberry-pi/) (though that's more complicated than we need). 141 | 142 | #### Copy certificate 143 | Copy the `squeeze-alexa.pem` to somewhere stunnel can see it, e.g. the same location as `stunnel.conf` (see above). 144 | 145 | #### Edit config 146 | See the example [`stunnel.conf`](example-config/stunnel.conf) for a fuller version, but you'll need changes of course. 147 | 148 | To do so you'll need something to edit your `stunnel.conf` (e.g. `vim` or `nano`) and add this at the end, referring to the cert path you just used above e.g. (for Entware): 149 | 150 | [slim] 151 | accept = MY-PORT 152 | connect = MY-HOSTNAME:9090 153 | 154 | verify = 3 155 | CAfile = /Apps/opt/etc/stunnel/squeeze-alexa.pem 156 | cert = /Apps/opt/etc/stunnel/squeeze-alexa.pem 157 | 158 | As before `MY-PORT` and `MY-HOSTNAME` should be substituted with your own values. 159 | Note that here `MY-HOSTNAME` here is referring to the LMS address as seen from the proxy, i.e. internally. 160 | This will usually just be blank (==`localhost`) if your LMS is on the same machine as stunnel. 161 | 162 | 163 | ### ...or with Nginx 1.9+ 164 | 165 | :new: Nginx (1.9+) is known to work for squeeze-alexa. 166 | * If you're on Raspberry Pi, make sure you have a new enough version. 167 | * Try [this example config](example-config/docker/nginx-tcp-ssl/nginx.conf), or something similar (remember to replace `LMS_SERVER` and `SSL_PORT`, or set them in your environment). 168 | * You'll usually want to override the default nginx.conf (e.g. `etc/nginx/nginx.conf`). 169 | * You'll need to copy the cert across to `/etc/ssl/squeeze-alexa.pem` 170 | * Make sure to start / restart (e.g. `systemctl restart nginx`) once you're done and start testing... 171 | 172 | 173 | ### ...or with Dockerised Nginx 174 | :new: This can be Dockerised easily for people who prefer Docker (and have it available on their server). 175 | See the [nginx-tcp-ssl directory](example-config/docker/nginx-tcp-ssl/). 176 | The hard bit is probably getting your networking stable and port-forwarding appropriately. 177 | Remember the certificate name has to match this server's hostname... 178 | 179 | #### To build 180 | From the directory above, first copy your `squeeze-alexa.pem` file in, then: 181 | 182 | ```bash 183 | docker build --build-arg INTERNAL_SERVER_HOSTNAME=192.168.1.123 -t $(basename $PWD) . 184 | ``` 185 | 186 | #### To run 187 | ```bash 188 | docker run -p 19090:19090 $(basename $PWD) 189 | ``` 190 | -------------------------------------------------------------------------------- /bin/deploy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2018 Nick Boultbee 5 | # This file is part of squeeze-alexa. 6 | # 7 | # squeeze-alexa is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # See LICENSE for full license 13 | 14 | import argparse 15 | import enum 16 | import json 17 | import logging 18 | import re 19 | import sys 20 | from io import BytesIO 21 | from os import path, walk, chdir 22 | from os.path import dirname, realpath, isdir 23 | from tarfile import TarFile, TarInfo 24 | from typing import BinaryIO, Set, Union 25 | from zipfile import ZipFile, ZIP_DEFLATED, ZipInfo 26 | 27 | import boto3 28 | from botocore.exceptions import ClientError 29 | 30 | ROOT = dirname(dirname(realpath(__file__))) 31 | DIST_SUBDIR = "dist" 32 | 33 | OUTPUT_ZIP = path.join(ROOT, "lambda_function.zip") 34 | MQTT_OUTPUT_GZIP = path.join(ROOT, "mqtt-squeeze.tgz") 35 | 36 | LAMBDA_NAME = "squeeze-alexa" 37 | MANAGED_POLICY_ARN = ("arn:aws:iam::aws:policy/service-role/" 38 | "AWSLambdaBasicExecutionRole") 39 | ROLE_NAME = "squeeze-alexa" 40 | EXCLUDE_REGEXES = [re.compile(s) for s in 41 | (r"__pycache__/", r"\.git/", r"^\..+", 42 | r"docs/", r"metadata/", 43 | r"\.po$", r"~$", r"\.pyc$", r"\\.md$", r"\.zip$")] 44 | 45 | 46 | class Commands(enum.Enum): 47 | AWS_DEPLOY = 'aws' 48 | ZIP_MQTT = 'mqtt' 49 | ZIP_SKILL = 'zip' 50 | 51 | 52 | logging.basicConfig(format="[{levelname:7s}] {message}", 53 | style="{") 54 | log = logging.getLogger("squeeze-alexa-uploader") 55 | log.setLevel(logging.INFO) 56 | 57 | ROLE_POLICY_DOC = json.dumps({ 58 | "Version": "2012-10-17", 59 | "Statement": [ 60 | { 61 | "Sid": "", 62 | "Effect": "Allow", 63 | "Principal": { 64 | "Service": "lambda.amazonaws.com" 65 | }, 66 | "Action": "sts:AssumeRole" 67 | } 68 | ] 69 | }) 70 | 71 | Arn = str 72 | 73 | 74 | class Error(Exception): 75 | """Base Exception""" 76 | pass 77 | 78 | 79 | def suitable(name: str) -> bool: 80 | for r in EXCLUDE_REGEXES: 81 | if r.search(name): 82 | return False 83 | return True 84 | 85 | 86 | def main(args=sys.argv[1:]): 87 | parser = argparse.ArgumentParser(description="squeeze-alexa deployer") 88 | parser.add_argument("-v", "--verbose", help="Verbose logging", 89 | action="store_true") 90 | 91 | subparsers = parser.add_subparsers(dest="cmd", help="Command") 92 | subparsers.required = True 93 | subparsers.add_parser(Commands.ZIP_SKILL.value, help='create local zip') 94 | 95 | subparsers.add_parser(Commands.ZIP_MQTT.value, 96 | help='create mqtt-squeeze zip') 97 | 98 | aws_parser = subparsers.add_parser(Commands.AWS_DEPLOY.value, 99 | help='Set up AWS Lambda') 100 | aws_parser.add_argument("--profile", action="store", 101 | help="AWS profile to use") 102 | aws_parser.add_argument("--skill", required=True, action="store", 103 | metavar="SKILL_ID", 104 | help="Your Alexa skill ID (amzn1.ask.skill....)") 105 | args = parser.parse_args(args) 106 | log.setLevel(logging.DEBUG if args.verbose else logging.INFO) 107 | log.debug(args) 108 | 109 | dist_dir = path.join(ROOT, DIST_SUBDIR) 110 | if isdir(dist_dir): 111 | log.info("Using built code and config from directory: %s", dist_dir) 112 | else: 113 | dist_dir = ROOT 114 | log.info("No '%s/' dir found, using root %s for files", 115 | DIST_SUBDIR, ROOT) 116 | chdir(dist_dir) 117 | c = Commands(args.cmd) 118 | if c == Commands.AWS_DEPLOY: 119 | log.debug("Setting up AWS Lambda") 120 | aws_upload(args, create_skill_zip()) 121 | elif c == Commands.ZIP_MQTT: 122 | log.debug("Creating tgz for mqtt-squeeze") 123 | with open(MQTT_OUTPUT_GZIP, "wb") as f: 124 | create_mqtt_gzip(f, root=dist_dir) 125 | log.info("Wrote %s", MQTT_OUTPUT_GZIP) 126 | else: 127 | log.info("Creating zip for manual skill upload. " 128 | "Use '%s' command to setup skill automatically", 129 | Commands.AWS_DEPLOY.value) 130 | zipped = create_skill_zip() 131 | with open(OUTPUT_ZIP, "wb") as f: 132 | f.write(zipped.read()) 133 | log.info("Wrote %s", OUTPUT_ZIP) 134 | 135 | 136 | def aws_upload(args: argparse.Namespace, zip_data: BinaryIO): 137 | if args.profile: 138 | session = boto3.session.Session() 139 | log.debug("Available profiles: %s", session.available_profiles) 140 | log.info("Using profile: %s", args.profile) 141 | boto3.setup_default_session(profile_name=args.profile) 142 | role_arn = set_up_role() 143 | if lambda_exists(): 144 | upload(zip_data) 145 | else: 146 | arn = create_lambda(role_arn, zip_data, args.skill) 147 | print("Lambda ARN:", arn) 148 | 149 | 150 | def lambda_exists(name=LAMBDA_NAME) -> bool: 151 | lam = boto3.client("lambda") 152 | try: 153 | lam.get_function(FunctionName=name) 154 | except ClientError as e: 155 | if e.response["Error"]["Code"] != "ResourceNotFoundException": 156 | raise 157 | return False 158 | else: 159 | log.info("AWS Lambda '%s' already exists. Updating code only...", name) 160 | return True 161 | 162 | 163 | def upload(zip: BinaryIO, name=LAMBDA_NAME): 164 | lam = boto3.client("lambda") 165 | r = lam.update_function_code(FunctionName=name, 166 | ZipFile=zip.read()) 167 | log.info("Updated Lambda: %s", r['FunctionArn']) 168 | log.debug("Updated %s (\"%s\"), with %.0f KB of zipped data", 169 | name, r["Description"], r["CodeSize"] / 1024) 170 | 171 | 172 | def create_lambda(role_arn: str, zip_data: BinaryIO, skill_id: str) -> Arn: 173 | lam = boto3.client("lambda") 174 | resp = lam.create_function(FunctionName=LAMBDA_NAME, 175 | Runtime="python3.6", 176 | Handler="handler.lambda_handler", 177 | Code={"ZipFile": zip_data.read()}, 178 | Description="Squeezebox integration for Alexa", 179 | Role=role_arn, 180 | Timeout=8) 181 | log.debug("Creation response: %s", resp) 182 | 183 | r = lam.add_permission( 184 | FunctionName=LAMBDA_NAME, 185 | StatementId="AlexaFunctionPermission", 186 | Action="lambda:InvokeFunction", 187 | Principal="alexa-appkit.amazon.com", 188 | EventSourceToken="amzn1.ask.skill.%s" % skill_id) 189 | log.debug("Permissioning response: %s", r) 190 | return resp["FunctionArn"] 191 | 192 | 193 | def create_skill_zip() -> BinaryIO: 194 | def files_in(zf: ZipFile, ext: str) -> Set[ZipInfo]: 195 | return {f for f in zf.filelist if f.filename.endswith(ext)} 196 | log.debug("Compressing Skill deployment files") 197 | io = BytesIO() 198 | with ZipFile(io, "w") as zf: 199 | for root, dirs, fns in walk("./"): 200 | for d in list(dirs): 201 | if not suitable(d + "/"): 202 | dir_path = path.join(root, d) 203 | log.debug("Excluding dir: %s", dir_path) 204 | try: 205 | dirs.remove(d) 206 | except ValueError: 207 | log.warning("Couldn't remove %s", dir_path) 208 | for name in fns: 209 | if suitable(name): 210 | zf.write(path.join(root, name), compress_type=ZIP_DEFLATED) 211 | if not files_in(zf, '.mo'): 212 | raise Error("Can't find any translations (.mo files). " 213 | "Did you forget to run the build first?") 214 | log.debug("All files: %s", ", ".join(zi.filename for zi in zf.filelist)) 215 | io.seek(0) 216 | log.info("Added %d files to zip (%.0f KB total)", 217 | len(zf.filelist), len(io.read()) / 1024) 218 | io.seek(0) 219 | return io 220 | 221 | 222 | def create_mqtt_gzip(f: BinaryIO, root: str = ROOT) -> None: 223 | def exclude_bad(ti: TarInfo) -> Union[TarInfo, None]: 224 | for r in EXCLUDE_REGEXES: 225 | if r.search(ti.name): 226 | return None 227 | return ti 228 | log.debug("Compressing MQTT deployment files") 229 | with TarFile.gzopen("mqtt-squeeze", mode="w", fileobj=f) as tf: 230 | tf.add(path.join(ROOT, 'mqtt_squeeze.py'), arcname='mqtt_squeeze.py') 231 | for d in ['etc', 'squeezealexa']: 232 | tf.add(path.join(root, d), arcname=d, filter=exclude_bad) 233 | 234 | if not [fn for fn in tf.getnames() if fn.endswith('.pem.crt')]: 235 | raise Error("Can't find any certs (.pem.crt files). " 236 | "Make sure you create these first in /etc/certs") 237 | log.debug("All files: %s", ", ".join(tf.getnames())) 238 | 239 | 240 | def set_up_role() -> str: 241 | iam = boto3.client("iam") 242 | 243 | def create_role() -> dict: 244 | response = iam.create_role(RoleName=ROLE_NAME, 245 | AssumeRolePolicyDocument=ROLE_POLICY_DOC) 246 | iam.attach_role_policy(RoleName=ROLE_NAME, 247 | PolicyArn=MANAGED_POLICY_ARN) 248 | return response 249 | 250 | try: 251 | role = create_role() 252 | except ClientError as e: 253 | if e.response["Error"]["Code"] != "EntityAlreadyExists": 254 | raise 255 | try: 256 | logging.info("Deleting existing role...") 257 | iam.delete_role(RoleName=ROLE_NAME) 258 | except ClientError: 259 | logging.info("Detaching existing role first...") 260 | role = boto3.resource("iam").Role(ROLE_NAME) 261 | role.detach_policy(PolicyArn=MANAGED_POLICY_ARN) 262 | role.delete() 263 | role = create_role() 264 | return role["Role"]["Arn"] 265 | 266 | 267 | if __name__ == "__main__": 268 | main() 269 | -------------------------------------------------------------------------------- /tests/squeezebox/test_server.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2017-18 Nick Boultbee 4 | # This file is part of squeeze-alexa. 5 | # 6 | # squeeze-alexa is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # See LICENSE for full license 12 | 13 | import re 14 | import time 15 | from typing import Dict, NewType 16 | from unittest import TestCase 17 | 18 | from pytest import raises 19 | 20 | from squeezealexa.squeezebox.server import Server, \ 21 | SqueezeboxPlayerSettings as SPS, SqueezeboxException, ServerFactory 22 | from squeezealexa.transport.base import Transport 23 | from squeezealexa.transport.factory import TransportFactory 24 | from squeezealexa.utils import print_d 25 | from tests.transport.fake_transport import FakeTransport, FAKE_LENGTH, \ 26 | A_REAL_STATUS 27 | 28 | Regex = NewType('Regex', str) 29 | A_PLAYER_ID = "01:23:45:67:89:0A" 30 | 31 | 32 | class TestSqueezeboxPlayerSettings: 33 | def test_raises_if_no_playerid_found(self): 34 | with raises(SqueezeboxException) as e: 35 | SPS({}) 36 | assert "couldn't find a playerid" in str(e).lower() 37 | 38 | 39 | class FixedTransportFactory(TransportFactory): 40 | 41 | def __init__(self, instance: Transport = FakeTransport()): 42 | super().__init__() 43 | self.instance = instance 44 | self.count = 0 45 | 46 | def create(self, mqtt_client=None): 47 | self.count += 1 48 | print_d("Creating instance #{count}", count=self.count) 49 | return self.instance 50 | 51 | 52 | class NoRefreshServer(Server): 53 | """A normal server, that has no transport never returns any players""" 54 | 55 | def __init__(self, user=None, password=None, cur_player_id=None): 56 | super().__init__(FixedTransportFactory(FakeTransport()).create(), 57 | user, password, cur_player_id, False) 58 | 59 | def refresh_status(self): 60 | self.players = {} 61 | 62 | 63 | class StubbedTransportServer(Server): 64 | 65 | def __init__(self, player_request_responses: Dict[Regex, str]): 66 | self.canned = player_request_responses 67 | self.transport = Transport() 68 | self.cur_player_id = None 69 | self.players = { 70 | A_PLAYER_ID: SPS({"playerid": A_PLAYER_ID, "connected": True}) 71 | } 72 | 73 | def player_request(self, line, player_id=None, raw=False, wait=True): 74 | for regex, response in self.canned.items(): 75 | if re.compile(regex).match(line): 76 | return response 77 | 78 | def refresh_status(self): 79 | pass 80 | 81 | 82 | class TestServerNoTransport: 83 | def test_no_players_raises(self): 84 | with raises(SqueezeboxException) as e: 85 | Server._INSTANCE = None 86 | NoRefreshServer() 87 | assert "no connected players" in str(e).lower() 88 | 89 | 90 | class TestServerFactory(TestCase): 91 | def setUp(self): 92 | transport = FakeTransport().start() 93 | self.factory = ServerFactory(FixedTransportFactory(transport)) 94 | 95 | def test_singleton(self): 96 | first = self.factory.create() 97 | second = self.factory.create() 98 | assert first is second 99 | 100 | def test_singletons_across_factories(self): 101 | first = self.factory.create() 102 | del self.factory 103 | transport = FakeTransport().start() 104 | factory2 = ServerFactory(FixedTransportFactory(transport)) 105 | second = factory2.create() 106 | assert first is second 107 | 108 | def test_staleness_creates_new_instance(self): 109 | first = self.factory.create() 110 | ServerFactory._CREATION_TIME = (time.time() - 111 | ServerFactory._MAX_CACHE_SECS - 1) 112 | second = self.factory.create() 113 | assert first is not second 114 | 115 | 116 | class TestServerWithFakeTransport(TestCase): 117 | 118 | def setUp(self): 119 | self.transport = FakeTransport().start() 120 | self.server = Server(transport=self.transport) 121 | 122 | def test_debug(self): 123 | Server(self.transport, debug=True) 124 | 125 | def test_unknown_default_player(self): 126 | transport = FakeTransport(fake_id="foo").start() 127 | self.server = Server(transport=transport, cur_player_id="GONE") 128 | assert self.server.cur_player_id == "foo" 129 | 130 | def test_status(self): 131 | assert self.server.get_milliseconds() == FAKE_LENGTH * 1000 132 | 133 | def test_str(self): 134 | assert 'localhost:0' in str(self.server) 135 | 136 | def test_login(self): 137 | self.server = Server(transport=self.transport, 138 | user='admin', password='pass') 139 | assert self.server.user == 'admin' 140 | 141 | def test_groups(self): 142 | raw = """"something%3Afoobar playerid%3Aecho6fd1 uuid%3A 143 | ip%3A127.0.0.1%3A39365 144 | name%3ALavf%20from%20echo6fd1 seq_no%3A0 model%3Ahttp power%3A1 145 | isplaying%3A0 canpoweroff%3A0 connected%3A0 isplayer%3A1 146 | sn%20player%20count%3A0 147 | other%20player%20count%3A0""".replace('\n', '') 148 | groups = self.server._groups(raw, 'playerid', ['connected']) 149 | expected = {'playerid': 'echo6fd1', 'uuid': None, 150 | 'ip': '127.0.0.1:39365', 'name': 'Lavf from echo6fd1', 151 | 'seq_no': 0, 'model': 'http', 'power': True, 152 | 'isplaying': False, 'canpoweroff': False, 153 | 'connected': False, 'isplayer': True} 154 | assert next(groups) == expected 155 | 156 | def test_groups_multiple(self): 157 | raw = """BLAH 158 | playerindex%3A0 playerid%3A00%3A04%3A20%3A17%3A6f%3Ad1 159 | uuid%3A968b401ba4791d3fadd152bbac2f1dab ip%3A192.168.1.35%3A23238 160 | name%3AUpstairs%20Music seq_no%3A0 model%3Areceiver 161 | modelname%3ASqueezebox%20Receiver power%3A0 isplaying%3A0 162 | displaytype%3Anone isplayer%3A1 canpoweroff%3A1 connected%3A1 163 | firmware%3A77 164 | playerindex%3A2 playerid%3A40%3A16%3A7e%3Aad%3A87%3A07 uuid%3A 165 | ip%3A192.168.1.37%3A54652 name%3AStudy seq_no%3A0 model%3Asqueezelite 166 | modelname%3ASqueezeLite power%3A0 isplaying%3A0 167 | displaytype%3Anone isplayer%3A1 canpoweroff%3A1 connected%3A1 168 | firmware%3Av1.8 sn%20player%20count%3A0 other%20player%20count%3A0 169 | """.replace('\n', '') 170 | groups = self.server._groups(raw, 'playerid') 171 | players = list(groups) 172 | assert len(players) == 2 173 | first = players[0] 174 | assert first['playerid'] == "00:04:20:17:6f:d1" 175 | assert players[1]['name'] == "Study" 176 | for data in groups: 177 | assert 'playerid' in data 178 | 179 | def test_groups_dodgy(self): 180 | raw = "blah bar%3Abaz" 181 | groups = list(self.server._groups(raw, start_key="id")) 182 | assert not groups 183 | 184 | def test_groups_status(self): 185 | data = next(self.server._groups(A_REAL_STATUS)) 186 | assert data['player_name'] == 'Study' 187 | assert data['playlist_cur_index'] == 20 188 | assert data['artist'] == 'Jamie Cullum' 189 | assert isinstance(data['can_seek'], bool) 190 | 191 | def test_faves(self): 192 | assert len(self.server.favorites) == 2 193 | 194 | def test_playlists(self): 195 | assert len(self.server.playlists) == 0 196 | 197 | def test_genres(self): 198 | assert len(self.server.genres) == 0 199 | 200 | def test_change_volume(self): 201 | self.server.change_volume(3) 202 | assert "mixer volume +3" in self.transport.all_input 203 | 204 | def test_change_volume_zero(self): 205 | self.server.change_volume(0) 206 | assert "mixer volume" not in self.transport.all_input 207 | 208 | def test_track_details(self): 209 | details = self.server.get_track_details() 210 | assert ["Jamie Cullum"] == details['artist'] 211 | 212 | def test_disconnected_transport_reconnects(self): 213 | self.transport.is_connected = False 214 | self.server._request(["foo"]) 215 | assert self.transport.is_connected 216 | 217 | def test_disconnected_transport_player_request_reconnects(self): 218 | self.transport.is_connected = False 219 | self.server.player_request("foo", player_id=A_PLAYER_ID) 220 | assert self.transport.is_connected 221 | 222 | 223 | class TestServerWithStubbedTransport: 224 | def test_track_details_blanks(self): 225 | server = StubbedTransportServer( 226 | {Regex('status.*'): "artist: title:song%202 composer:J.S.%20Bach"}) 227 | details = server.get_track_details() 228 | assert "artist" not in details 229 | assert details["composer"] == ['J.S. Bach'] 230 | 231 | 232 | def test_tricky_players_parsing(): 233 | """See https://github.com/declension/squeeze-alexa/issues/93""" 234 | tricky_players = """serverstatus 0 99 lastscan%3A1536990512 235 | version%3A7.9.1 uuid%3Aa6abbce0-edfe-447d-9c4b-2f132345733f 236 | mac%3A00%3A01%3A02%3A03%3A04%3A05 info%20total%20albums%3A107 237 | info%20total%20artists%3A209 info%20total%20genres%3A13 238 | info%20total%20songs%3A2151 239 | info%20total%20duration%3A534087.510000002 player%20count%3A3 240 | playerindex%3A0 playerid%3A00%3A01%3A02%3A03%3A04%3Ad1 241 | uuid%3Ab1a5d6e01890c4c440d2da913233e622 ip%3A192.168.168.173%3A35566 242 | name%3ACuisine seq_no%3A180 model%3Ababy 243 | modelname%3ASqueezebox%20Radio power%3A0 isplaying%3A0 244 | displaytype%3Anone isplayer%3A1 canpoweroff%3A1 connected%3A1 245 | firmware%3A7.7.3-r16676 playerindex%3A1 246 | playerid%3A00%3A01%3A02%3A03%3A04%3Ab6 247 | uuid%3Afeeaab78bf7d9d4773495e7112eefaff ip%3A192.168.168.134%3A34510 248 | name%3AChambre seq_no%3A73 model%3Ababy 249 | modelname%3ASqueezebox%20Radio power%3A0 isplaying%3A0 250 | displaytype%3Anone isplayer%3A1 canpoweroff%3A1 connected%3A1 251 | firmware%3A7.7.3-r16676 playerindex%3A2 252 | playerid%3A00%3A01%3A02%3A03%3A04%3Af6 253 | uuid%3A8d470575086e09c3995a5fcb7a1667c8 ip%3A192.168.168.186%3A39845 254 | name%3ASalon seq_no%3A17 model%3Afab4 modelname%3ASqueezebox%20Touch 255 | power%3A0 isplaying%3A0 displaytype%3Anone isplayer%3A1 256 | canpoweroff%3A1 connected%3A1 firmware%3A7.8.0-r16754 257 | sn%20player%20count%3A2 id%3A31579863 258 | name%3ASqueezebox%20Radio%20Fanal 259 | playerid%3A00%3A01%3A02%3A03%3A04%3Ac2 model%3Ababy id%3A11266387 260 | name%3ASqueezebox%20Radio%20Meme 261 | playerid%3A00%3A01%3A02%3A03%3A04%3A5a model%3Ababy 262 | other%20player%20count%3A0""".replace("\n", "") 263 | 264 | transport = FakeTransport(fake_server_status=tricky_players).start() 265 | server = Server(transport) 266 | server.refresh_status() 267 | assert len(server.player_names) == 3, "Should only have found 3 players" 268 | assert server.player_names == {'Cuisine', 'Chambre', 'Salon'} 269 | --------------------------------------------------------------------------------