├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── install-service.bat ├── logger.js ├── package.json ├── run.bat ├── schemas ├── remote │ ├── .ask │ │ └── config │ ├── deploy.bat │ ├── models │ │ └── en-US.json │ └── skill.json └── subsonic │ ├── .ask │ └── config │ ├── deploy.bat │ ├── models │ └── en-US.json │ └── skill.json ├── server.js ├── skills ├── remote │ ├── convertProntoCode.js │ └── index.js └── subsonic │ └── index.js ├── subsonic.js ├── uninstall-service.bat └── winservice.js /.gitignore: -------------------------------------------------------------------------------- 1 | config.js 2 | sslcert 3 | package-lock.json 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (http://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # Typescript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | *.md -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 160, 3 | "singleQuote": true, 4 | "tabWidth": 4 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT-Zero License 2 | 3 | Copyright (c) 2018 nitzzzu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 18 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alexa local skills 2 | 3 | This is a collection of alexa self hosted skills (based on ASK SDK v2): 4 | 5 | - subsonic skill: makes your Echo play music from your local Subsonic/Airsonic server. 6 | - remote skill: uses Broadlink RM PRO remote to control your IR devices (TV..) 7 | 8 | To make it work you need the following: 9 | 10 | - Server with NodeJs 8+ installed and [Airsonic](https://github.com/airsonic/airsonic/releases) 11 | - Port 443 (HTTPS) forwarded and not firewalled 12 | - `duckdns.org` account or public host with HTTPS certificate (letsencrypt.org) 13 | 14 | ## HTTPS and DNS configuration 15 | 16 | ### Setup duckdns.org 17 | 18 | - Create account, set domain name and get the token 19 | - Install [duckdns updater](https://github.com/nitzzzu/duckdns-updater) 20 | 21 | ### Setup letsencrypt.org certificates on Windows 22 | 23 | - Download [Simple ACME Client for Windows](https://github.com/PKISharp/win-acme) 24 | - Download [curl](https://curl.haxx.se/download.html) 25 | - Create `duckdns-setdns.bat`: 26 | 27 | ``` 28 | @echo off 29 | set arg3=%3 30 | curl -k "https://www.duckdns.org/update?domains={yourdomain}&token={token}&txt=%arg3%" 31 | ``` 32 | 33 | - Create `duckdns-cleardns.bat`: 34 | 35 | ``` 36 | @echo off 37 | curl -k "https://www.duckdns.org/update?domains={yourdomain}&token={token}&clear=true" 38 | ``` 39 | 40 | - Run `letsencrypt.exe` and follow the steps: 41 | - Choose `Create new Certificate with advanced options` (M) 42 | - Choose `Manually input host names` (4) 43 | - Choose `[dns-01] run external program/script` as validation step (2) 44 | - Set create/clear dns scripts (`duckdns-setdns.bat` and `duckdns-cleardns.bat`) 45 | - Skip installation scripts 46 | 47 | - The certificates will be placed in `C:\ProgramData\letsencrypt-win-simple\httpsacme-v01.api.letsencrypt.org`: 48 | 49 | ``` 50 | CA: 'ca-xxx.duckdns.org-crt.pem' 51 | CERT: 'xxx.duckdns.org-crt.pem' 52 | KEY: 'xxx.duckdns.org-key.pem' 53 | ``` 54 | 55 | The certificates expire after 90 days but they are automatically renewed by Scheduled Task. 56 | Once they are renewed you have to recopy them and restart the server. 57 | 58 | ## Installation and configuration 59 | 60 | - Clone source: `git clone https://github.com/nitzzzu/alexa-local-skills` 61 | - Copy the SSL certificates (CA, CERT, KEY) in `./sslcert` folder 62 | - Deploy skills on Amazon: 63 | 64 | - It is required to have an Amazon developer account: [Alexa console](https://developer.amazon.com/alexa/console/ask) 65 | - Install Alexa CLI: `npm install -g ask-cli` 66 | - Link to your developer account: `ask init` (choose: `No. Skip AWS credentials association step.` when asked) 67 | - Deloy skills: 68 | - Skills schemas are places in `schemas` folder 69 | - Change url in skill manifest file `skill.json` (replace `https://xxx.duckdns.org/remote` with your duckdns account) 70 | - To deploy them run `deploy.bat` in each folder from `schemas`. 71 | - Adapt skill ids in `config.js` (skill ids can be found in `.ask` folder) 72 | 73 | - Create configuration file `config.js`: 74 | 75 | ``` 76 | module.exports = { 77 | REMOTE_SKILL_ID: '[SKILL_ID]', 78 | SUBSONIC_SKILL_ID: '[SKILL_ID]', 79 | SUBSONICSERVER: 'http://192.168.1.1:4040', 80 | SUBSONICUSERNAME: '', 81 | SUBSONICPASSWORD: '', 82 | SSLCERTIFICATECA: './sslcert/ca-xxx.duckdns.org-crt.pem', 83 | SSLCERTIFICATECERT: './sslcert/xxx.duckdns.org-crt.pem', 84 | SSLCERTIFICATEKEY: './sslcert/xxx.duckdns.org-key.pem', 85 | STREAMURL: 'https://xxx.duckdns.org/stream?q=' 86 | }; 87 | ``` 88 | 89 | - Install: `npm install` 90 | - (OPTIONAL) Install as windows service: `node winservice install` 91 | - Start the service OR `node server.js` 92 | 93 | ## Skill usage 94 | 95 | > Alexa, ask subsonic to play eminem 96 | > Alexa, ask remote turn on TV 97 | -------------------------------------------------------------------------------- /install-service.bat: -------------------------------------------------------------------------------- 1 | node winservice install -------------------------------------------------------------------------------- /logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const fs = require('fs'); 3 | 4 | const timestampFormat = () => new Date().toLocaleTimeString(); 5 | const logsDir = 'logs'; 6 | 7 | if (!fs.existsSync(logsDir)) { 8 | fs.mkdirSync(logsDir); 9 | } 10 | 11 | winston.add(winston.transports.File, { 12 | filename: `${logsDir}/log.log`, 13 | timestamp: timestampFormat, 14 | json: false, 15 | level: 'info' 16 | }); 17 | 18 | winston.remove(winston.transports.Console); 19 | winston.add(winston.transports.Console, { 20 | timestamp: timestampFormat, 21 | colorize: true, 22 | level: 'info' 23 | }); 24 | 25 | module.exports = winston; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alexa-subsonic-skill", 3 | "version": "1.0.0", 4 | "description": "Alexa skill to play music from local Subsonic server", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/nitzzzu/alexa-subsonic-skill.git" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/nitzzzu/alexa-subsonic-skill/issues" 17 | }, 18 | "homepage": "https://github.com/nitzzzu/alexa-subsonic-skill#readme", 19 | "dependencies": { 20 | "ask-sdk": "^2.3.0", 21 | "body-parser": "^1.18.2", 22 | "broadlinkjs-rm": "^0.6.0", 23 | "cors": "^2.8.4", 24 | "express": "^4.16.2", 25 | "md5": "^2.2.1", 26 | "querystring": "^0.2.0", 27 | "request": "^2.88.0", 28 | "request-promise-native": "^1.0.5", 29 | "winston": "^2.4.1" 30 | }, 31 | "optionalDependencies": { 32 | "node-windows": "^0.1.14" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /run.bat: -------------------------------------------------------------------------------- 1 | node server.js 2 | pause -------------------------------------------------------------------------------- /schemas/remote/.ask/config: -------------------------------------------------------------------------------- 1 | { 2 | "deploy_settings": { 3 | "default": { 4 | "skill_id": "", 5 | "was_cloned": false 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /schemas/remote/deploy.bat: -------------------------------------------------------------------------------- 1 | ask deploy 2 | pause -------------------------------------------------------------------------------- /schemas/remote/models/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "interactionModel": { 3 | "languageModel": { 4 | "invocationName": "remote", 5 | "types": [], 6 | "intents": [ 7 | { 8 | "name": "AMAZON.CancelIntent", 9 | "samples": [] 10 | }, 11 | { 12 | "name": "AMAZON.HelpIntent", 13 | "samples": [] 14 | }, 15 | { 16 | "name": "AMAZON.StopIntent", 17 | "samples": [] 18 | }, 19 | { 20 | "name": "TurnOnIntent", 21 | "slots": [ 22 | { 23 | "name": "device", 24 | "type": "AMAZON.SearchQuery" 25 | } 26 | ], 27 | "samples": ["turn on {device}", "power on {device}"] 28 | } 29 | ] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /schemas/remote/skill.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest": { 3 | "publishingInformation": { 4 | "locales": { 5 | "en-US": { 6 | "summary": "Alexa remote", 7 | "examplePhrases": ["Alexa, ask remote turn on TV"], 8 | "name": "remote" 9 | } 10 | } 11 | }, 12 | "apis": { 13 | "custom": { 14 | "endpoint": { 15 | "sslCertificateType": "Trusted", 16 | "uri": "https://xxx.duckdns.org/remote" 17 | } 18 | } 19 | }, 20 | "manifestVersion": "1.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /schemas/subsonic/.ask/config: -------------------------------------------------------------------------------- 1 | { 2 | "deploy_settings": { 3 | "default": { 4 | "skill_id": "", 5 | "was_cloned": false 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /schemas/subsonic/deploy.bat: -------------------------------------------------------------------------------- 1 | ask deploy 2 | pause -------------------------------------------------------------------------------- /schemas/subsonic/models/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "interactionModel": { 3 | "languageModel": { 4 | "invocationName": "subsonic", 5 | "intents": [ 6 | { 7 | "name": "AMAZON.CancelIntent", 8 | "samples": [] 9 | }, 10 | { 11 | "name": "AMAZON.HelpIntent", 12 | "samples": [] 13 | }, 14 | { 15 | "name": "AMAZON.StopIntent", 16 | "samples": [] 17 | }, 18 | { 19 | "name": "SearchIntent", 20 | "slots": [ 21 | { 22 | "name": "SearchQuery", 23 | "type": "VIDEOS" 24 | } 25 | ], 26 | "samples": [ 27 | "put on {SearchQuery}", 28 | "start playing {SearchQuery}", 29 | "play {SearchQuery}", 30 | "search for {SearchQuery}", 31 | "find {SearchQuery}" 32 | ] 33 | }, 34 | { 35 | "name": "AMAZON.PauseIntent", 36 | "samples": [] 37 | }, 38 | { 39 | "name": "AMAZON.ResumeIntent", 40 | "samples": [] 41 | }, 42 | { 43 | "name": "AMAZON.RepeatIntent", 44 | "samples": [] 45 | }, 46 | { 47 | "name": "AMAZON.LoopOnIntent", 48 | "samples": [] 49 | }, 50 | { 51 | "name": "AMAZON.LoopOffIntent", 52 | "samples": [] 53 | } 54 | ], 55 | "types": [ 56 | { 57 | "values": [ 58 | { 59 | "name": { 60 | "value": "4 hours Peaceful and Relaxing Instrumental Music" 61 | } 62 | }, 63 | { 64 | "name": { 65 | "value": "eminem" 66 | } 67 | }, 68 | { 69 | "name": { 70 | "value": "let me be your fantasy" 71 | } 72 | }, 73 | { 74 | "name": { 75 | "value": "DJ jazzy jeff and the fresh prince" 76 | } 77 | }, 78 | { 79 | "name": { 80 | "value": "john travolta and olivia newton john" 81 | } 82 | }, 83 | { 84 | "name": { 85 | "value": "KC and the sunshine band" 86 | } 87 | }, 88 | { 89 | "name": { 90 | "value": "toad the wet sproket" 91 | } 92 | }, 93 | { 94 | "name": { 95 | "value": "the rolling stones" 96 | } 97 | }, 98 | { 99 | "name": { 100 | "value": "the fray" 101 | } 102 | }, 103 | { 104 | "name": { 105 | "value": "prince" 106 | } 107 | } 108 | ], 109 | "name": "VIDEOS" 110 | } 111 | ] 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /schemas/subsonic/skill.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest": { 3 | "publishingInformation": { 4 | "locales": { 5 | "en-US": { 6 | "name": "subsonic" 7 | } 8 | } 9 | }, 10 | "apis": { 11 | "custom": { 12 | "endpoint": { 13 | "sslCertificateType": "Trusted", 14 | "uri": "https://xxx.duckdns.org/subsonic" 15 | }, 16 | "interfaces": [ 17 | { 18 | "type": "AUDIO_PLAYER" 19 | } 20 | ] 21 | } 22 | }, 23 | "manifestVersion": "1.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const express = require('express'); 3 | const cors = require('cors'); 4 | const fs = require('fs'); 5 | const bodyParser = require('body-parser'); 6 | const request = require('request'); 7 | const logger = require('./logger.js'); 8 | 9 | const config = require('./config'); 10 | 11 | const subsonic = require('./subsonic'); 12 | const subsonicSkill = require('./skills/subsonic/index'); 13 | const remoteSkill = require('./skills/remote/index'); 14 | 15 | const app = express(); 16 | app.use(cors()); 17 | app.options('*', cors()); 18 | app.use(bodyParser.json()); 19 | app.use( 20 | bodyParser.urlencoded({ 21 | extended: true 22 | }) 23 | ); 24 | 25 | // app.get('/', (req, res) => res.sendStatus(200)); 26 | 27 | app.get('/stream', async (req, res) => { 28 | let options = await subsonic.streamUrl(req.query.q); 29 | 30 | let r = request(options); 31 | r.on('response', function(res1) { 32 | res1.pipe(res); 33 | }); 34 | }); 35 | 36 | app.post('/subsonic', (req, res) => { 37 | subsonicSkill 38 | .invoke(req.body) 39 | .then(function(responseBody) { 40 | res.json(responseBody); 41 | }) 42 | .catch(function(error) { 43 | console.log(error); 44 | res.status(500).send('Error during the request'); 45 | }); 46 | }); 47 | 48 | app.post('/remote', (req, res) => { 49 | remoteSkill 50 | .invoke(req.body) 51 | .then(function(responseBody) { 52 | res.json(responseBody); 53 | }) 54 | .catch(function(error) { 55 | console.log(error); 56 | res.status(500).send('Error during the request'); 57 | }); 58 | }); 59 | 60 | subsonic.open(config.SUBSONICSERVER, config.SUBSONICUSERNAME, config.SUBSONICPASSWORD); 61 | 62 | // app.listen(444, () => { 63 | // logger.info('Server started'); 64 | // }); 65 | 66 | https 67 | .createServer( 68 | { 69 | //ca: fs.readFileSync(config.SSLCERTIFICATECA), 70 | cert: fs.readFileSync(config.SSLCERTIFICATECHAIN), 71 | key: fs.readFileSync(config.SSLCERTIFICATEKEY) 72 | }, 73 | app 74 | ) 75 | .listen(443, () => { 76 | logger.info('Skill started'); 77 | }); 78 | -------------------------------------------------------------------------------- /skills/remote/convertProntoCode.js: -------------------------------------------------------------------------------- 1 | const prontoToLIRC = (prontoCode, log) => { 2 | const prontoArr = prontoCode.split(' '); 3 | 4 | const intArr = prontoArr.map((item) => { 5 | return parseInt(`0x${item}`); 6 | }); 7 | 8 | 9 | if (intArr[0]) { 10 | log(`Pronto code should start with 0000`); 11 | 12 | return; 13 | } 14 | 15 | if (intArr.length != (4 + 2 * (intArr[2] + intArr[3]))) { 16 | return log(`Pronto code is invalid`); 17 | 18 | return; 19 | } 20 | 21 | const frequency = 1 / (intArr[1] * 0.241246); 22 | 23 | const lircArr = intArr.map((item) => { 24 | return parseInt(Math.round(item / frequency)) 25 | }).slice(4); 26 | 27 | return lircArr; 28 | } 29 | 30 | const lircToBroadlink = (pulses, log) => { 31 | const pulseArr = [ ]; 32 | 33 | pulses.forEach((pulse) => { 34 | pulse = parseInt(pulse * 269 / 8192); 35 | 36 | if (pulse < 256) { 37 | pulseArr.push(pulse.toString(16).padStart(2, '0')); 38 | } else { 39 | pulseArr.push('00'); 40 | 41 | const twoBytes = pulse.toString(16).padStart(4, '0'); 42 | 43 | pulseArr.push(twoBytes[0] + twoBytes[1]); 44 | pulseArr.push(twoBytes[2] + twoBytes[3]); 45 | } 46 | }); 47 | 48 | let finalArr = [ '26', '00' ]; 49 | 50 | const count = pulseArr.length; 51 | const twoBytes = count.toString(16).padEnd(4, '0'); 52 | finalArr.push(twoBytes[0] + twoBytes[1]); 53 | finalArr.push(twoBytes[2] + twoBytes[3]); 54 | 55 | finalArr = finalArr.concat(pulseArr); 56 | finalArr.push('0d'); 57 | finalArr.push('05'); 58 | 59 | const remainder = (finalArr.length + 4) % 16; 60 | 61 | let finalHex = finalArr.join(''); 62 | finalHex = finalHex.padEnd(finalHex.length + ((16 - remainder) * 2), '0'); 63 | 64 | return finalHex; 65 | } 66 | 67 | const convertProntoToBroadlink = (prontoCode, log) => { 68 | const lircPulses = prontoToLIRC(prontoCode, log); 69 | 70 | if (!lircPulses) return 71 | 72 | const broadlinkCode = lircToBroadlink(lircPulses, log); 73 | 74 | return broadlinkCode; 75 | } 76 | 77 | module.exports = convertProntoToBroadlink -------------------------------------------------------------------------------- /skills/remote/index.js: -------------------------------------------------------------------------------- 1 | const Alexa = require('ask-sdk-core'); 2 | const logger = require('winston'); 3 | const config = require('../../config'); 4 | const BroadlinkJS = require('broadlinkjs-rm'); 5 | const convertProntoCode = require('./convertProntoCode'); 6 | 7 | const LaunchRequestHandler = { 8 | canHandle(handlerInput) { 9 | return handlerInput.requestEnvelope.request.type === 'LaunchRequest'; 10 | }, 11 | handle(handlerInput) { 12 | return handlerInput.responseBuilder.speak('Welcome to the Alexa Local Skills!').getResponse(); 13 | } 14 | }; 15 | 16 | const TurnOnIntentHandler = { 17 | canHandle(handlerInput) { 18 | return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'TurnOnIntent'; 19 | }, 20 | handle(handlerInput) { 21 | let device = handlerInput.requestEnvelope.request.intent.slots.device.value; 22 | 23 | logger.info('Turning on ' + device); 24 | 25 | let broadlink = new BroadlinkJS(); 26 | broadlink.discover(); 27 | 28 | broadlink.on('deviceReady', device => { 29 | // const macAddressParts = device.mac.toString('hex').match(/[\s\S]{1,2}/g) || []; 30 | // const macAddress = macAddressParts.join(':'); 31 | // device.host.macAddress = macAddress; 32 | 33 | let hexData = 34 | '0000 006C 0022 0002 015B 00AD 0016 0016 0016 0016 0016 0041 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0016 0041 0016 0041 0016 0016 0016 0041 0016 0041 0016 0041 0016 0041 0016 0041 0016 0016 0016 0016 0016 0016 0016 0041 0016 0016 0016 0016 0016 0016 0016 0016 0016 0041 0016 0041 0016 0041 0016 0016 0016 0041 0016 0041 0016 0041 0016 0041 0016 05F7 015B 0057 0016 0E6C'; 35 | if (hexData.substring(0, 4) === '0000') { 36 | hexData = convertProntoCode(hexData); 37 | } 38 | 39 | const hexDataBuffer = new Buffer(hexData, 'hex'); 40 | device.sendData(hexDataBuffer, false, hexData); 41 | }); 42 | 43 | return handlerInput.responseBuilder.speak(`Turning on ${device}`).getResponse(); 44 | } 45 | }; 46 | 47 | const HelpIntentHandler = { 48 | canHandle(handlerInput) { 49 | return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent'; 50 | }, 51 | handle(handlerInput) { 52 | return handlerInput.responseBuilder.speak(`You can say 'Turn On TV'`).getResponse(); 53 | } 54 | }; 55 | 56 | const CancelAndStopIntentHandler = { 57 | canHandle(handlerInput) { 58 | return ( 59 | handlerInput.requestEnvelope.request.type === 'IntentRequest' && 60 | (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent' || 61 | handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent') 62 | ); 63 | }, 64 | handle(handlerInput) { 65 | return handlerInput.responseBuilder.speak('Goodbye!').getResponse(); 66 | } 67 | }; 68 | 69 | const SessionEndedRequestHandler = { 70 | canHandle(handlerInput) { 71 | return handlerInput.requestEnvelope.request.type === 'SessionEndedRequest'; 72 | }, 73 | handle(handlerInput) { 74 | console.log(`Session ended with reason: ${handlerInput.requestEnvelope.request.reason}`); 75 | 76 | return handlerInput.responseBuilder.getResponse(); 77 | } 78 | }; 79 | 80 | const ErrorHandler = { 81 | canHandle() { 82 | return true; 83 | }, 84 | handle(handlerInput, error) { 85 | console.log(`Error handled: ${error.message}`); 86 | 87 | return handlerInput.responseBuilder.speak("Sorry, I can't understand the command. Please say again.").getResponse(); 88 | } 89 | }; 90 | 91 | module.exports = Alexa.SkillBuilders.custom() 92 | .addRequestHandlers(LaunchRequestHandler, TurnOnIntentHandler, HelpIntentHandler, CancelAndStopIntentHandler, SessionEndedRequestHandler) 93 | .addErrorHandlers(ErrorHandler) 94 | .withSkillId(config.REMOTE_SKILL_ID) 95 | .create(); 96 | -------------------------------------------------------------------------------- /skills/subsonic/index.js: -------------------------------------------------------------------------------- 1 | // based on https://github.com/alexa/skill-sample-nodejs-audio-player/blob/mainline/multiple-streams/lambda/src/index.js 2 | const Alexa = require('ask-sdk-core'); 3 | const querystring = require('querystring'); 4 | const logger = require('winston'); 5 | const config = require('../../config'); 6 | 7 | let lastSearch; 8 | let lastPlaybackStart; 9 | let lastPlaybackStop; 10 | let repeatEnabled = false; 11 | 12 | const SearchIntentHandler = { 13 | canHandle(handlerInput) { 14 | return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'SearchIntent'; 15 | }, 16 | handle(handlerInput) { 17 | let query = handlerInput.requestEnvelope.request.intent.slots.SearchQuery.value; 18 | 19 | logger.info('Searching ... ' + query); 20 | 21 | lastSearch = config.STREAMURL + querystring.escape(query); 22 | lastPlaybackStart = new Date().getTime(); 23 | return handlerInput.responseBuilder 24 | .speak('Playing song') 25 | .addAudioPlayerPlayDirective('REPLACE_ALL', lastSearch, 'myMusic', undefined, 0) 26 | .getResponse(); 27 | } 28 | }; 29 | 30 | const LaunchRequestHandler = { 31 | canHandle(handlerInput) { 32 | return handlerInput.requestEnvelope.request.type === 'LaunchRequest'; 33 | }, 34 | handle(handlerInput) { 35 | return handlerInput.responseBuilder.speak('I can play your local music hosted on Subsonic').getResponse(); 36 | } 37 | }; 38 | 39 | const HelpIntentHandler = { 40 | canHandle(handlerInput) { 41 | return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent'; 42 | }, 43 | handle(handlerInput) { 44 | return handlerInput.responseBuilder.speak('I can play your local music hosted on Subsonic').getResponse(); 45 | } 46 | }; 47 | 48 | const AudioPlayerHandler = { 49 | canHandle(handlerInput) { 50 | return handlerInput.requestEnvelope.request.type.startsWith('AudioPlayer.'); 51 | }, 52 | async handle(handlerInput) { 53 | const { requestEnvelope, responseBuilder } = handlerInput; 54 | 55 | switch (requestEnvelope.request.type) { 56 | case 'AudioPlayer.PlaybackStarted': 57 | logger.info('Alexa begins playing the audio stream'); 58 | break; 59 | 60 | case 'AudioPlayer.PlaybackFinished': 61 | logger.info('The stream comes to an end'); 62 | if (repeatEnabled && lastSearch) { 63 | logger.info('Repeat was enabled. Playing ' + lastSearch + ' again ...'); 64 | lastPlaybackStart = new Date().getTime(); 65 | responseBuilder.addAudioPlayerPlayDirective('REPLACE_ALL', lastSearch, 'myMusic', undefined, 0); 66 | } 67 | break; 68 | 69 | case 'AudioPlayer.PlaybackStopped': 70 | logger.info('Alexa stops playing the audio stream'); 71 | break; 72 | 73 | case 'AudioPlayer.PlaybackNearlyFinished': 74 | logger.info('The currently playing stream is nearly complate and the device is ready to receive a new stream'); 75 | break; 76 | 77 | case 'AudioPlayer.PlaybackFailed': 78 | logger.info('Alexa encounters an error when attempting to play a stream'); 79 | break; 80 | 81 | default: 82 | throw new Error('Should never reach here!'); 83 | } 84 | 85 | return responseBuilder.getResponse(); 86 | } 87 | }; 88 | 89 | const PauseIntentHandler = { 90 | async canHandle(handlerInput) { 91 | return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.PauseIntent'; 92 | }, 93 | handle(handlerInput) { 94 | lastPlaybackStop = new Date().getTime(); 95 | return handlerInput.responseBuilder.addAudioPlayerStopDirective().getResponse(); 96 | } 97 | }; 98 | 99 | const ResumeIntentHandler = { 100 | async canHandle(handlerInput) { 101 | return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.ResumeIntent'; 102 | }, 103 | handle(handlerInput) { 104 | if (lastSearch === undefined) { 105 | return handlerInput.responseBuilder.speak('Nothing to resume!').getResponse(); 106 | } else { 107 | return handlerInput.responseBuilder.addAudioPlayerPlayDirective( 108 | 'REPLACE_ALL', 109 | lastSearch, 110 | 'myMusic', 111 | undefined, 112 | lastPlaybackStop - lastPlaybackStart 113 | ); 114 | } 115 | } 116 | }; 117 | 118 | const RepeatIntentHandler = { 119 | async canHandle(handlerInput) { 120 | return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.RepeatIntent'; 121 | }, 122 | handle(handlerInput) { 123 | if (lastSearch === undefined) { 124 | return handlerInput.responseBuilder.speak('Nothing to repeat!').getResponse(); 125 | } else { 126 | lastPlaybackStart = new Date().getTime(); 127 | return handlerInput.responseBuilder.addAudioPlayerPlayDirective('REPLACE_ALL', lastSearch, 'myMusic', undefined, 0); 128 | } 129 | } 130 | }; 131 | 132 | const LoopOnIntentHandler = { 133 | async canHandle(handlerInput) { 134 | return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.LoopOnIntent'; 135 | }, 136 | handle(handlerInput) { 137 | logger.info('Repeat enabled.'); 138 | repeatEnabled = true; 139 | return handlerInput.responseBuilder.speak('Repeat enabled').getResponse(); 140 | } 141 | }; 142 | 143 | const LoopOffIntentHandler = { 144 | async canHandle(handlerInput) { 145 | return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.LoopOffIntent'; 146 | }, 147 | handle(handlerInput) { 148 | logger.info('Repeat disabled.'); 149 | repeatEnabled = false; 150 | return handlerInput.responseBuilder.speak('Repeat disabled').getResponse(); 151 | } 152 | }; 153 | 154 | const StopIntentHandler = { 155 | async canHandle(handlerInput) { 156 | return handlerInput.requestEnvelope.request.type === 'IntentRequest' && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent'; 157 | }, 158 | handle(handlerInput) { 159 | lastSearch = undefined; 160 | return handlerInput.responseBuilder.addAudioPlayerStopDirective().getResponse(); 161 | } 162 | }; 163 | 164 | const SessionEndedRequestHandler = { 165 | canHandle(handlerInput) { 166 | return handlerInput.requestEnvelope.request.type === 'SessionEndedRequest'; 167 | }, 168 | handle(handlerInput) { 169 | console.log(`Session ended with reason: ${handlerInput.requestEnvelope.request.reason}`); 170 | 171 | return handlerInput.responseBuilder.getResponse(); 172 | } 173 | }; 174 | 175 | const ErrorHandler = { 176 | canHandle() { 177 | return true; 178 | }, 179 | handle(handlerInput, error) { 180 | console.log(`Error handled: ${error.message}`); 181 | 182 | return handlerInput.responseBuilder.speak("Sorry, I can't understand the command. Please say again.").getResponse(); 183 | } 184 | }; 185 | 186 | module.exports = Alexa.SkillBuilders.custom() 187 | .addRequestHandlers( 188 | LaunchRequestHandler, 189 | SearchIntentHandler, 190 | HelpIntentHandler, 191 | AudioPlayerHandler, 192 | PauseIntentHandler, 193 | ResumeIntentHandler, 194 | RepeatIntentHandler, 195 | LoopOnIntentHandler, 196 | LoopOffIntentHandler, 197 | StopIntentHandler, 198 | SessionEndedRequestHandler 199 | ) 200 | .addErrorHandlers(ErrorHandler) 201 | .withSkillId(config.SUBSONIC_SKILL_ID) 202 | .create(); 203 | -------------------------------------------------------------------------------- /subsonic.js: -------------------------------------------------------------------------------- 1 | // http://www.subsonic.org/pages/api.jsp 2 | 3 | const rp = require('request-promise-native'); 4 | const md5 = require('md5'); 5 | 6 | let subsonic = { 7 | server: '', 8 | username: '', 9 | salt: '', 10 | authtoken: '', 11 | 12 | getOptions: function(uri, params) { 13 | let qs = Object.assign(params || {}, { 14 | v: '1.15.0', 15 | f: 'json', 16 | c: 'alexa', 17 | u: this.username, 18 | s: this.salt, 19 | t: this.authtoken 20 | }); 21 | 22 | let options = { 23 | uri, 24 | qs, 25 | json: true 26 | }; 27 | return options; 28 | }, 29 | 30 | execute: function(method, params) { 31 | let uri = `${this.server}/rest/${method}.view`; 32 | 33 | try { 34 | return rp(this.getOptions(uri, params)); 35 | } catch (e) {} 36 | }, 37 | 38 | open: function(server, username, password) { 39 | this.server = server; 40 | this.username = username; 41 | 42 | this.salt = Math.random() 43 | .toString(36) 44 | .replace(/[^a-z]+/g, ''); 45 | this.authtoken = md5(password + this.salt); 46 | }, 47 | 48 | ping: async function() { 49 | return await this.execute('ping'); 50 | }, 51 | 52 | search: async function(search, useID3, params) { 53 | let method = useID3 ? 'search3' : 'search2'; 54 | if (!params) { 55 | params = {}; 56 | } 57 | params.query = search; 58 | return await this.execute(method, params); 59 | }, 60 | 61 | streamUrl: async function(search) { 62 | let response = await subsonic.search(search, true); 63 | if (response['subsonic-response'] && response['subsonic-response'].searchResult3) { 64 | let uri = `${this.server}/rest/stream?id=${response['subsonic-response'].searchResult3.song[0].id}`; 65 | return this.getOptions(uri); 66 | } 67 | return ''; 68 | } 69 | }; 70 | 71 | module.exports = subsonic; 72 | -------------------------------------------------------------------------------- /uninstall-service.bat: -------------------------------------------------------------------------------- 1 | node winservice uninstall -------------------------------------------------------------------------------- /winservice.js: -------------------------------------------------------------------------------- 1 | const Service = require('node-windows').Service; 2 | 3 | let svc = new Service({ 4 | name: 'AlexaSkills', 5 | description: 'Alexa Skills', 6 | script: require('path').join(__dirname, 'server.js'), 7 | maxRetries: 3 8 | }); 9 | 10 | svc.on('install', function() { 11 | console.log('Install complete. Starting...'); 12 | svc.start(); 13 | console.log('Service started'); 14 | }); 15 | 16 | svc.on('uninstall', function() { 17 | console.log('Uninstall complete.'); 18 | console.log('The service exists: ', svc.exists); 19 | }); 20 | 21 | const args = process.argv.slice(2); 22 | if (args[0] == 'install') { 23 | svc.install(); 24 | } 25 | if (args[0] == 'uninstall') { 26 | svc.uninstall(); 27 | } 28 | --------------------------------------------------------------------------------