Zigbee web panel migration [WIP]

This commit is contained in:
Fabio Manganiello 2021-02-08 02:04:59 +01:00
parent b8979040da
commit 86e6ffd18d
11 changed files with 1243 additions and 88 deletions

View file

@ -1745,6 +1745,16 @@
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"dev": true "dev": true
}, },
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"optional": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"cacache": { "cacache": {
"version": "13.0.1", "version": "13.0.1",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-13.0.1.tgz", "resolved": "https://registry.npmjs.org/cacache/-/cacache-13.0.1.tgz",
@ -1771,6 +1781,34 @@
"unique-filename": "^1.1.1" "unique-filename": "^1.1.1"
} }
}, },
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"optional": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"optional": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"optional": true
},
"find-cache-dir": { "find-cache-dir": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz",
@ -1792,6 +1830,25 @@
"path-exists": "^4.0.0" "path-exists": "^4.0.0"
} }
}, },
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"optional": true
},
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"optional": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"locate-path": { "locate-path": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@ -1856,6 +1913,16 @@
"minipass": "^3.1.1" "minipass": "^3.1.1"
} }
}, },
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"optional": true,
"requires": {
"has-flag": "^4.0.0"
}
},
"terser-webpack-plugin": { "terser-webpack-plugin": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-2.3.8.tgz", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-2.3.8.tgz",
@ -1872,6 +1939,18 @@
"terser": "^4.6.12", "terser": "^4.6.12",
"webpack-sources": "^1.4.3" "webpack-sources": "^1.4.3"
} }
},
"vue-loader-v16": {
"version": "npm:vue-loader@16.1.2",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.1.2.tgz",
"integrity": "sha512-8QTxh+Fd+HB6fiL52iEVLKqE9N1JSlMXLR92Ijm6g8PZrwIxckgpqjPDWRP5TWxdiPaHR+alUWsnu1ShQOwt+Q==",
"dev": true,
"optional": true,
"requires": {
"chalk": "^4.1.0",
"hash-sum": "^2.0.0",
"loader-utils": "^2.0.0"
}
} }
} }
}, },
@ -11741,87 +11820,6 @@
} }
} }
}, },
"vue-loader-v16": {
"version": "npm:vue-loader@16.0.0-rc.1",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.0.0-rc.1.tgz",
"integrity": "sha512-yR+BS90EOXTNieasf8ce9J3TFCpm2DGqoqdbtiwQ33hon3FyIznLX7sKavAq1VmfBnOeV6It0Htg4aniv8ph1g==",
"dev": true,
"optional": true,
"requires": {
"chalk": "^4.1.0",
"hash-sum": "^2.0.0",
"loader-utils": "^2.0.0"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"optional": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"optional": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"optional": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"optional": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"optional": true
},
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"optional": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"optional": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"vue-router": { "vue-router": {
"version": "4.0.0-rc.3", "version": "4.0.0-rc.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.0-rc.3.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.0-rc.3.tgz",

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

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

@ -26,6 +26,9 @@
}, },
"rtorrent": { "rtorrent": {
"class": "fa fa-magnet" "class": "fa fa-magnet"
},
"zigbee.mqtt": {
"imgUrl": "/icons/zigbee.svg"
} }
} }
} }

View file

@ -11,6 +11,7 @@
<a :href="`/#${name}`"> <a :href="`/#${name}`">
<span class="icon"> <span class="icon">
<i :class="icons[name].class" v-if="icons[name]?.class" /> <i :class="icons[name].class" v-if="icons[name]?.class" />
<img :src="icons[name].imgUrl" v-else-if="icons[name]?.imgUrl" alt="name"/>
<i class="fas fa-puzzle-piece" v-else /> <i class="fas fa-puzzle-piece" v-else />
</span> </span>
<span class="name" v-if="!collapsed" v-text="name" /> <span class="name" v-if="!collapsed" v-text="name" />

View file

@ -1,9 +1,9 @@
<template> <template>
<div class="row item" @click="clicked"> <div class="row item" @click="clicked">
<div class="col-1 icon"> <div class="col-1 icon" v-if="iconClass">
<i :class="iconClass" v-if="iconClass" /> <i :class="iconClass" />
</div> </div>
<div class="col-11 text" v-text="text" /> <div class="text" :class="{'col-11': iconClass != null}" v-text="text" />
</div> </div>
</template> </template>

View file

@ -4,6 +4,8 @@
@change="$emit('input', $event)" @mouseup="$emit('mouseup', $event)" @input="$emit('input', $event)" @change="$emit('input', $event)" @mouseup="$emit('mouseup', $event)" @input="$emit('input', $event)"
@mousedown="$emit('mousedown', $event)" @touch="$emit('input', $event)" @mousedown="$emit('mousedown', $event)" @touch="$emit('input', $event)"
@touchstart="$emit('mousedown', $event)" @touchend="$emit('mouseup', $event)"> @touchstart="$emit('mousedown', $event)" @touchend="$emit('mouseup', $event)">
<span class="label" v-if="withLabel" v-text="value" />
</label> </label>
</template> </template>
@ -25,6 +27,11 @@ export default {
type: Array, type: Array,
default: () => [0, 100], default: () => [0, 100],
}, },
withLabel: {
type: Boolean,
default: false,
}
}, },
} }
</script> </script>
@ -62,14 +69,21 @@ export default {
&[disabled] { &[disabled] {
&::-webkit-progress-value, &::-webkit-progress-value,
&::-moz-range-progress { &::-moz-range-progress {
background: none; opacity: .5;
} }
&::-webkit-slider-thumb, &::-webkit-slider-thumb,
&::-moz-range-thumb { &::-moz-range-thumb {
display: none; opacity: .4;
width: 0;
} }
} }
} }
</style>
label {
position: relative;
.label {
font-weight: normal;
text-align: center;
}
}
</style>

View file

@ -0,0 +1,498 @@
<template>
<div class="item device" :class="{selected: selected}">
<Loading v-if="loading" />
<div class="row name header vertical-center" :class="{selected: selected}"
v-text="device.friendly_name || device.ieee_address" @click="$emit('select')" />
<div class="params" v-if="selected">
<div class="row">
<div class="param-name">Name</div>
<div class="param-value">
<div class="name-edit" :class="{hidden: !editName}">
<form @submit.prevent="rename">
<label>
<input type="text" name="name" ref="name" :value="device.friendly_name">
</label>
<span class="buttons">
<button type="button" class="btn btn-default" @click="editName = 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="name-edit" :class="{hidden: editName}">
<span v-text="device.friendly_name"></span>
<span class="buttons">
<button type="button" class="btn btn-default" @click="editName = true">
<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.ieee_address"></div>
</div>
<div class="row" v-if="device.network_address">
<div class="param-name">Network Address</div>
<div class="param-value" v-text="device.network_address"></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.definition?.vendor">
<div class="param-name">Vendor</div>
<div class="param-value">
{{ device.definition.vendor }}
</div>
</div>
<div class="row" v-if="device.definition?.model">
<div class="param-name">Model</div>
<div class="param-value">
{{ device.definition.model }}
</div>
</div>
<div class="row" v-if="device.model_id">
<div class="param-name">Model ID</div>
<div class="param-value">
{{ device.model_id }}
</div>
</div>
<div class="row" v-if="device.definition?.description">
<div class="param-name">Description</div>
<div class="param-value">
{{ device.definition.description }}
</div>
</div>
<div class="row" v-if="device.software_build_id">
<div class="param-name">Software Build ID</div>
<div class="param-value">
{{ device.software_build_id }}
</div>
</div>
<div class="row" v-if="device.definition?.date_code">
<div class="param-name">Date Code</div>
<div class="param-value">
{{ device.definition.date_code }}
</div>
</div>
<div class="row" v-if="device.power_source">
<div class="param-name">Power Source</div>
<div class="param-value">
{{ device.power_source }}
</div>
</div>
<div class="section values" v-if="Object.keys(displayedValues).length">
<div class="header">
<div class="title">Values</div>
</div>
<div class="body">
<div class="row value" v-for="(value, property) in displayedValues" :key="property">
<div class="param-name">
{{ value.description }}
<span class="text" v-if="value.value?.x != null && value.value?.y != null">Color (XY coordinates)</span>
<span class="text" v-else-if="value.value?.hue != null && value.value?.saturation != null">Color (Hue/saturation)</span>
<span class="name" v-text="value.property" v-if="value.property" />
<span class="unit" v-text="value.unit" v-if="value.unit" />
</div>
<div class="param-value">
<ToggleSwitch :value="value.value_on != null ? value.value === value.value_on : !!value.value"
v-if="value.type === 'binary'" @input="setValue(value, $event)" />
<Slider :with-label="true" :range="[value.value_min, value.value_max]" :value="value.value"
:disabled="!value.writable" @change="setValue(value, $event)"
v-else-if="value.type === 'numeric' && value.value_min != null && value.value_max != null" />
<label v-else-if="value.type === 'numeric' && (value.value_min == null || value.value_max == null)">
<input type="number" :with-label="true" :value="value.value" :disabled="!value.writable"
@change="setValue(value, $event)" />
</label>
<label v-else-if="value.type === 'enum'">
<select :value="value.readable && value.value != null ? value.value : ''"
@change="setValue(value, $event)">
<option v-if="!value.readable" />
<option v-for="option in value.values" :key="option" :value="option" v-text="option"
:selected="value.readable && value.value === option" :disabled="!value.writable" />
</select>
</label>
<label v-else-if="(value.value?.x != null && value.value?.y != null) || (value.value?.hue != null && value.value?.saturation != null)">
<!-- <input type="color" :value="rgbColor" @change.stop="onColorSelect" />-->
</label>
<label v-else>
<input type="text" :disabled="!value.writable" :value="value.value" @change="setValue(value, $event)" />
</label>
</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>
</template>
<script>
import Loading from "@/components/Loading";
import Slider from "@/components/elements/Slider";
import ToggleSwitch from "@/components/elements/ToggleSwitch";
import Utils from "@/Utils";
// import {ColorConverter} from "@/components/panels/Light/color";
export default {
name: "Device",
components: {ToggleSwitch, Slider, Loading},
mixins: [Utils],
emits: ['select', 'rename'],
props: {
device: {
type: Object,
required: true,
},
selected: {
type: Boolean,
default: false,
},
},
data() {
return {
editName: false,
loading: false,
status: {},
}
},
computed: {
values() {
if (!this.device.definition?.exposes)
return {}
const extractValues = (values) => {
const extractValue = (value, root) => {
if (!value.features) {
if (value.property)
root[value.property] = value
return
}
if (value.property) {
root[value.property] = root[value.property] || {}
root = root[value.property]
}
for (const feature of value.features)
extractValue(feature, root)
}
const ret = {}
for (const value of values)
extractValue(value, ret)
return ret
}
return extractValues(this.device.definition.exposes)
},
displayedValues() {
const ret = {}
const mergeValues = (obj, [key, value]) => {
if (key in this.status)
value = {
...value,
value: this.status[key]
}
if (value.access != null) {
value.readable = !!(value.access & 0x1)
value.writable = !!(value.access & 0x2)
delete value.access
}
obj[key] = value
Object.entries(value).filter((v) => v[1] instanceof Object).reduce(mergeValues, obj[key])
return obj
}
Object.entries(this.values).reduce(mergeValues, ret)
return ret
},
},
methods: {
async refresh() {
this.loading = true
try {
this.status = await this.request('zigbee.mqtt.device_get',
{device: this.device.friendly_name || this.device.ieee_address})
} finally {
this.loading = false
}
},
async rename() {
const name = (this.$refs.name.value || '').trim()
if (!name.length || name === this.device.friendly_name)
return
this.loading = true
try {
await this.request('zigbee.mqtt.device_rename', {
device: this.device.friendly_name?.length ? this.device.friendly_name : this.device.ieee_address,
name: name,
})
this.$emit('rename', {name: this.device.friendly_name, newName: name});
} finally {
this.editName = false
this.loading = false
}
},
async setValue(value, event) {
const request = {
device: this.device.friendly_name || this.device.ieee_address,
property: value.property,
value: null,
}
switch (value.type) {
case 'binary':
if (value.value_toggle) {
request.value = value.value_toggle
} else if (value.value_on && value.value_off) {
request.value = value.value === value.value_on ? value.value_off : value.value_on
} else {
request.value = !value.value
}
break
case 'numeric':
request.value = parseFloat(event.target.value)
break
case 'enum':
if (event.target.value?.length) {
request.value = event.target.value
}
break
}
if (request.value == null)
return
this.loading = true
try {
await this.request('zigbee.mqtt.device_set', request)
await this.refresh()
} finally {
this.loading = false
}
},
},
created() {
this.refresh()
this.$watch(() => this.selected, (newValue) => {
if (newValue)
this.refresh()
})
},
}
</script>
<style lang="scss" scoped>
@import "common";
.item {
&.selected {
box-shadow: $selected-item-box-shadow;
}
.name.header {
padding: 1em !important;
cursor: pointer;
text-transform: uppercase;
letter-spacing: .06em;
&.selected {
border-radius: 1.5em;
}
&.selected {
background: $selected-bg;
}
}
.title {
font-size: 1.2em;
padding-left: .5em;
}
&:hover {
background: $hover-bg;
&.selected {
background: $selected-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 {
.section {
padding: 0;
}
}
.value {
.param-name {
display: inline-block;
.name {
font-family: monospace;
font-size: .8em;
text-transform: unset;
padding: 0;
&:before {
content: '[';
}
&:after {
content: ']';
}
}
.unit {
font-size: .8em;
&:before {
content: ' [unit: ';
}
&:after {
content: ']';
}
}
}
.param-value {
label {
width: 90%;
}
input {
width: 100%;
}
}
}
button {
border: 0;
background: none;
padding: 0 .5em;
&:hover {
color: $default-hover-fg;
}
}
.name-edit {
width: 100%;
display: inline-flex;
justify-content: right;
align-items: center;
form {
width: 100%;
display: inline-flex;
align-items:center;
justify-content: right;
flex-direction: row;
}
.buttons {
display: inline-flex;
justify-content: right;
margin: 0 0 0 .5em;
}
form {
background: none;
padding: 0;
border: none;
box-shadow: none;
}
}
}
</style>

View file

@ -0,0 +1,521 @@
<template>
<div class="zigbee-container">
<Loading v-if="loading" />
<!-- Include group modal -->
<div class="view-options">
<div class="view-selector col-s-8 col-m-9 col-l-10">
<label>
<select :value="selected.view" @change="this.selected.view = $event.target.value">
<option v-for="(enabled, view) in views"
v-text="(view[0].toUpperCase() + view.slice(1)).replace('_', ' ')"
:key="view" :selected="enabled" :value="view">
</option>
</select>
</label>
</div>
<div class="buttons">
<button class="btn btn-default" title="Add Group" v-if="selected.view === 'groups'"
:disabled="loading" @click="addGroup">
<i class="fa fa-plus"></i>
</button>
<Dropdown ref="networkCommandsDropdown" icon-class="fa fa-cog" title="Network commands">
<DropdownItem text="Start Network" :disabled="loading" @click="startNetwork" />
<DropdownItem text="Stop Network" :disabled="loading" @click="stopNetwork" />
<DropdownItem text="Permit Join" :disabled="loading" @click="permitJoin(true)" />
<DropdownItem text="Reset" :disabled="loading" @click="reset" />
<DropdownItem text="Factory Reset" :disabled="loading" @click="factoryReset" />
</Dropdown>
<button class="btn btn-default" title="Refresh network" :disabled="loading" @click="refresh">
<i class="fa fa-sync-alt"></i>
</button>
</div>
</div>
<div class="view-container">
<div class="view devices" v-if="selected.view === 'devices'">
<div class="no-items" v-if="!Object.keys(devices).length">
<div class="loading" v-if="loading">Loading devices...</div>
<div class="empty" v-else>No devices found on the network</div>
</div>
<ZigbeeDevice v-for="(device, id) in devices" :key="id"
:device="device" :selected="selected.deviceId === id"
@select="selected.deviceId = selected.deviceId === id ? null : id"
@rename="refreshDevices" />
<!-- <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">
<div class="loading" v-if="loading">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>
</div>
</template>
<script>
import Dropdown from "../../elements/Dropdown"
import DropdownItem from "@/components/elements/DropdownItem"
import Loading from "@/components/Loading"
import Utils from "@/Utils"
import ZigbeeDevice from "@/components/panels/ZigbeeMqtt/Device";
export default {
name: "ZigbeeMqtt",
components: {Dropdown, DropdownItem, Loading, ZigbeeDevice},
mixins: [Utils],
data() {
return {
status: {},
devices: {},
groups: {},
loading: false,
selected: {
view: 'devices',
deviceId: undefined,
groupId: undefined,
},
views: {
devices: true,
groups: true,
},
modal: {
group: {
visible: false,
},
},
}
},
computed: {
// 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.loading,
// click: async function () {
// if (!self.selected.valueId) {
// return
// }
//
// self.loading = true
// await this.request('zwave.scene_add_value', {
// id_on_network: self.selected.valueId,
// scene_id: group.scene_id,
// })
//
// self.loading = false
// self.refresh()
// },
// }
// })
// },
},
methods: {
async refreshDevices() {
this.loading = true
try {
this.devices = (await this.request('zigbee.mqtt.devices')).reduce((devices, device) => {
if (device.friendly_name in this.devices) {
device = {
values: this.devices[device.friendly_name].values || {},
...this.devices[device.friendly_name],
}
}
devices[device.friendly_name] = device
return devices
}, {})
} finally {
this.loading = false
}
},
async refreshGroups() {
this.loading = true
this.groups = (await this.request('zigbee.mqtt.groups')).reduce((groups, group) => {
groups[group.id] = group
return groups
}, {})
this.loading = false
},
refresh() {
this.refreshDevices()
this.refreshGroups()
},
updateProperties(device, props) {
this.devices[device].values = props
},
async addGroup() {
const name = prompt('Group name')
if (!(name && name.length)) {
return
}
this.loading = true
try {
await this.request('zigbee.mqtt.group_add', {name: name})
} finally {
this.loading = false
}
await this.refreshGroups()
},
async startNetwork() {
this.loading = true
try {
await this.request('zigbee.mqtt.start_network')
} finally {
this.loading = false
}
},
async stopNetwork() {
this.loading = true
try {
await this.request('zigbee.mqtt.stop_network')
} finally {
this.loading = false
}
},
async permitJoin(permit) {
let seconds = prompt('Join allow period in seconds (type 0 for no time limits)', '60')
if (!seconds) {
return
}
seconds = parseInt(seconds)
this.loading = true
try {
await this.request('zigbee.mqtt.permit_join', {permit: !!permit, timeout: seconds || null})
} finally {
this.loading = false
}
},
async reset() {
if (!confirm('Are you sure that you want to reset the device?')) {
return
}
this.loading = true
try {
await this.request('zigbee.mqtt.reset')
} finally {
this.loading = false
}
},
async factoryReset() {
if (!confirm('Are you SURE that you want to do a device factory reset?')) {
if (!confirm('Are you REALLY sure? ALL network information and custom firmware will be lost!!'))
return
}
this.loading = true
try {
await this.request('zigbee.mqtt.factory_reset')
} finally {
this.loading = false
}
},
// openAddToGroupDropdown(event) {
// this.selected.valueId = event.valueId
// openDropdown(this.$refs.addToGroupDropdown)
// },
async addToGroup(device, group) {
this.loading = true
await this.request('zigbee.mqtt.group_add_device', {
device: device,
group: group,
})
this.loading = false
const self = this
setTimeout(() => {
self.refresh()
self.refreshGroups()
}, 100)
},
async removeNodeFromGroup(event) {
if (!confirm('Are you sure that you want to remove this value from the group?')) {
return
}
this.loading = true
await this.request('zigbee.mqtt.group_remove_device', {
group: event.group,
device: event.device,
})
this.loading = false
},
},
created() {
// const self = this
// this.bus.$on('refresh', this.refresh)
// this.bus.$on('refreshDevices', this.refreshDevices)
// this.bus.$on('refreshGroups', this.refreshGroups)
// 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.ZigbeeMqttOfflineEvent')
//
// registerEventHandler(() => {
// createNotification({
// text: 'The controller is now online',
// iconClass: 'fas fa-check',
// })
// }, 'platypush.message.event.zigbee.mqtt.ZigbeeMqttOfflineEvent')
//
// 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((event) => {
// self.updateProperties(event.device, event.properties)
// }, 'platypush.message.event.zigbee.mqtt.ZigbeeMqttDevicePropertySetEvent')
//
// registerEventHandler(this.refresh,
// 'platypush.message.event.zigbee.mqtt.ZigbeeMqttOnlineEvent',
// '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() {
this.refresh()
},
}
</script>
<style lang="scss">
@import "common";
.zigbee-container {
width: 100%;
height: 100%;
padding: 0;
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%;
height: $header-height;
justify-content: space-between;
align-items: center;
padding: 0;
background: $header-bg;
border-bottom: $default-border-2;
box-shadow: $border-shadow-bottom;
.view-selector {
display: inline-flex;
padding-left: .5em;
label {
width: 100%;
}
}
.buttons {
display: inline-flex;
margin: 0;
button {
background: none;
border: none;
padding: 0 .75em;
&:hover {
color: $default-hover-fg;
}
}
.dropdown {
.item {
padding: .5em 2em .5em .5em;
}
}
}
}
.view-container {
width: 100%;
height: calc(100% - #{$header-height});
display: flex;
justify-content: center;
overflow: auto;
padding-top: 2em;
}
.view {
min-width: 400pt;
max-width: 750pt;
height: max-content;
background: $view-bg;
border: $view-border;
border-radius: 1.5em;
box-shadow: $view-box-shadow;
}
.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;
}
}
}
.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;
}
}
}
}
</style>

View file

@ -0,0 +1,102 @@
@import "vars";
.zigbee-container {
.view {
.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;
}
}
}
.row {
.param-name {
display: inline-flex;
width: 40%;
margin-left: 1%;
vertical-align: top;
letter-spacing: .04em;
}
.param-value {
display: inline-flex;
width: 58%;
justify-content: right;
align-items: center;
.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%;
}
.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;
}
}
}
}
select {
width: 100%;
}
}

View file

@ -0,0 +1,13 @@
$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: #f0f0f0;
$param-odd-row-bg: white;
$param-section-header-border: 1px solid #e8e8e8;
$selected-item-box-shadow: 0 2px 4px 0 #bbb;
$error-color: #aa0000;
$header-bg: #f8f8f8;
$header-height: 3.5em;