diff --git a/README.md b/README.md index 9571bef..b5d6d4e 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ service that does not need any more information but the entity id. # Configuration There are two sections on the plugin's cofiguration panel: * Home Assistant Settings - Contains global settings for your Home Assistant installation. Once saved, the settings are valid for all Buttons. - * Entity Settings + Contains global settings for your Home Assistant installation. Once saved, the settings are used for all stream deck Buttons. + * Entity Settings Contains settings for an individual button. ## Home Assistant Settings @@ -52,7 +52,8 @@ After you saved your Home Assistant Settings, the plugin will automatically try ### Basic configuration * Domain: Home Assistant entities are grouped by domains. Select the domain (for example "switch") of an entity, you want to configure. * Entity: This is the actual entity you are going to configure (for example "Kitchen Light") - * Service: The service that will be called every time you press the StreamDeck button for this entity. + * Service: The service that will be called every time you press the StreamDeck button. + * Service (long press): The service that will be called every time you press and hold the StreamDeck button for more than 300ms. * Service Data JSON: JSON formatted data that is sent with your service call when you press a button. Example: ``` @@ -79,25 +80,3 @@ After you hit the save button, the button should immediately show the new config # Happy? Consider to donating me a coffee :) [![buy me a coffee](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/donate?hosted_button_id=3UKRJEJVWV9H4) -## Project setup -``` -npm install -``` - -### Compiles and hot-reloads for development -``` -npm run serve -``` - -### Compiles and minifies for production -``` -npm run build -``` - -### Lints and fixes files -``` -npm run lint -``` - -### Customize configuration -See [Configuration Reference](https://cli.vuejs.org/config/). diff --git a/doc/entity_settings.png b/doc/entity_settings.png index ed9e918..4743e63 100644 Binary files a/doc/entity_settings.png and b/doc/entity_settings.png differ diff --git a/doc/example.png b/doc/example.png index 2570185..f61b7e8 100644 Binary files a/doc/example.png and b/doc/example.png differ diff --git a/doc/ha_settings.png b/doc/ha_settings.png index e2f6410..2e557b8 100644 Binary files a/doc/ha_settings.png and b/doc/ha_settings.png differ diff --git a/package-lock.json b/package-lock.json index f610f2f..61128ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1103,6 +1103,61 @@ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", "dev": true }, + "@nuxt/opencollective": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.3.2.tgz", + "integrity": "sha512-XG7rUdXG9fcafu9KTDIYjJSkRO38EwjlKYIb5TQ/0WDbiTUTtUtgncMscKOYzfsY86kGs05pAuMOR+3Fi0aN3A==", + "requires": { + "chalk": "^4.1.0", + "consola": "^2.15.0", + "node-fetch": "^2.6.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "@soda/friendly-errors-webpack-plugin": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@soda/friendly-errors-webpack-plugin/-/friendly-errors-webpack-plugin-1.8.0.tgz", @@ -2692,6 +2747,28 @@ "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", "dev": true }, + "bootstrap": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.0.tgz", + "integrity": "sha512-Io55IuQY3kydzHtbGvQya3H+KorS/M9rSNyfCGCg9WZ4pyT/lCxIlpJgG1GXW/PswzC84Tr2fBYi+7+jFVQQBw==" + }, + "bootstrap-vue": { + "version": "2.21.2", + "resolved": "https://registry.npmjs.org/bootstrap-vue/-/bootstrap-vue-2.21.2.tgz", + "integrity": "sha512-0Exe+4MZysqhZNXIKf4TzkvXaupxh9EHsoCRez0o5Dc0J7rlafayOEwql63qXv74CgZO8E4U8ugRNJko1vMvNw==", + "requires": { + "@nuxt/opencollective": "^0.3.2", + "bootstrap": ">=4.5.3 <5.0.0", + "popper.js": "^1.16.1", + "portal-vue": "^2.1.7", + "vue-functional-data-merge": "^3.1.0" + } + }, + "bootswatch": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/bootswatch/-/bootswatch-4.6.0.tgz", + "integrity": "sha512-Yr6YqFBC8jwTzoJoLViYlvO97IhPWGqZEm+6FXHfD/G6gbUok6sZkdXxdh4Zb6iCGEwr9p7zGCn38yKQD/bh2Q==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3470,6 +3547,11 @@ "integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==", "dev": true }, + "consola": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", + "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==" + }, "console-browserify": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", @@ -7476,6 +7558,11 @@ "lower-case": "^1.1.1" } }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, "node-forge": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", @@ -8137,6 +8224,16 @@ "ts-pnp": "^1.1.6" } }, + "popper.js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==" + }, + "portal-vue": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/portal-vue/-/portal-vue-2.1.7.tgz", + "integrity": "sha512-+yCno2oB3xA7irTt0EU5Ezw22L2J51uKAacE/6hMPMoO/mx3h4rXFkkBkT4GFsMDv/vEe8TNKC3ujJJ0PTwb6g==" + }, "portfinder": { "version": "1.0.28", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz", @@ -10990,6 +11087,11 @@ } } }, + "vue-functional-data-merge": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vue-functional-data-merge/-/vue-functional-data-merge-3.1.0.tgz", + "integrity": "sha512-leT4kdJVQyeZNY1kmnS1xiUlQ9z1B/kdBFCILIjYYQDqZgLqCLa0UhjSSeRX6c3mUe6U5qYeM8LrEqkHJ1B4LA==" + }, "vue-hot-reload-api": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz", diff --git a/package.json b/package.json index df5795e..c535c0e 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "lint": "vue-cli-service lint" }, "dependencies": { + "bootstrap": "^4.6.0", + "bootstrap-vue": "^2.21.2", + "bootswatch": "^4.6.0", "core-js": "^3.6.5", "nunjucks": "^3.2.3", "vue": "^2.6.11" diff --git a/public/pi.html b/public/pi.html index 2040990..db1f41f 100644 --- a/public/pi.html +++ b/public/pi.html @@ -6,7 +6,6 @@ name=viewport> - HomeAssistant Plugin PI diff --git a/src/Pi.vue b/src/Pi.vue index 8f906c9..84fbf00 100644 --- a/src/Pi.vue +++ b/src/Pi.vue @@ -1,103 +1,184 @@ @@ -105,9 +186,11 @@ import StreamDeck from "@/modules/common/streamdeck"; import {ObjectUtils} from "@/modules/common/utils"; import {Entity, Homeassistant} from "@/modules/common/homeassistant"; +import WindowPortal from "@/components/WindowPortal"; export default { name: 'Pi', + components: {WindowPortal}, props: {}, data: () => { return { @@ -116,8 +199,11 @@ export default { domain: "", entity: "", + service: "", serviceData: "", + serviceLongPress: "", + serviceDataLongPress: "", // Custom Labels useCustomTitle: false, @@ -131,7 +217,11 @@ export default { availableDomains: [], availableEntities: [], availableServices: [], - availableAttributes: [] + availableAttributes: [], + + // Home-Assistant-State + haConnected: false, + haError: "" } }, created() { @@ -153,8 +243,15 @@ export default { let actionSettings = actionInfo.payload.settings this.domain = actionSettings["domain"] this.entity = actionSettings["entityId"] - this.service = actionSettings["service"] - this.serviceData = actionSettings["serviceData"] + + if (actionSettings["service"]) { + this.service = actionSettings["service"].id + this.serviceData = actionSettings["service"].data + } + if (actionSettings["serviceLongPress"]) { + this.serviceLongPress = actionSettings["serviceLongPress"].id + this.serviceDataLongPress = actionSettings["serviceLongPress"].data + } this.useCustomTitle = actionSettings["useCustomTitle"] this.buttonTitle = actionSettings["buttonTitle"] || "{{friendly_name}}" @@ -168,6 +265,38 @@ export default { }, computed: { + serverUrlState: function () { + return this.serverUrl.length > 4 + }, + + accessTokenState: function () { + return this.accessToken.length > 4 + }, + + serviceDataFeedback: function () { + if (!this.serviceData) { + return ""; + } + try { + const json = JSON.parse(this.serviceData); + return (typeof json === "object") ? "" : "Service data must be an JSON object." + } catch (e) { + return "Invalid JSON string."; + } + }, + + serviceDataLongPressFeedback: function () { + if (!this.serviceDataLongPress) { + return ""; + } + try { + const json = JSON.parse(this.serviceDataLongPress); + return (typeof json === "object") ? "" : "Service data must be an JSON object." + } catch (e) { + return "Invalid JSON string."; + } + }, + isHaSettingsComplete: function () { return !this.serverUrl || !this.accessToken }, @@ -205,39 +334,50 @@ export default { } this.$HA = new Homeassistant(this.serverUrl, this.accessToken, () => { - this.$HA.getStates((states) => { - this.availableDomains = states - .map(state => new Entity(state.entity_id).domain) - .sort() - .reduce( - (acc, curr) => acc.add(curr), new Set() - ); - - this.availableEntities = states - .map((state) => { + this.haConnected = true; + this.$HA.getStates((states) => { + this.availableDomains = Array.from(states + .map(state => new Entity(state.entity_id).domain) + .sort() + .reduce( + (acc, curr) => acc.add(curr), new Set() + )); + + this.availableEntities = states + .map((state) => { + return { + value: new Entity(state.entity_id), + text: state.attributes.friendly_name || state.entity_id + } + } + ) + .sort((a, b) => (a.text > b.text) ? 1 : ((b.text > a.text) ? -1 : 0)) + + this.availableAttributes = states + .map((state) => { return { - value: new Entity(state.entity_id), - text: state.attributes.friendly_name || state.entity_id + entity: new Entity(state.entity_id), + attributes: ObjectUtils.paths(state.attributes) } - } - ) - .sort((a, b) => (a.text > b.text) ? 1 : ((b.text > a.text) ? -1 : 0)) - - this.availableAttributes = states - .map((state) => { - return { - entity: new Entity(state.entity_id), - attributes: ObjectUtils.paths(state.attributes) - } - }) - }); - this.$HA.getServices((services) => { - this.availableServices = services; - }); - }) + }) + }); + this.$HA.getServices((services) => { + this.availableServices = services; + }); + }, + () => { + this.haConnected = false; + this.haError = "Failed to connect websocket."; + }, + () => { + this.haConnected = false; + this.haError = "Websocket was closed."; + } + ) }, saveGlobalSettings: function () { + this.haError = ""; this.$SD.saveGlobalSettings({"serverUrl": this.serverUrl, "accessToken": this.accessToken}); this.connectHomeAssistant() }, @@ -246,8 +386,15 @@ export default { let actionSettings = { domain: this.domain, entityId: this.entity, - service: this.service, - serviceData: this.serviceData, + + service: { + id: this.service, + data: this.serviceData + }, + serviceLongPress: { + id: this.serviceLongPress, + data: this.serviceDataLongPress + }, useCustomTitle: this.useCustomTitle, buttonTitle: this.buttonTitle, diff --git a/src/Plugin.vue b/src/Plugin.vue index 4de55f8..528f88f 100644 --- a/src/Plugin.vue +++ b/src/Plugin.vue @@ -17,7 +17,8 @@ export default { $HA: null, $reconnectTimeout: null, actionSettings: {}, - globalSettings: {} + globalSettings: {}, + buttonLongpressTimeouts: new Map() //context, timeout } }, beforeCreate() { @@ -64,24 +65,21 @@ export default { }) this.$SD.on("keyDown", (message) => { - console.log(this.actionSettings) - if (this.$HA) { - let context = message.context - let settings = this.actionSettings[context]; - if (settings.service) { - try { - const entity = new Entity(settings.entityId); - const serviceData = settings.serviceData ? JSON.parse(settings.serviceData) : {}; - // add default entity_id if not specified - if (!serviceData.entity_id) { - serviceData.entity_id = entity.entityId; - } - this.$HA.callService(settings.service, entity, serviceData) - } catch (e) { - console.error(e) - this.$SD.showAlert(context); - } - } + let context = message.context + + const timeout = setTimeout(buttonLongPress, 300, context); + this.buttonLongpressTimeouts.set(context, timeout) + }) + + this.$SD.on("keyUp", (message) => { + let context = message.context + + // If "long press timeout" is still present, we perform a normal press + const lpTimeout = this.buttonLongpressTimeouts.get(context); + if (lpTimeout) { + clearTimeout(lpTimeout); + this.buttonLongpressTimeouts.delete(context) + buttonShortPress(context); } }) @@ -107,6 +105,41 @@ export default { } }) + const buttonShortPress = (context) => { + let settings = this.actionSettings[context]; + callService(context, settings.service); + } + + const buttonLongPress = (context) => { + this.buttonLongpressTimeouts.delete(context); + let settings = this.actionSettings[context]; + if (settings.serviceLongPress.id) { + callService(context, settings.serviceLongPress); + } else { + callService(context, settings.service); + } + } + + const callService = (context, serviceToCall) => { + let settings = this.actionSettings[context]; + if (this.$HA) { + if (serviceToCall) { + try { + const entity = new Entity(settings.entityId); + const serviceData = serviceToCall.data ? JSON.parse(serviceToCall.data) : {}; + // add default entity_id if not specified + if (!serviceData.entity_id) { + serviceData.entity_id = entity.entityId; + } + this.$HA.callService(serviceToCall.id, entity, serviceData) + } catch (e) { + console.error(e) + this.$SD.showAlert(context); + } + } + } + } + const connectHomeAssistant = () => { if (this.globalSettings.serverUrl && this.globalSettings.accessToken) { if (this.$HA) { diff --git a/src/components/WindowPortal.vue b/src/components/WindowPortal.vue new file mode 100644 index 0000000..92e2b6e --- /dev/null +++ b/src/components/WindowPortal.vue @@ -0,0 +1,83 @@ + + + diff --git a/src/modules/common/streamdeck.js b/src/modules/common/streamdeck.js index 6824ceb..b7dcddf 100644 --- a/src/modules/common/streamdeck.js +++ b/src/modules/common/streamdeck.js @@ -27,6 +27,9 @@ export default class StreamDeck { case "keyDown": this.events.emit("keyDown", incomingEvent) break; + case "keyUp": + this.events.emit("keyUp", incomingEvent) + break; case "willAppear": this.events.emit("willAppear", incomingEvent) break; diff --git a/src/modules/common/utils.js b/src/modules/common/utils.js index 23af2c3..3674ae2 100644 --- a/src/modules/common/utils.js +++ b/src/modules/common/utils.js @@ -16,7 +16,7 @@ export const ObjectUtils = { .map(subPath => `${keyPath}.${subPath}`) .forEach(path => paths.push(path)) } else { - paths.push(key) + paths.push(keyPath) } } ) diff --git a/src/pi/main.js b/src/pi/main.js index 84d111d..d076089 100644 --- a/src/pi/main.js +++ b/src/pi/main.js @@ -1,5 +1,15 @@ import Vue from 'vue' import Pi from "@/Pi"; +import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' + +import 'bootstrap/dist/css/bootstrap.css' +import 'bootstrap-vue/dist/bootstrap-vue.css' +import "bootswatch/dist/superhero/bootstrap.min.css"; + +// Make BootstrapVue available throughout your project +Vue.use(BootstrapVue) +// Optionally install the BootstrapVue icon components plugin +Vue.use(IconsPlugin) Vue.config.productionTip = false