From 2d3c61173d657ba0f98b2cf5ab5fd05c2fcbdae6 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 23 Feb 2020 22:54:50 +0100 Subject: [PATCH] Added Zigbee web panel (closes #123) --- .../webpanel/plugins/zigbee.mqtt/index.scss | 264 +++++++++++++++ .../webpanel/plugins/zigbee.mqtt/vars.scss | 13 + .../http/static/img/icons/zigbee-logo.svg | 5 + .../http/static/js/elements/dropdown.js | 7 +- .../static/js/plugins/zigbee.mqtt/device.js | 127 +++++++ .../static/js/plugins/zigbee.mqtt/group.js | 149 +++++++++ .../static/js/plugins/zigbee.mqtt/index.js | 311 ++++++++++++++++++ .../http/static/js/plugins/zwave/index.js | 2 +- .../http/templates/elements/dropdown.html | 4 +- platypush/backend/http/templates/nav.html | 1 + .../templates/plugins/zigbee.mqtt/device.html | 163 +++++++++ .../templates/plugins/zigbee.mqtt/group.html | 75 +++++ .../templates/plugins/zigbee.mqtt/index.html | 69 ++++ .../plugins/zigbee.mqtt/modals/group.html | 20 ++ .../http/templates/plugins/zwave/node.html | 2 +- platypush/message/event/zigbee/mqtt.py | 14 +- platypush/message/response/__init__.py | 2 +- platypush/plugins/zigbee/mqtt.py | 93 +++++- 18 files changed, 1306 insertions(+), 15 deletions(-) create mode 100644 platypush/backend/http/static/css/source/webpanel/plugins/zigbee.mqtt/index.scss create mode 100644 platypush/backend/http/static/css/source/webpanel/plugins/zigbee.mqtt/vars.scss create mode 100644 platypush/backend/http/static/img/icons/zigbee-logo.svg create mode 100644 platypush/backend/http/static/js/plugins/zigbee.mqtt/device.js create mode 100644 platypush/backend/http/static/js/plugins/zigbee.mqtt/group.js create mode 100644 platypush/backend/http/static/js/plugins/zigbee.mqtt/index.js create mode 100644 platypush/backend/http/templates/plugins/zigbee.mqtt/device.html create mode 100644 platypush/backend/http/templates/plugins/zigbee.mqtt/group.html create mode 100644 platypush/backend/http/templates/plugins/zigbee.mqtt/index.html create mode 100644 platypush/backend/http/templates/plugins/zigbee.mqtt/modals/group.html 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 @@ + + + Zigbee icon + + 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 @@ + + + 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 @@ + + + + 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' %} + + + 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 @@ + +
+
+
+
+
Select devices to add
+
+ +
+
+
+
+
+
+
+
+
+ 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 @@
Neighbours
-
+
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): """