Added Zigbee web panel (closes #123)

This commit is contained in:
Fabio Manganiello 2020-02-23 22:54:50 +01:00
parent 0643b7fade
commit 2d3c61173d
18 changed files with 1306 additions and 15 deletions

View File

@ -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;
}
}
}
}

View File

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

View File

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

After

Width:  |  Height:  |  Size: 632 B

View File

@ -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;
}

View File

@ -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');
},
},
});

View File

@ -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);
},
});

View File

@ -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();
},
});

View File

@ -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;
},
},

View File

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

View File

@ -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',
}
%}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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):

View File

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

View File

@ -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):
"""