diff --git a/platypush/backend/http/static/css/source/webpanel/plugins/zigbee.mqtt/index.scss b/platypush/backend/http/static/css/source/webpanel/plugins/zigbee.mqtt/index.scss new file mode 100644 index 000000000..28293469d --- /dev/null +++ b/platypush/backend/http/static/css/source/webpanel/plugins/zigbee.mqtt/index.scss @@ -0,0 +1,264 @@ +@import 'common/vars'; +@import 'webpanel/plugins/zigbee.mqtt/vars'; + +.fa.fa-zigbee:before { + content: ' '; + background: url('/static/img/icons/zigbee-logo.svg'); + background-size: 1em 1em; + width: 1em; + height: 1em; + display: inline-block; +} + +.zigbee-container { + height: 100%; + padding: 0 .5em; + background: $container-bg; + display: flex; + flex-direction: column; + align-items: center; + overflow: auto; + + .no-items { + padding: 2em; + font-size: 1.5em; + color: $no-items-color; + display: flex; + align-items: center; + justify-content: center; + } + + .view-options { + display: flex; + width: 100%; + justify-content: space-between; + padding: 1em 0; + + .view-selector { + display: inline-flex; + } + + .buttons { + display: inline-flex; + } + + select { + width: 100%; + border-radius: 1em; + } + } + + .btn-default { + border: 0; + padding: 0 1em; + + &:hover { + border: $default-border-2; + border-radius: 1em; + } + } + + + .buttons { + text-align: right; + } + + .view { + min-width: 400pt; + max-width: 750pt; + background: $view-bg; + border: $view-border; + border-radius: 1.5em; + box-shadow: $view-box-shadow; + } + + .item { + &.selected { + box-shadow: $selected-item-box-shadow; + } + + .name { + padding: 1em; + cursor: pointer; + text-transform: uppercase; + letter-spacing: .06em; + + &.selected { + border-radius: 1.5em; + } + } + + &:hover { + background: $hover-bg; + } + + &:not(:last-child) { + border-bottom: $item-border; + } + + &:first-child { + border-radius: 1.5em 1.5em 0 0; + } + + &:last-child { + border-radius: 0 0 1.5em 1.5em; + } + } + + .params { + background: $params-bg; + padding-bottom: 1em; + + .section { + display: flex; + flex-direction: column; + padding: 0 1em; + + &:not(:first-child) { + padding-top: 1em; + } + + .header { + display: flex; + align-items: center; + font-weight: bold; + border-bottom: $param-section-header-border; + } + } + + .row { + display: flex; + align-items: center; + border-radius: 1em; + padding: .3em; + + &:nth-child(even) { + background: $param-even-row-bg; + } + + &:nth-child(odd) { + background: $param-odd-row-bg; + } + + &:hover { + background: $hover-bg; + } + } + + .param-name { + display: inline-flex; + width: 40%; + margin-left: 1%; + vertical-align: top; + letter-spacing: .04em; + } + + .param-value { + display: inline-block; + width: 58%; + text-align: right; + + .value-edit { + display: flex; + align-items: center; + } + + .value-data { + display: inline-block; + font-weight: bold; + } + + .slider-container { + display: flex; + align-items: center; + } + + .unit { + font-size: .8em; + margin-left: 1em; + display: inline; + } + + select { + width: 100%; + border-radius: 2em; + } + + .numeric { + input.slider { + text-align: left; + } + + input[type=text] { + text-align: right; + width: 100%; + } + + .row { + background: none; + &:hover { + background: none; + } + } + + .value-min, .value-max { + width: 50%; + font-size: .85em; + opacity: .75; + } + + .value-min { + text-align: left; + } + + .value-max { + text-align: right; + } + } + } + } + + .btn-value-name-edit { + padding: 0; + } + + .modal { + .section { + .header { + background: none; + padding: .5em 0; + } + + .body { + padding: 0; + } + } + + .network-info { + min-width: 600pt; + } + } + + .error { + color: $error-color; + } + + .device, .group { + .actions { + .row { + cursor: pointer; + } + } + + form { + margin-bottom: 0; + } + + .param-value { + input[type=text] { + text-align: right; + } + } + } +} + diff --git a/platypush/backend/http/static/css/source/webpanel/plugins/zigbee.mqtt/vars.scss b/platypush/backend/http/static/css/source/webpanel/plugins/zigbee.mqtt/vars.scss new file mode 100644 index 000000000..798334a20 --- /dev/null +++ b/platypush/backend/http/static/css/source/webpanel/plugins/zigbee.mqtt/vars.scss @@ -0,0 +1,13 @@ +$container-bg: #f1f1f1; +$view-bg: white; +$view-border: 1px solid #d8d8d8; +$view-box-shadow: 1px 2px 2px #ccc; +$item-border: 1px solid #dddddd; +$no-items-color: #555555; +$params-bg: white; +$param-even-row-bg: #ededed; +$param-odd-row-bg: white; +$param-section-header-border: 1px solid #e8e8e8; +$selected-item-box-shadow: 0 2px 4px 0 #bbb; +$error-color: #aa0000; + diff --git a/platypush/backend/http/static/img/icons/zigbee-logo.svg b/platypush/backend/http/static/img/icons/zigbee-logo.svg new file mode 100644 index 000000000..b7cf73a1b --- /dev/null +++ b/platypush/backend/http/static/img/icons/zigbee-logo.svg @@ -0,0 +1,5 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <style>.color {fill: #5f7869}</style> + <title>Zigbee icon</title> + <path class="color" d="M11.988 0a11.85 11.85 0 00-8.617 3.696c7.02-.875 11.401-.583 13.289-.34 3.752.583 3.558 3.404 3.558 3.404L8.237 19.112c2.299.22 6.897.366 13.796-.631a11.86 11.86 0 001.912-6.469C23.945 5.374 18.595 0 11.988 0zm.232 4.31c-2.451-.014-5.772.146-9.963.723C.854 7.003.055 9.41.055 12.012.055 18.626 5.38 24 11.988 24c3.63 0 6.85-1.63 9.053-4.182-7.286.948-11.813.631-13.75.388-3.775-.56-3.557-3.404-3.557-3.404L15.691 4.474a38.635 38.635 0 00-3.471-.163Z"/> +</svg> diff --git a/platypush/backend/http/static/js/elements/dropdown.js b/platypush/backend/http/static/js/elements/dropdown.js index c1d98766e..292ef325e 100644 --- a/platypush/backend/http/static/js/elements/dropdown.js +++ b/platypush/backend/http/static/js/elements/dropdown.js @@ -14,6 +14,11 @@ Vue.component('dropdown', { type: Array, default: [], }, + + classes: { + type: Array, + default: () => [], + }, }, methods: { @@ -38,7 +43,7 @@ let clickHndl = function(event) { var element = event.target; while (element) { - if (element == openedDropdown) { + if (element === openedDropdown) { return; } diff --git a/platypush/backend/http/static/js/plugins/zigbee.mqtt/device.js b/platypush/backend/http/static/js/plugins/zigbee.mqtt/device.js new file mode 100644 index 000000000..102bfae8f --- /dev/null +++ b/platypush/backend/http/static/js/plugins/zigbee.mqtt/device.js @@ -0,0 +1,127 @@ +Vue.component('zigbee-device', { + template: '#tmpl-zigbee-device', + props: ['device','bus','selected'], + data: function() { + return { + newPropertyName: '', + editMode: { + name: false, + }, + }; + }, + + methods: { + onDeviceClicked: function() { + this.bus.$emit('deviceClicked', { + deviceId: this.device.friendly_name, + }); + }, + + setValue: async function(event) { + let name = undefined; + if (this.newPropertyName && this.newPropertyName.length) { + name = this.newPropertyName; + } else { + name = event.event + ? event.event.target.parentElement.dataset.name + : event.target.dataset.name; + } + + if (!name || !name.length) { + return; + } + + const target = event.event + ? event.event.target.parentElement.querySelector('input') + : event.target; + + const value = target.getAttribute('type') === 'checkbox' + ? (target.checked ? 'OFF' : 'ON') + : target.value; + + await request('zigbee.mqtt.device_set', { + device: this.device.friendly_name, + property: name, + value: value, + }); + + if (this.newPropertyName && this.newPropertyName.length) { + this.newPropertyName = ''; + } + + this.bus.$emit('refreshDevices'); + }, + + removeDevice: async function(force=false) { + if (!confirm('Are you sure that you want to remove this device?')) { + return; + } + + await request('zigbee.mqtt.device_remove', { + device: this.device.friendly_name, + force: force, + }); + + this.bus.$emit('refreshDevices'); + }, + + banDevice: async function() { + if (!confirm('Are you sure that you want to ban this device?')) { + return; + } + + await request('zigbee.mqtt.device_ban', { + device: this.device.friendly_name, + }); + + this.bus.$emit('refreshDevices'); + }, + + whitelistDevice: async function() { + if (!confirm('Are you sure that you want to whitelist this device? Note: ALL the other non-whitelisted ' + + 'devices will be removed from the network')) { + return; + } + + await request('zigbee.mqtt.device_whitelist', { + device: this.device.friendly_name, + }); + + this.bus.$emit('refreshDevices'); + }, + + disableForm: function(form) { + form.querySelector('input,button').readOnly = true; + }, + + enableForm: function(form) { + form.querySelector('input,button').readOnly = false; + }, + + onEditMode: function(mode) { + Vue.set(this.editMode, mode, true); + const form = this.$refs[mode + 'Form']; + const input = form.querySelector('input[type=text]'); + + setTimeout(() => { + input.focus(); + input.select(); + }, 10); + }, + + editName: async function(event) { + this.disableForm(event.target); + const name = event.target.querySelector('input[name=name]').value; + + await request('zigbee.mqtt.device_rename', { + device: this.device.friendly_name, + name: name, + }); + + this.editMode.name = false; + this.enableForm(event.target); + this.bus.$emit('refreshDevices'); + }, + }, +}); + diff --git a/platypush/backend/http/static/js/plugins/zigbee.mqtt/group.js b/platypush/backend/http/static/js/plugins/zigbee.mqtt/group.js new file mode 100644 index 000000000..26f902574 --- /dev/null +++ b/platypush/backend/http/static/js/plugins/zigbee.mqtt/group.js @@ -0,0 +1,149 @@ +Vue.component('zigbee-group', { + template: '#tmpl-zigbee-group', + props: ['group','bus','selected'], + data: function() { + return { + properties: {}, + }; + }, + + methods: { + anyOn: async function() { + for (const dev of this.group.devices) { + const params = await request('zigbee.mqtt.device_get', {device: dev.friendly_name}); + if (params.state === 'ON') { + return true; + } + } + + return false; + }, + + allOn: async function() { + for (const dev of this.group.devices) { + const params = await request('zigbee.mqtt.device_get', {device: dev.friendly_name}); + if (params.state === 'OFF') { + return false; + } + } + + return true; + }, + + refreshProperties: async function() { + const props = {}; + + for (const dev of this.group.devices) { + const params = await request('zigbee.mqtt.device_get', {device: dev.friendly_name}); + for (const [name, value] of Object.entries(params)) { + if (name === 'linkquality') { + continue; + } + + if (name in props) { + props[name].push(value); + } else { + props[name] = [value]; + } + } + } + + for (const [name, values] of Object.entries(props)) { + if (name === 'state') { + props[name] = values.filter((value) => value === 'ON').length > 0; + } else if (!isNaN(values[0])) { + props[name] = values.reduce((sum, value) => sum + value, 0) / values.length; + } else { + props[name] = values[0]; + } + } + + this.properties = props; + }, + + onGroupClicked: function() { + this.bus.$emit('groupClicked', { + groupId: this.group.id, + }); + }, + + setValue: async function(event) { + const name = event.target.dataset.name; + if (!name || !name.length) { + return; + } + + await request('zigbee.mqtt.group_set', { + group: this.group.friendly_name, + property: name, + value: event.target.value, + }); + + this.bus.$emit('refreshDevices'); + }, + + toggleState: async function() { + const state = (await this.anyOn()) ? 'OFF' : 'ON'; + await request('zigbee.mqtt.group_set', { + group: this.group.friendly_name, + property: 'state', + value: state, + }); + + this.bus.$emit('refreshDevices'); + }, + + renameGroup: async function() { + const name = prompt('New name', this.group.friendly_name); + if (!name || !name.length || name === this.group.friendly_name) { + return; + } + + this.commandRunning = true; + await request('zigbee.mqtt.group_rename', { + name: name, + group: this.group.friendly_name, + }); + + this.commandRunning = false; + const self = this; + + setTimeout(() => { + self.bus.$emit('refreshGroups'); + }, 100); + }, + + removeGroup: async function() { + if (!confirm('Are you sure that you want to delete this group?')) { + return; + } + + this.commandRunning = true; + await request('zigbee.mqtt.group_remove', {name: this.group.friendly_name}); + this.commandRunning = false; + this.bus.$emit('refreshGroups'); + }, + + removeFromGroup: async function(device) { + if (!confirm('Are you sure that you want to remove this node from ' + this.group.label + '?')) { + return; + } + + await request('zigbee.mqtt.group_remove_device', { + device: device, + group: this.group.friendly_name, + }); + + this.bus.$emit('refreshGroups'); + }, + }, + + created: function() { + this.refreshProperties(); + this.bus.$on('refresh', this.refreshProperties); + this.bus.$on('refreshDevices', this.refreshProperties); + this.bus.$on('refreshGroups', this.refreshProperties); + this.bus.$on('refreshProperties', this.refreshProperties); + }, +}); + diff --git a/platypush/backend/http/static/js/plugins/zigbee.mqtt/index.js b/platypush/backend/http/static/js/plugins/zigbee.mqtt/index.js new file mode 100644 index 000000000..024728a7c --- /dev/null +++ b/platypush/backend/http/static/js/plugins/zigbee.mqtt/index.js @@ -0,0 +1,311 @@ +Vue.component('zigbee-mqtt', { + template: '#tmpl-zigbee-mqtt', + props: ['config'], + + data: function() { + return { + bus: new Vue({}), + status: {}, + devices: {}, + groups: {}, + commandRunning: false, + selected: { + view: 'devices', + deviceId: undefined, + groupId: undefined, + }, + loading: { + status: false, + devices: false, + groups: false, + }, + views: { + devices: true, + groups: true, + }, + modal: { + group: { + visible: false, + }, + }, + }; + }, + + computed: { + networkDropdownItems: function() { + const self = this; + return [ + { + text: 'Start Network', + disabled: this.commandRunning, + click: async function() { + self.commandRunning = true; + await request('zigbee.mqtt.start_network'); + self.commandRunning = false; + }, + }, + + { + text: 'Stop Network', + disabled: this.commandRunning, + click: async function() { + self.commandRunning = true; + await request('zigbee.mqtt.stop_network'); + self.commandRunning = false; + }, + }, + + { + text: 'Permit Join', + disabled: this.commandRunning, + click: async function() { + let seconds = prompt('Join allow period in seconds (type 0 for no time limits)', '60'); + if (!seconds) { + return; + } + + seconds = parseInt(seconds); + self.commandRunning = true; + await request('zigbee.mqtt.permit_join', {permit: true, timeout: seconds || null}); + self.commandRunning = false; + }, + }, + + { + text: 'Reset', + disabled: this.commandRunning, + click: async function() { + if (!confirm('Are you sure that you want to reset the device?')) { + return; + } + + await request('zigbee.mqtt.reset'); + }, + }, + + { + text: 'Factory Reset', + disabled: this.commandRunning, + classes: ['error'], + click: async function() { + if (!confirm('Are you sure that you want to do a device soft reset? ALL network information and custom firmware will be lost!!')) { + return; + } + + await request('zigbee.mqtt.factory_reset'); + }, + }, + ] + }, + + addToGroupDropdownItems: function() { + const self = this; + return Object.values(this.groups).filter((group) => { + return !group.values || !group.values.length || !(this.selected.valueId in this.scene.values); + }).map((group) => { + return { + text: group.name, + disabled: this.commandRunning, + click: async function () { + if (!self.selected.valueId) { + return; + } + + self.commandRunning = true; + await request('zwave.scene_add_value', { + id_on_network: self.selected.valueId, + scene_id: group.scene_id, + }); + + self.commandRunning = false; + self.refresh(); + }, + }; + }); + }, + }, + + methods: { + refreshDevices: async function () { + const self = this; + this.loading.devices = true; + this.devices = (await request('zigbee.mqtt.devices')).reduce((devices, device) => { + if (device.friendly_name in self.devices) { + device = { + values: self.devices[device.friendly_name].values || {}, + ...self.devices[device.friendly_name], + } + } + + devices[device.friendly_name] = device; + return devices; + }, {}); + + Object.values(this.devices).forEach((device) => { + if (device.type === 'Coordinator') { + return; + } + + request('zigbee.mqtt.device_get', {device: device.friendly_name}).then((response) => { + Vue.set(self.devices[device.friendly_name], 'values', response || {}); + }); + }); + + this.loading.devices = false; + }, + + refreshGroups: async function () { + this.loading.groups = true; + this.groups = (await request('zigbee.mqtt.groups')).reduce((groups, group) => { + groups[group.id] = group; + return groups; + }, {}); + + this.loading.groups = false; + }, + + refresh: function () { + this.refreshDevices(); + this.refreshGroups(); + this.bus.$emit('refreshProperties'); + }, + + addGroup: async function() { + const name = prompt('Group name'); + if (!name) { + return; + } + + this.commandRunning = true; + await request('zigbee.mqtt.group_add', {name: name}); + this.commandRunning = false; + this.refreshGroups(); + }, + + onViewChange: function(event) { + Vue.set(this.selected, 'view', event.target.value); + }, + + onDeviceClicked: function(event) { + Vue.set(this.selected, 'deviceId', event.deviceId === this.selected.deviceId ? undefined : event.deviceId); + }, + + onGroupClicked: function(event) { + Vue.set(this.selected, 'groupId', event.groupId === this.selected.groupId ? undefined : event.groupId); + }, + + openNetworkCommandsDropdown: function() { + openDropdown(this.$refs.networkCommandsDropdown); + }, + + openAddToGroupDropdown: function(event) { + this.selected.valueId = event.valueId; + openDropdown(this.$refs.addToGroupDropdown); + }, + + addToGroup: async function(device, group) { + this.commandRunning = true; + await request('zigbee.mqtt.group_add_device', { + device: device, + group: group, + }); + + this.commandRunning = false; + const self = this; + + setTimeout(() => { + self.refresh(); + self.bus.$emit('refreshProperties'); + }, 100) + }, + + removeNodeFromGroup: async function(event) { + if (!confirm('Are you sure that you want to remove this value from the group?')) { + return; + } + + this.commandRunning = true; + await request('zigbee.mqtt.group_remove_device', { + group: event.group, + device: event.device, + }); + + this.commandRunning = false; + }, + }, + + created: function() { + const self = this; + this.bus.$on('refresh', this.refresh); + this.bus.$on('refreshDevices', this.refreshDevices); + this.bus.$on('refreshGroups', this.refreshGroups); + this.bus.$on('deviceClicked', this.onDeviceClicked); + this.bus.$on('groupClicked', this.onGroupClicked); + this.bus.$on('openAddToGroupModal', () => {self.modal.group.visible = true}); + this.bus.$on('openAddToGroupDropdown', this.openAddToGroupDropdown); + this.bus.$on('removeFromGroup', this.removeNodeFromGroup); + + registerEventHandler(() => { + createNotification({ + text: 'WARNING: The controller is now offline', + error: true, + }); + }, 'platypush.message.event.zigbee.mqtt.ZigbeeMqttOnlineEvent'); + + registerEventHandler(() => { + createNotification({ + text: 'Failed to remove the device', + error: true, + }); + }, 'platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceRemovedFailedEvent'); + + registerEventHandler(() => { + createNotification({ + text: 'Failed to add the group', + error: true, + }); + }, 'platypush.message.event.zigbee.mqtt.ZigbeeMqttGroupAddedFailedEvent'); + + registerEventHandler(() => { + createNotification({ + text: 'Failed to remove the group', + error: true, + }); + }, 'platypush.message.event.zigbee.mqtt.ZigbeeMqttGroupRemovedFailedEvent'); + + registerEventHandler(() => { + createNotification({ + text: 'Failed to remove the devices from the group', + error: true, + }); + }, 'platypush.message.event.zigbee.mqtt.ZigbeeMqttGroupRemoveAllFailedEvent'); + + registerEventHandler((event) => { + createNotification({ + text: 'Unhandled Zigbee error: ' + (event.error || '[Unknown error]'), + error: true, + }); + }, 'platypush.message.event.zigbee.mqtt.ZigbeeMqttErrorEvent'); + + registerEventHandler(this.refresh, + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttOnlineEvent', + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttDevicePropertySetEvent', + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttDevicePairingEvent', + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceConnectedEvent', + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceBannedEvent', + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceRemovedEvent', + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceWhitelistedEvent', + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceRenamedEvent', + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceBindEvent', + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttDeviceUnbindEvent', + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttGroupAddedEvent', + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttGroupRemovedEvent', + 'platypush.message.event.zigbee.mqtt.ZigbeeMqttGroupRemoveAllEvent', + ); + }, + + mounted: function() { + this.refresh(); + }, +}); + diff --git a/platypush/backend/http/static/js/plugins/zwave/index.js b/platypush/backend/http/static/js/plugins/zwave/index.js index 2f27231be..90c2b0749 100644 --- a/platypush/backend/http/static/js/plugins/zwave/index.js +++ b/platypush/backend/http/static/js/plugins/zwave/index.js @@ -77,7 +77,7 @@ Vue.component('zwave', { disabled: this.commandRunning, click: async function() { self.commandRunning = true; - await request('zwave.start_network'); + await request('zwave.stop_network'); self.commandRunning = false; }, }, diff --git a/platypush/backend/http/templates/elements/dropdown.html b/platypush/backend/http/templates/elements/dropdown.html index 0b22fd5b9..82e4e9b46 100644 --- a/platypush/backend/http/templates/elements/dropdown.html +++ b/platypush/backend/http/templates/elements/dropdown.html @@ -1,6 +1,8 @@ <script type="text/x-template" id="tmpl-dropdown"> <div class="dropdown" :id="id" :class="{hidden: !visible}"> - <div class="row item" :class="{disabled: item.disabled}" v-for="item in items" @click="clicked(item)"> + <div class="row item" + :class="{disabled: item.disabled, ...classes.reduce((classes, c) => {classes[c] = true; return classes}, {})}" + v-for="item in items" @click="clicked(item)"> <div class="col-1 icon"> <i class="fa" :class="['fa-' + (item.icon || '')]" v-if="item.icon"></i> <i :class="item.iconClass" v-else-if="item.iconClass"></i> diff --git a/platypush/backend/http/templates/nav.html b/platypush/backend/http/templates/nav.html index 72a80ff1a..bd342acac 100644 --- a/platypush/backend/http/templates/nav.html +++ b/platypush/backend/http/templates/nav.html @@ -19,6 +19,7 @@ 'switches': 'fa fa-toggle-on', 'tts': 'fa fa-comment', 'tts.google': 'fa fa-comment', + 'zigbee.mqtt': 'fa fa-zigbee', 'zwave': 'fa fa-zwave', } %} diff --git a/platypush/backend/http/templates/plugins/zigbee.mqtt/device.html b/platypush/backend/http/templates/plugins/zigbee.mqtt/device.html new file mode 100644 index 000000000..a481399a4 --- /dev/null +++ b/platypush/backend/http/templates/plugins/zigbee.mqtt/device.html @@ -0,0 +1,163 @@ +<script type="text/x-template" id="tmpl-zigbee-device"> + <div class="item device" :class="{selected: selected}"> + <div class="row name vertical-center" :class="{selected: selected}" + v-text="device.friendly_name" @click="onDeviceClicked"></div> + + <div class="params" v-if="selected"> + <div class="row"> + <div class="param-name">Name</div> + <div class="param-value"> + <div :class="{hidden: !editMode.name}"> + <form ref="nameForm" @submit.prevent="editName"> + <input type="text" name="name" :value="device.friendly_name"> + + <span class="buttons"> + <button type="button" class="btn btn-default" @click="editMode.name = false"> + <i class="fas fa-times"></i> + </button> + + <button type="submit" class="btn btn-default"> + <i class="fa fa-check"></i> + </button> + </span> + </form> + </div> + + <div :class="{hidden: editMode.name}"> + <span v-text="device.friendly_name"></span> + <span class="buttons"> + <button type="button" class="btn btn-default" @click="onEditMode('name')"> + <i class="fa fa-edit"></i> + </button> + </span> + </div> + </div> + </div> + + <div class="row"> + <div class="param-name">IEEE Address</div> + <div class="param-value" v-text="device.ieeeAddr"></div> + </div> + + <div class="row"> + <div class="param-name">Network Address</div> + <div class="param-value" v-text="'0x' + parseInt(device.networkAddress).toString(16)"></div> + </div> + + <div class="row"> + <div class="param-name">Type</div> + <div class="param-value" v-text="device.type"></div> + </div> + + <div class="row" v-if="device.manufacturerID"> + <div class="param-name">Manufacturer</div> + <div class="param-value"> + {% raw %}{{ device.manufacturerName }} ({{ device.manufacturerID }}){% endraw %} + </div> + </div> + + <div class="row" v-if="device.modelID"> + <div class="param-name">Model</div> + <div class="param-value"> + {% raw %}{{ device.model }} ({{ device.modelID }}){% endraw %} + </div> + </div> + + <div class="row" v-if="device.softwareBuildID && device.softwareBuildID"> + <div class="param-name">Software Build ID</div> + <div class="param-value" v-text="device.softwareBuildID"></div> + </div> + + <div class="row" v-if="device.dateCode && device.dateCode.length"> + <div class="param-name">Date Code</div> + <div class="param-value" v-text="device.dateCode"></div> + </div> + + <div class="row" v-if="device.powerSource"> + <div class="param-name">Power Source</div> + <div class="param-value" v-text="device.powerSource"></div> + </div> + + <div class="row" v-if="device.lastSeen"> + <div class="param-name">Last Seen</div> + <div class="param-value" v-text="(new Date(device.lastSeen)).toLocaleString()"></div> + </div> + + <div class="section values" v-if="device.values && Object.keys(device.values).length"> + <div class="header"> + <div class="title">Values</div> + </div> + + <div class="body"> + <div class="row" + v-for="value, name in device.values" + :key="name"> + <div class="param-name" v-text="name"></div> + <div class="param-value"> + <toggle-switch :value="value === 'ON' ? true : false" + :data-name="name" + @toggled="setValue" + v-if="name === 'state'"> + </toggle-switch> + + <span v-text="value.toString() + '%'" v-else-if="name === 'linkquality'"></span> + + <div v-else> + <input type="text" :value="value" :data-name="name" @change="setValue"> + </div> + </div> + </div> + + <div class="row"> + <div class="param-name"> + <input type="text" placeholder="New property name" v-model="newPropertyName"> + </div> + + <div class="param-value"> + <input type="text" placeholder="New property value" @change="setValue"> + </div> + </div> + </div> + </div> + + <div class="section actions"> + <div class="header"> + <div class="title">Actions</div> + </div> + + <div class="body"> + <div class="row" @click="removeDevice(false)"> + <div class="param-name">Remove Device</div> + <div class="param-value"> + <i class="fa fa-trash"></i> + </div> + </div> + + <div class="row error" @click="removeDevice(true)"> + <div class="param-name">Force Remove Device</div> + <div class="param-value"> + <i class="fa fa-trash"></i> + </div> + </div> + + <div class="row" @click="banDevice"> + <div class="param-name">Ban Device</div> + <div class="param-value"> + <i class="fa fa-ban"></i> + </div> + </div> + + <div class="row" @click="whitelistDevice"> + <div class="param-name">Whitelist Device</div> + <div class="param-value"> + <i class="fa fa-list"></i> + </div> + </div> + </div> + </div> + </div> + </div> +</script> + +<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/zigbee.mqtt/device.js') }}"></script> + diff --git a/platypush/backend/http/templates/plugins/zigbee.mqtt/group.html b/platypush/backend/http/templates/plugins/zigbee.mqtt/group.html new file mode 100644 index 000000000..9ba2e44eb --- /dev/null +++ b/platypush/backend/http/templates/plugins/zigbee.mqtt/group.html @@ -0,0 +1,75 @@ +<script type="text/x-template" id="tmpl-zigbee-group"> + <div class="item group" :class="{selected: selected}"> + <div class="row name vertical-center" :class="{selected: selected}" + v-text="group.friendly_name" @click="onGroupClicked"></div> + + <div class="params" v-if="selected"> + <div class="section values"> + <div class="header"> + <div class="title">Values</div> + </div> + + <div class="body"> + <div class="row" v-for="value, name in properties" :key="name"> + <div class="param-name" v-text="name"></div> + <div class="param-value"> + <div v-if="name === 'state'"> + <toggle-switch :value="value" @toggled="toggleState"></toggle-switch> + </div> + <div v-else> + <input type="text" :value="value" :data-name="name" @change="setValue"> + </div> + </div> + </div> + </div> + </div> + + <div class="section devices"> + <div class="header"> + <div class="title col-10">Devices</div> + <div class="buttons col-2"> + <button class="btn btn-default" title="Add Devices" @click="bus.$emit('openAddToGroupModal')"> + <i class="fa fa-plus"></i> + </button> + </div> + </div> + + <div class="body"> + <div class="row" v-for="device in group.devices"> + <div class="col-10" v-text="device.friendly_name"></div> + <div class="buttons col-2"> + <button class="btn btn-default" title="Remove from group" @click="removeFromGroup(device.friendly_name)"> + <i class="fa fa-trash"></i> + </button> + </div> + </div> + </div> + </div> + + <div class="section actions"> + <div class="header"> + <div class="title">Actions</div> + </div> + + <div class="body"> + <div class="row" @click="renameGroup"> + <div class="col-10">Rename Group</div> + <div class="buttons col-2"> + <i class="fa fa-edit"></i> + </div> + </div> + + <div class="row" @click="removeGroup"> + <div class="col-10">Remove Group</div> + <div class="buttons col-2"> + <i class="fa fa-trash"></i> + </div> + </div> + </div> + </div> + </div> + </div> +</script> + +<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/zigbee.mqtt/group.js') }}"></script> + diff --git a/platypush/backend/http/templates/plugins/zigbee.mqtt/index.html b/platypush/backend/http/templates/plugins/zigbee.mqtt/index.html new file mode 100644 index 000000000..e059679f2 --- /dev/null +++ b/platypush/backend/http/templates/plugins/zigbee.mqtt/index.html @@ -0,0 +1,69 @@ +{% include 'plugins/zigbee.mqtt/device.html' %} +{% include 'plugins/zigbee.mqtt/group.html' %} + +<script type="text/x-template" id="tmpl-zigbee-mqtt"> + <div class="zigbee-container"> + {% include 'plugins/zigbee.mqtt/modals/group.html' %} + + <div class="view-options"> + <div class="view-selector col-s-9 col-m-10 col-l-11"> + <select @change="onViewChange"> + <option v-for="_, view in views" + v-text="(view[0].toUpperCase() + view.slice(1)).replace('_', ' ')" + :key="view" :selected="view == selected.view" :value="view"> + </option> + </select> + </div> + + <div class="buttons"> + <button class="btn btn-default" title="Add Group" v-if="selected.view === 'groups'" + :disabled="commandRunning" @click="addGroup"> + <i class="fa fa-plus"></i> + </button> + + <button class="btn btn-default" title="Network commands" @click="openNetworkCommandsDropdown"> + <i class="fa fa-cog"></i> + </button> + + <button class="btn btn-default" title="Refresh network" @click="refresh"> + <i class="fa fa-sync-alt"></i> + </button> + </div> + + <dropdown ref="networkCommandsDropdown" :items="networkDropdownItems"></dropdown> + </div> + + <div class="view devices" v-if="selected.view == 'devices'"> + <div class="no-items" v-if="Object.keys(devices).length == 0"> + <div class="loading" v-if="loading.nodes">Loading devices...</div> + <div class="empty" v-else>No devices found on the network</div> + </div> + + <zigbee-device + v-for="device, deviceId in devices" + :key="deviceId" + :device="device" + :bus="bus" + :selected="selected.deviceId == deviceId"> + </zigbee-device> + + <dropdown ref="addToGroupDropdown" :items="addToGroupDropdownItems"></dropdown> + </div> + + <div class="view groups" v-else-if="selected.view == 'groups'"> + <div class="no-items" v-if="Object.keys(groups).length == 0"> + <div class="loading" v-if="loading.groups">Loading groups...</div> + <div class="empty" v-else>No groups available on the network</div> + </div> + + <zigbee-group + v-for="group, groupId in groups" + :key="groupId" + :group="group" + :selected="selected.groupId == groupId" + :bus="bus"> + </zigbee-group> + </div> + </div> +</script> + diff --git a/platypush/backend/http/templates/plugins/zigbee.mqtt/modals/group.html b/platypush/backend/http/templates/plugins/zigbee.mqtt/modals/group.html new file mode 100644 index 000000000..0f7f39e5d --- /dev/null +++ b/platypush/backend/http/templates/plugins/zigbee.mqtt/modals/group.html @@ -0,0 +1,20 @@ +<modal id="zigbee-add-to-group" title="Add devices to group" v-model="modal.group.visible" v-if="modal.group.visible"> + <div class="group-add"> + <div class="params"> + <div class="section"> + <div class="header"> + <div class="title">Select devices to add</div> + </div> + + <div class="body"> + <div class="row clickable" @click="addToGroup(device.friendly_name, groups[selected.groupId].friendly_name)" + :key="device.friendly_name" + v-for="device in Object.values(devices).filter((dev) => Object.values(groups[selected.groupId].devices).map((d) => d.friendly_name).indexOf(dev.friendly_name) < 0)"> + <div class="param-name" v-text="device.friendly_name"></div> + </div> + </div> + </div> + </div> + </div> +</modal> + diff --git a/platypush/backend/http/templates/plugins/zwave/node.html b/platypush/backend/http/templates/plugins/zwave/node.html index 666c71eaf..27cce6d9d 100644 --- a/platypush/backend/http/templates/plugins/zwave/node.html +++ b/platypush/backend/http/templates/plugins/zwave/node.html @@ -57,7 +57,7 @@ <div class="row" v-if="node.neighbours.length"> <div class="param-name">Neighbours</div> <div class="param-value"> - <div class="row" v-for="neighbour in node.neighbours" v-text="neighbour"></div> + <div class="row pull-right" v-for="neighbour in node.neighbours" v-text="neighbour"></div> </div> </div> diff --git a/platypush/message/event/zigbee/mqtt.py b/platypush/message/event/zigbee/mqtt.py index 8a473ab9a..e3179407f 100644 --- a/platypush/message/event/zigbee/mqtt.py +++ b/platypush/message/event/zigbee/mqtt.py @@ -1,4 +1,4 @@ -from typing import Optional, Dict, Any +from typing import Dict, Any from platypush.message.event import Event @@ -108,7 +108,7 @@ class ZigbeeMqttGroupAddedEvent(ZigbeeMqttEvent): Triggered when a group is added. """ def __init__(self, host: str, port: int, group=None, *args, **kwargs): - super().__init__(*args, host=host, port=port, device=device, **kwargs) + super().__init__(*args, host=host, port=port, group=group, **kwargs) class ZigbeeMqttGroupAddedFailedEvent(ZigbeeMqttEvent): @@ -116,7 +116,7 @@ class ZigbeeMqttGroupAddedFailedEvent(ZigbeeMqttEvent): Triggered when a request to add a group fails. """ def __init__(self, host: str, port: int, group=None, *args, **kwargs): - super().__init__(*args, host=host, port=port, device=device, **kwargs) + super().__init__(*args, host=host, port=port, group=group, **kwargs) class ZigbeeMqttGroupRemovedEvent(ZigbeeMqttEvent): @@ -124,7 +124,7 @@ class ZigbeeMqttGroupRemovedEvent(ZigbeeMqttEvent): Triggered when a group is removed. """ def __init__(self, host: str, port: int, group=None, *args, **kwargs): - super().__init__(*args, host=host, port=port, device=device, **kwargs) + super().__init__(*args, host=host, port=port, group=group, **kwargs) class ZigbeeMqttGroupRemovedFailedEvent(ZigbeeMqttEvent): @@ -132,7 +132,7 @@ class ZigbeeMqttGroupRemovedFailedEvent(ZigbeeMqttEvent): Triggered when a request to remove a group fails. """ def __init__(self, host: str, port: int, group=None, *args, **kwargs): - super().__init__(*args, host=host, port=port, device=device, **kwargs) + super().__init__(*args, host=host, port=port, group=group, **kwargs) class ZigbeeMqttGroupRemoveAllEvent(ZigbeeMqttEvent): @@ -140,7 +140,7 @@ class ZigbeeMqttGroupRemoveAllEvent(ZigbeeMqttEvent): Triggered when all the devices are removed from a group. """ def __init__(self, host: str, port: int, group=None, *args, **kwargs): - super().__init__(*args, host=host, port=port, device=device, **kwargs) + super().__init__(*args, host=host, port=port, group=group, **kwargs) class ZigbeeMqttGroupRemoveAllFailedEvent(ZigbeeMqttEvent): @@ -148,7 +148,7 @@ class ZigbeeMqttGroupRemoveAllFailedEvent(ZigbeeMqttEvent): Triggered when a request to remove all the devices from a group fails. """ def __init__(self, host: str, port: int, group=None, *args, **kwargs): - super().__init__(*args, host=host, port=port, device=device, **kwargs) + super().__init__(*args, host=host, port=port, group=group, **kwargs) class ZigbeeMqttErrorEvent(ZigbeeMqttEvent): diff --git a/platypush/message/response/__init__.py b/platypush/message/response/__init__.py index 67629e0d8..7488dc4b7 100644 --- a/platypush/message/response/__init__.py +++ b/platypush/message/response/__init__.py @@ -66,7 +66,7 @@ class Response(Message): Overrides the str() operator and converts the message into a UTF-8 JSON string """ - output = self.output if self.output is not None and self.output != {} else { + output = self.output if self.output is not None else { 'success': True if not self.errors else False } diff --git a/platypush/plugins/zigbee/mqtt.py b/platypush/plugins/zigbee/mqtt.py index 180a413a1..6df4ebf87 100644 --- a/platypush/plugins/zigbee/mqtt.py +++ b/platypush/plugins/zigbee/mqtt.py @@ -92,12 +92,12 @@ class ZigbeeMqttPlugin(MqttPlugin): """ - def __init__(self, host: str, port: int = 1883, base_topic: str = 'zigbee2mqtt', timeout: int = 60, + def __init__(self, host: str = 'localhost', port: int = 1883, base_topic: str = 'zigbee2mqtt', timeout: int = 60, tls_certfile: Optional[str] = None, tls_keyfile: Optional[str] = None, tls_version: Optional[str] = None, tls_ciphers: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, **kwargs): """ - :param host: Default MQTT broker where ``zigbee2mqtt`` publishes its messages. + :param host: Default MQTT broker where ``zigbee2mqtt`` publishes its messages (default: ``localhost``). :param port: Broker listen port (default: 1883). :param base_topic: Topic prefix, as specified in ``/opt/zigbee2mqtt/data/configuration.yaml`` (default: '``base_topic``'). @@ -305,6 +305,15 @@ class ZigbeeMqttPlugin(MqttPlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ + if name == device: + self.logger.info('Old and new name are the same: nothing to do') + return + + # noinspection PyUnresolvedReferences + devices = self.devices().output + assert not [dev for dev in devices if dev.get('friendly_name') == name], \ + 'A device named {} already exists on the network'.format(name) + self.publish( topic=self._topic('bridge/config/rename{}'.format('_last' if not device else '')), msg={'old': device, 'new': name} if device else name, @@ -333,7 +342,7 @@ class ZigbeeMqttPlugin(MqttPlugin): return properties - # noinspection PyShadowingBuiltins + # noinspection PyShadowingBuiltins,DuplicatedCode @action def device_set(self, device: str, property: str, value: Any, **kwargs): """ @@ -372,6 +381,37 @@ class ZigbeeMqttPlugin(MqttPlugin): reply_topic=self._topic(device), msg=device, **self._mqtt_args(**kwargs)).\ output.get('group_list', []) + @action + def groups(self, **kwargs): + """ + Get the groups registered on the device. + + :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` + (default: query the default configured device). + """ + groups = self.publish(topic=self._topic('bridge/config/groups'), msg={}, + reply_topic=self._topic('bridge/log'), + **self._mqtt_args(**kwargs)).output.get('message', []) + + # noinspection PyUnresolvedReferences + devices = { + device['ieeeAddr']: device + for device in self.devices(**kwargs).output + } + + return [ + { + 'id': group['ID'], + 'friendly_name': group['friendly_name'], + 'optimistic': group.get('optimistic', False), + 'devices': [ + devices[device.split('/')[0]] + for device in group.get('devices', []) + ] + } + for group in groups + ] + @action def group_add(self, name: str, id: Optional[int] = None, **kwargs): """ @@ -388,6 +428,53 @@ class ZigbeeMqttPlugin(MqttPlugin): self.publish(topic=self._topic('bridge/config/add_group'), msg=args, **self._mqtt_args(**kwargs)) + # noinspection PyShadowingBuiltins,DuplicatedCode + @action + def group_set(self, group: str, property: str, value: Any, **kwargs): + """ + Set a properties on a group. The compatible properties vary depending on the devices on the group. + For example, a light bulb may have the "``state``" (with values ``"ON"`` and ``"OFF"``) and "``brightness``" + properties, while an environment sensor may have the "``temperature``" and "``humidity``" properties, and so on. + + :param group: Display name of the group. + :param property: Name of the property that should be set. + :param value: New value of the property. + :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` + (default: query the default configured device). + """ + properties = self.publish(topic=self._topic(group + '/set'), + reply_topic=self._topic(group), + msg={property: value}, **self._mqtt_args(**kwargs)).output + + if property: + assert property in properties, 'No such property: ' + property + return {property: properties[property]} + + return properties + + @action + def group_rename(self, name: str, group: str, **kwargs): + """ + Rename a group. + + :param name: New name. + :param group: Current name of the group to rename. + :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` + (default: query the default configured device). + """ + if name == group: + self.logger.info('Old and new name are the same: nothing to do') + return + + # noinspection PyUnresolvedReferences + groups = {group.get('friendly_name'): group for group in self.groups().output} + assert name not in groups, 'A group named {} already exists on the network'.format(name) + + self.publish( + topic=self._topic('bridge/config/rename'), + msg={'old': group, 'new': name} if group else name, + **self._mqtt_args(**kwargs)) + @action def group_remove(self, name: str, **kwargs): """