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 @@
+
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 @@
+