diff --git a/README.md b/README.md index 348da11..0066cc9 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,23 @@ Thank you for that work. * docu docu docu (sorry ... will come) ## Changelog: + +### __WORK IN PROGRESS__ +* (Apollon77) Query API endpoints (including new method getEndpoints()) from Amazon on start and use this API endpoint for the calls +* (Apollon77) Enhance getDevicePreferences() to request preferences for one device +* (Apollon77) Add setDevicePreferences() to set the device preferences for a device +* (Apollon77) Add getDeviceWifiDetails() to get the Wifi definitions (including SSID and MAC) for a device +* (Apollon77) Load Device Preferences on startup and make accessible via device.preferences on the device objects +* (Apollon77) Add methods getDevicePreferences() and setDevicePreferences() to the alexa class and to the device objects +* (Apollon77) Add new Media Message "jump" (in sendMessage() method) with a mediaId as value (can be used to jump to another queue item) +* (Apollon77) Add getRoutineSoundList() to query available sound IDs for a routine +* (Apollon77) Add new command "sound" when creating/sending sequence nodes to play sounds +* (Apollon77) Add method getWholeHomeAudioGroups() to query information about the current Audio groups +* (Apollon77) Enhance sending "notification" sequence node to allow providing an object as value with keys title and text to specify the title for displaying the message too +* (Apollon77) Add setEnablementForSmarthomeDevice() to enable/disable a smart home device +* (Apollon77) Log Response with status code also when no callback is provided (but without body content) +* (Apollon77) Slightly adjust the calculated timeout when getting many smart home device values + ### 5.1.0 (2022-07-04) * (Apollon77) Detect Rate limit exceeded response and do one automatic request retry 10s later (plus a random part) * (Apollon77) Calculate the timeout of querySmarthomeDevices dynamically between 10s and 60s (maximum overrideable by new optional parameter) depending on the number of devices to query diff --git a/alexa-remote.d.ts b/alexa-remote.d.ts index 0739d72..f864c10 100644 --- a/alexa-remote.d.ts +++ b/alexa-remote.d.ts @@ -80,6 +80,11 @@ declare module "alexa-remote2" { export type Value = string | number | boolean; + export type SequenceValue = Value | { + title: string + text: string + }; + export type Sound = { displayName: string; folder: string; @@ -165,7 +170,8 @@ declare module "alexa-remote2" { | "rewind" | "volume" | "shuffle" - | "repeat"; + | "repeat" + | "jump"; export type SequenceNodeCommand = | "weather" @@ -196,7 +202,7 @@ declare module "alexa-remote2" { export type SequenceNodeDetails = { command: SequenceNodeCommand; - value: Value; + value: SequenceValue; device?: SerialOrNameOrArray; } @@ -315,6 +321,8 @@ declare module "alexa-remote2" { getSkills(callback: CallbackWithErrorAndBody): void; + getRoutineSoundList(callback: CallbackWithErrorAndBody): void; + createNotificationObject( serialOrName: SerialOrName, type: string, @@ -427,7 +435,7 @@ declare module "alexa-remote2" { createSequenceNode( command: SequenceNodeCommand, - value: Value, + value: SequenceValue, serialOrName: SerialOrNameOrArray, overrideCustomerId?: string ): void; @@ -450,7 +458,7 @@ declare module "alexa-remote2" { sendSequenceCommand( serialOrName: SerialOrNameOrArray, command: SequenceNodeCommand, - value: Value, + value: SequenceValue, overrideCustomerId?: string | CallbackWithErrorAndBody, callback?: CallbackWithErrorAndBody ): void; @@ -495,7 +503,20 @@ declare module "alexa-remote2" { getHomeGroup(callback: CallbackWithErrorAndBody): void; - getDevicePreferences(callback: CallbackWithErrorAndBody): void; + getDevicePreferences( + serialOrName: SerialOrName | CallbackWithErrorAndBody, + callback?: CallbackWithErrorAndBody + ): void; + + setDevicePreferences( + serialOrName: SerialOrName, + preferences: Record, + callback: CallbackWithErrorAndBody + ): void; + + getDeviceWifiDetails(serialOrName: SerialOrName, callback: CallbackWithErrorAndBody): void; + + getAllDoNotDisturbDeviceStatus(callback: CallbackWithErrorAndBody): void; getAllDeviceVolumes(callback: CallbackWithErrorAndBody): void; @@ -520,6 +541,12 @@ declare module "alexa-remote2" { callback: CallbackWithErrorAndBody ): void; + setEnablementForSmarthomeDevice( + smarthomeDevice: string, + enabled: boolean, + callback: CallbackWithErrorAndBody + ): void; + deleteSmarthomeGroup( smarthomeGroup: string, callback: CallbackWithErrorAndBody @@ -559,5 +586,9 @@ declare module "alexa-remote2" { isWsMqttConnected(): boolean; stopProxyServer(callback: CallbackWithError): void + + getWholeHomeAudioGroups(callback: CallbackWithErrorAndBody): void + + getEndpoints(callback: CallbackWithErrorAndBody): void } } diff --git a/alexa-remote.js b/alexa-remote.js index c4f26ef..cba4f50 100755 --- a/alexa-remote.js +++ b/alexa-remote.js @@ -29,6 +29,7 @@ class AlexaRemote extends EventEmitter { this.cookieData = null; this.authenticationDetails = null; this.ownerCustomerId = null; + this.endpoints = null; this.baseUrl = 'alexa.amazon.de'; } @@ -171,11 +172,20 @@ class AlexaRemote extends EventEmitter { this.init(this._options, callback); }, this._options.cookieRefreshInterval); } - this.prepare(() => { - if (this._options.useWsMqtt) { - this.initWsMqttConnection(); + this.getEndpoints((err, endpoints) => { + if (!err && endpoints && endpoints.websiteApiUrl) { + this.endpoints = endpoints; + this.baseUrl = this.endpoints.websiteApiUrl.replace(/^https?:\/\//, ''); + this._options.logger && this._options.logger('Alexa-Remote: Change Base URL for API calls to ' + this.baseUrl); + } else { + this._options.logger && this._options.logger('Alexa-Remote: Could not query endpoints: ' + err); } - callback && callback(); + this.prepare(() => { + if (this._options.useWsMqtt) { + this.initWsMqttConnection(); + } + callback && callback(); + }); }); }); }); @@ -252,62 +262,81 @@ class AlexaRemote extends EventEmitter { initDeviceState(callback) { this.getDevices((err, result) => { if (!err && result && Array.isArray(result.devices)) { - const customerIds = {}; - const joinedDevices = []; - - result.devices.forEach((device) => { - joinedDevices.push(device); - if (device.appDeviceList && device.appDeviceList.length) { - device.appDeviceList.forEach(subDevice => { - const appDevice = Object.assign({}, device, subDevice); - appDevice.parentDeviceSerialNumber = device.serialNumber; - appDevice.appDeviceList = []; - joinedDevices.push(appDevice); + this.getDevicePreferences((err, devicePrefs) => { + const devicePreferences = {}; + if (!err && devicePrefs && devicePrefs.devicePreferences && Array.isArray(devicePrefs.devicePreferences)) { + devicePrefs.devicePreferences.forEach(pref => { + devicePreferences[pref.deviceSerialNumber] = pref; }); } - }); - joinedDevices.forEach((device) => { - const existingDevice = this.find(device.serialNumber); - if (!existingDevice) { - this.serialNumbers[device.serialNumber] = device; - } - else { - device = extend(true, existingDevice, device); - } - let name = device.accountName; - this.names [name] = device; - this.names [name.toLowerCase()] = device; - if (device.deviceTypeFriendlyName) { - name += ` (${device.deviceTypeFriendlyName})`; + const customerIds = {}; + const joinedDevices = []; + + result.devices.forEach((device) => { + joinedDevices.push(device); + if (device.appDeviceList && device.appDeviceList.length) { + device.appDeviceList.forEach(subDevice => { + const appDevice = Object.assign({}, device, subDevice); + appDevice.parentDeviceSerialNumber = device.serialNumber; + appDevice.appDeviceList = []; + joinedDevices.push(appDevice); + }); + } + }); + + joinedDevices.forEach((device) => { + const existingDevice = this.find(device.serialNumber); + if (!existingDevice) { + this.serialNumbers[device.serialNumber] = device; + } + else { + device = extend(true, existingDevice, device); + } + + if (devicePreferences[device.serialNumber]) { + device.preferences = devicePreferences[device.serialNumber]; + } + + let name = device.accountName; this.names [name] = device; this.names [name.toLowerCase()] = device; - } - //device._orig = JSON.parse(JSON.stringify(device)); - device._name = name; - device.sendCommand = this.sendCommand.bind(this, device); - device.setTunein = this.setTunein.bind(this, device); - device.rename = this.renameDevice.bind(this, device); - device.setDoNotDisturb = this.setDoNotDisturb.bind(this, device); - device.delete = this.deleteDevice.bind(this, device); - if (device.deviceTypeFriendlyName) this.friendlyNames[device.deviceTypeFriendlyName] = device; - if (customerIds[device.deviceOwnerCustomerId] === undefined) customerIds[device.deviceOwnerCustomerId] = 0; - customerIds[device.deviceOwnerCustomerId] += 1; - device.isControllable = ( - device.capabilities.includes('AUDIO_PLAYER') || - device.capabilities.includes('AMAZON_MUSIC') || - device.capabilities.includes('TUNE_IN') - ); - device.hasMusicPlayer = ( - device.capabilities.includes('AUDIO_PLAYER') || - device.capabilities.includes('AMAZON_MUSIC') - ); - device.isMultiroomDevice = (device.clusterMembers.length > 0); - device.isMultiroomMember = (device.parentClusters.length > 0); + if (device.deviceTypeFriendlyName) { + name += ` (${device.deviceTypeFriendlyName})`; + this.names [name] = device; + this.names [name.toLowerCase()] = device; + } + //device._orig = JSON.parse(JSON.stringify(device)); + device._name = name; + device.sendCommand = this.sendCommand.bind(this, device); + device.setTunein = this.setTunein.bind(this, device); + device.rename = this.renameDevice.bind(this, device); + device.setDoNotDisturb = this.setDoNotDisturb.bind(this, device); + device.delete = this.deleteDevice.bind(this, device); + device.getDevicePreferences = this.getDevicePreferences.bind(this, device); + device.setDevicePreferences = this.setDevicePreferences.bind(this, device); + if (device.deviceTypeFriendlyName) this.friendlyNames[device.deviceTypeFriendlyName] = device; + if (customerIds[device.deviceOwnerCustomerId] === undefined) customerIds[device.deviceOwnerCustomerId] = 0; + customerIds[device.deviceOwnerCustomerId] += 1; + device.isControllable = ( + device.capabilities.includes('AUDIO_PLAYER') || + device.capabilities.includes('AMAZON_MUSIC') || + device.capabilities.includes('TUNE_IN') + ); + device.hasMusicPlayer = ( + device.capabilities.includes('AUDIO_PLAYER') || + device.capabilities.includes('AMAZON_MUSIC') + ); + device.isMultiroomDevice = (device.clusterMembers.length > 0); + device.isMultiroomMember = (device.parentClusters.length > 0); + }); + //this.ownerCustomerId = Object.keys(customerIds)[0]; // this could end in the wrong one! + callback && callback(); }); - //this.ownerCustomerId = Object.keys(customerIds)[0]; // this could end in the wrong one! } - callback && callback(); + else { + callback && callback(); + } }); } @@ -826,6 +855,9 @@ class AlexaRemote extends EventEmitter { return; } + // TODO maybe handle the case of "non HTTP 200 responses" better and return accordingly? + // maybe set err AND body? + // add x-amzn-ErrorType header to err? (e.g. 400 on /player: ExpiredPlayQueueException:http://internal.amazon.com/coral/com.amazon.dee.web.coral.model/) this._options.logger && this._options.logger(`Alexa-Remote: Response: ${JSON.stringify(ret)}`); callback(null, ret); callback = null; @@ -907,6 +939,8 @@ class AlexaRemote extends EventEmitter { } else { handleResponse(null, res, resBuffer.toString()); } + } else { + this._options.logger && this._options.logger(`Alexa-Remote: Response (without callback): Status: ${res.statusCode}`); } }); }); @@ -970,6 +1004,9 @@ class AlexaRemote extends EventEmitter { } } + getEndpoints(callback) { + this.httpsGetCall('/api/endpoints', callback); + } getDevices(callback) { this.httpsGet ('/api/devices-v2/device?cached=true&_=%t', callback); @@ -1162,6 +1199,10 @@ class AlexaRemote extends EventEmitter { }, request); } + getWholeHomeAudioGroups(callback) { + this.httpsGet ('/api/wholeHomeAudio/v1/groups', (err, res) => callback && callback(err, res && res.groups)); + } + createNotificationObject(serialOrName, type, label, value, status, sound) { // type = Reminder, Alarm if (status && typeof status === 'object') { sound = status; @@ -1764,6 +1805,10 @@ class AlexaRemote extends EventEmitter { commandObj.type = 'RepeatCommand'; commandObj.repeat = (value === 'on' || value === true); break; + case 'jump': + commandObj.type = 'JumpCommand'; + commandObj.mediaId = value; + break; default: return; } @@ -1843,6 +1888,11 @@ class AlexaRemote extends EventEmitter { seqNode.skillId = 'amzn1.ask.1p.tellalexa'; seqNode.operationPayload.text = value.toString(); break; + case 'sound': + seqNode.type = 'Alexa.Sound'; + seqNode.skillId = 'amzn1.ask.1p.sound'; + seqNode.operationPayload.soundStringId = value.toString(); + break; case 'curatedtts': { const supportedValues = ['goodbye', 'confirmations', 'goodmorning', 'compliments', 'birthday', 'goodnight', 'iamhome']; if (!supportedValues.includes(value)) { @@ -1934,19 +1984,25 @@ class AlexaRemote extends EventEmitter { delete seqNode.operationPayload.deviceType; delete seqNode.operationPayload.deviceSerialNumber; break; - case 'notification': + case 'notification': { seqNode.type = 'Alexa.Notifications.SendMobilePush'; + let title = 'ioBroker'; + if (value && typeof value === 'object') { + title = value.title || title; + value = value.text || value.value; + } if (typeof value !== 'string') value = String(value); if (value.length === 0) { throw new Error('Can not notify empty string'); } seqNode.operationPayload.notificationMessage = value; seqNode.operationPayload.alexaUrl = '#v2/behaviors'; - seqNode.operationPayload.title = 'ioBroker'; + seqNode.operationPayload.title = title; delete seqNode.operationPayload.deviceType; delete seqNode.operationPayload.deviceSerialNumber; delete seqNode.operationPayload.locale; break; + } case 'announcement': case 'ssml': seqNode.type = 'AlexaAnnouncement'; @@ -2119,7 +2175,7 @@ class AlexaRemote extends EventEmitter { callback, { headers: { - 'Routines-Version': '1.1.210292' + 'Routines-Version': '3.0.128540' } } ); @@ -2221,8 +2277,49 @@ class AlexaRemote extends EventEmitter { this.httpsGet (`https://alexa-comms-mobile-service.${this._options.amazonPage}/users/${this.commsId}/identities?includeUserName=true`, callback); } - getDevicePreferences(callback) { - this.httpsGet ('/api/device-preferences?cached=true&_=%t', callback); + getDevicePreferences(serialOrName, callback) { + if (typeof serialOrName === 'function') { + callback = serialOrName; + serialOrName = null; + } + this.httpsGet ('/api/device-preferences', (err, res) => { + if (serialOrName) { + const device = this.find(serialOrName); + if (!device) { + return callback && callback(new Error('Unknown Device or Serial number'), null); + } + + if (!err && res && res.devicePreferences && Array.isArray(res.devicePreferences)) { + const devicePreferences = res.devicePreferences.filter(pref => pref.deviceSerialNumber === device.serialNumber); + if (devicePreferences.length > 0) { + return callback && callback(null, devicePreferences[0]); + } else { + return callback && callback(new Error(`No Device Preferences found for ${device.serialNumber}`), null); + } + } + } + callback && callback(err, res); + }); + } + + setDevicePreferences(serialOrName, preferences, callback) { + const device = this.find(serialOrName); + if (!device) { + return callback && callback(new Error('Unknown Device or Serial number'), null); + } + + this.httpsGet (`/api/device-preferences/${device.serialNumber}`, callback, { + method: 'PUT', + data: JSON.stringify(preferences) + }); + } + + getDeviceWifiDetails(serialOrName, callback) { + const dev = this.find(serialOrName); + if (!dev) { + return callback && callback(new Error('Unknown Device or Serial number'), null); + } + this.httpsGet (`/api/device-wifi-details?deviceSerialNumber=${dev.serialNumber}&deviceType=${dev.deviceType}`, callback); } getAllDeviceVolumes(callback) { @@ -2253,7 +2350,7 @@ class AlexaRemote extends EventEmitter { callback, { headers: { - 'Routines-Version': '1.1.210292' + 'Routines-Version': '3.0.128540' }, timeout: 30000 } @@ -2265,13 +2362,24 @@ class AlexaRemote extends EventEmitter { callback, { headers: { - 'Routines-Version': '1.1.210292' + 'Routines-Version': '3.0.128540' }, timeout: 30000 } ); } + getRoutineSoundList(callback) { + this.httpsGet ('/api/behaviors/entities?skillId=amzn1.ask.1p.sound', + callback, + { + headers: { + 'Routines-Version': '3.0.128540' + }, + timeout: 30000 + } + ); + } renameDevice(serialOrName, newName, callback) { const dev = this.find(serialOrName); @@ -2301,6 +2409,17 @@ class AlexaRemote extends EventEmitter { this.httpsGet (`/api/phoenix/appliance/${smarthomeDevice}`, callback, flags); } + setEnablementForSmarthomeDevice(smarthomeDevice, enabled, callback) { + const flags = { + method: 'PUT', + data: JSON.stringify ({ + 'applianceId': smarthomeDevice, + 'enabled': !!enabled + }), + }; + this.httpsGet (`/api/phoenix/v2/appliance/${smarthomeDevice}/enablement`, callback, flags); + } + deleteSmarthomeGroup(smarthomeGroup, callback) { const flags = { method: 'DELETE' @@ -2349,7 +2468,7 @@ class AlexaRemote extends EventEmitter { data: JSON.stringify ({ 'stateRequests': reqArr }), - timeout: Math.min(maxTimeout || 60000, Math.max(10000, applicanceIds.length * 300)) + timeout: Math.min(maxTimeout || 60000, Math.max(10000, applicanceIds.length * 150)) }; diff --git a/package-lock.json b/package-lock.json index 977cd3b..8764e80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "5.1.0", "license": "MIT", "dependencies": { - "alexa-cookie2": "^4.0.2", + "alexa-cookie2": "^4.0.3", "extend": "^3.0.2", "https": "^1.0.0", "querystring": "^0.2.1", @@ -19,7 +19,7 @@ "devDependencies": { "@alcalzone/release-script": "^3.5.9", "@alcalzone/release-script-plugin-license": "^3.5.9", - "eslint": "^8.18.0" + "eslint": "^8.19.0" }, "engines": { "node": ">=12.0.0" @@ -355,9 +355,9 @@ "dev": true }, "node_modules/alexa-cookie2": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/alexa-cookie2/-/alexa-cookie2-4.0.2.tgz", - "integrity": "sha512-nyZUDE3iJOleJxXZb1zB3r2Wq0m3qrEeNXOJbo/AVCX8Tekrya5UzF3otBGeX9wuEeU6Cmz9DM6i8CSFgKIcfA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/alexa-cookie2/-/alexa-cookie2-4.0.3.tgz", + "integrity": "sha512-QNtLzGzRFzyBPfZqSBrLrZ15Zjvwe22H5bvXFfPTHeD3JxLUjtUqZmx94gfEmqZHZv06vhTmDmaiu0OsiU8KIw==", "dependencies": { "cookie": "^0.5.0", "express": "^4.18.1", @@ -779,9 +779,9 @@ } }, "node_modules/eslint": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.18.0.tgz", - "integrity": "sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.19.0.tgz", + "integrity": "sha512-SXOPj3x9VKvPe81TjjUJCYlV4oJjQw68Uek+AM0X4p+33dj2HY5bpTZOgnQHcG2eAm1mtCU9uNMnJi7exU/kYw==", "dev": true, "dependencies": { "@eslint/eslintrc": "^1.3.0", @@ -2727,9 +2727,9 @@ } }, "alexa-cookie2": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/alexa-cookie2/-/alexa-cookie2-4.0.2.tgz", - "integrity": "sha512-nyZUDE3iJOleJxXZb1zB3r2Wq0m3qrEeNXOJbo/AVCX8Tekrya5UzF3otBGeX9wuEeU6Cmz9DM6i8CSFgKIcfA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/alexa-cookie2/-/alexa-cookie2-4.0.3.tgz", + "integrity": "sha512-QNtLzGzRFzyBPfZqSBrLrZ15Zjvwe22H5bvXFfPTHeD3JxLUjtUqZmx94gfEmqZHZv06vhTmDmaiu0OsiU8KIw==", "requires": { "cookie": "^0.5.0", "express": "^4.18.1", @@ -3050,9 +3050,9 @@ "dev": true }, "eslint": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.18.0.tgz", - "integrity": "sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.19.0.tgz", + "integrity": "sha512-SXOPj3x9VKvPe81TjjUJCYlV4oJjQw68Uek+AM0X4p+33dj2HY5bpTZOgnQHcG2eAm1mtCU9uNMnJi7exU/kYw==", "dev": true, "requires": { "@eslint/eslintrc": "^1.3.0", diff --git a/package.json b/package.json index 4cc909b..855875c 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "layla.amazon.de" ], "dependencies": { - "alexa-cookie2": "^4.0.2", + "alexa-cookie2": "^4.0.3", "https": "^1.0.0", "querystring": "^0.2.1", "ws": "^8.8.0", @@ -39,7 +39,7 @@ "devDependencies": { "@alcalzone/release-script": "^3.5.9", "@alcalzone/release-script-plugin-license": "^3.5.9", - "eslint": "^8.18.0" + "eslint": "^8.19.0" }, "scripts": { "test": "node node_modules/mocha/bin/mocha",