diff --git a/platypush/backend/http/static/css/source/common/elements.scss b/platypush/backend/http/static/css/source/common/elements.scss index f07a9d043b..d78c2be46f 100644 --- a/platypush/backend/http/static/css/source/common/elements.scss +++ b/platypush/backend/http/static/css/source/common/elements.scss @@ -11,6 +11,10 @@ text-align: right !important; } +.clickable { + cursor: pointer; +} + a:focus { outline: none; } diff --git a/platypush/backend/http/static/css/source/webpanel/plugins/zwave/index.scss b/platypush/backend/http/static/css/source/webpanel/plugins/zwave/index.scss new file mode 100644 index 0000000000..872ab07cf9 --- /dev/null +++ b/platypush/backend/http/static/css/source/webpanel/plugins/zwave/index.scss @@ -0,0 +1,195 @@ +@import 'common/vars'; +@import 'webpanel/plugins/zwave/vars'; + +.fa.fa-zwave:before { + content: ' '; + background: url('/static/img/icons/z-wave-logo.png'); + background-size: 1em 1em; + width: 1em; + height: 1em; + display: inline-block; +} + +.zwave-container { + height: 100%; + padding: 0 .5em; + background: $container-bg; + display: flex; + flex-direction: column; + align-items: center; + + .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; + } + } + + .modal { + .section { + .header { + background: none; + padding: .5em 0; + } + + .body { + padding: 0; + } + } + + .network-info { + min-width: 600pt; + } + } + + .error { + color: $error-color; + } + + .node { + .actions { + .row { + cursor: pointer; + } + } + + form { + margin-bottom: 0; + } + } +} + diff --git a/platypush/backend/http/static/css/source/webpanel/plugins/zwave/vars.scss b/platypush/backend/http/static/css/source/webpanel/plugins/zwave/vars.scss new file mode 100644 index 0000000000..798334a201 --- /dev/null +++ b/platypush/backend/http/static/css/source/webpanel/plugins/zwave/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/z-wave-logo.png b/platypush/backend/http/static/img/icons/z-wave-logo.png new file mode 100644 index 0000000000..df9c955059 Binary files /dev/null and b/platypush/backend/http/static/img/icons/z-wave-logo.png differ diff --git a/platypush/backend/http/static/js/plugins/light.hue/index.js b/platypush/backend/http/static/js/plugins/light.hue/index.js index 52fc3e6c65..d903a07f35 100644 --- a/platypush/backend/http/static/js/plugins/light.hue/index.js +++ b/platypush/backend/http/static/js/plugins/light.hue/index.js @@ -132,7 +132,7 @@ Vue.component('light-hue', { ); this.selectedScene = event.id; - groups = {} + const groups = {}; for (const lightId of Object.values(this.scenes[this.selectedScene].lights)) { this.lights[lightId].state.on = true; @@ -148,7 +148,7 @@ Vue.component('light-hue', { groups[groupId].lights.push(lightId); - if (groups[groupId].lights.length == Object.values(group.lights).length) { + if (groups[groupId].lights.length === Object.values(group.lights).length) { groups[groupId].all_on = true; } } @@ -164,8 +164,8 @@ Vue.component('light-hue', { }, collapsedToggled: function(event) { - if (event.type == this.selectedProperties.type - && event.id == this.selectedProperties.id) { + if (event.type === this.selectedProperties.type + && event.id === this.selectedProperties.id) { this.selectedProperties = { type: undefined, id: undefined, diff --git a/platypush/backend/http/static/js/plugins/zwave/group.js b/platypush/backend/http/static/js/plugins/zwave/group.js new file mode 100644 index 0000000000..0410c1afdb --- /dev/null +++ b/platypush/backend/http/static/js/plugins/zwave/group.js @@ -0,0 +1,24 @@ +Vue.component('zwave-group', { + template: '#tmpl-zwave-group', + props: ['group','nodes','bus','selected'], + + methods: { + onGroupClicked: function() { + this.bus.$emit('groupClicked', { + groupId: this.group.index, + }); + }, + + removeFromGroup: async function(nodeId) { + if (!confirm('Are you sure that you want to remove this node from ' + this.group.label + '?')) { + return; + } + + await request('zwave.remove_node_from_group', { + node_id: nodeId, + group_index: this.group.index, + }); + }, + }, +}); + diff --git a/platypush/backend/http/static/js/plugins/zwave/index.js b/platypush/backend/http/static/js/plugins/zwave/index.js new file mode 100644 index 0000000000..48aed6dc3a --- /dev/null +++ b/platypush/backend/http/static/js/plugins/zwave/index.js @@ -0,0 +1,457 @@ +Vue.component('zwave', { + template: '#tmpl-zwave', + props: ['config'], + + data: function() { + return { + bus: new Vue({}), + status: {}, + views: {}, + nodes: {}, + groups: {}, + scenes: {}, + values: {}, + switches: new Set(), + dimmers: new Set(), + sensors: new Set(), + batteryLevels: new Set(), + powerLevels: new Set(), + bulbs: new Set(), + doorlocks: new Set(), + usercodes: new Set(), + thermostats: new Set(), + protections: new Set(), + commandRunning: false, + selected: { + view: 'nodes', + nodeId: undefined, + groupId: undefined, + sceneId: undefined, + valueId: undefined, + }, + loading: { + status: false, + nodes: false, + groups: false, + scenes: false, + values: false, + }, + modal: { + networkInfo: { + visible: false, + }, + group: { + visible: false, + }, + }, + }; + }, + + computed: { + networkDropdownItems: function() { + const self = this; + return [ + { + text: 'Start Network', + disabled: this.commandRunning, + click: async function() { + self.commandRunning = true; + await request('zwave.start_network'); + self.commandRunning = false; + }, + }, + + { + text: 'Stop Network', + disabled: this.commandRunning, + click: async function() { + self.commandRunning = true; + await request('zwave.start_network'); + self.commandRunning = false; + }, + }, + + { + text: 'Switch All On', + disabled: this.commandRunning, + click: async function() { + self.commandRunning = true; + await request('zwave.switch_all', {state: true}); + self.commandRunning = false; + self.refresh(); + }, + }, + + { + text: 'Switch All Off', + disabled: this.commandRunning, + click: async function() { + self.commandRunning = true; + await request('zwave.switch_all', {state: false}); + self.commandRunning = false; + self.refresh(); + }, + }, + + { + text: 'Cancel Command', + click: async function() { + await request('zwave.cancel_command'); + }, + }, + + { + text: 'Kill Command', + click: async function() { + await request('zwave.kill_command'); + }, + }, + + { + text: 'Set Controller Name', + disabled: this.commandRunning, + click: async function() { + const name = prompt('Controller name'); + if (!name) { + return; + } + + self.commandRunning = true; + await request('zwave.set_controller_name', {name: name}); + self.commandRunning = false; + self.refresh(); + }, + }, + + + { + text: 'Receive Configuration From Primary', + disabled: this.commandRunning, + click: async function() { + self.commandRunning = true; + await request('zwave.receive_configuration'); + self.commandRunning = false; + self.refresh(); + }, + }, + + { + text: 'Create New Primary', + disabled: this.commandRunning, + click: async function() { + self.commandRunning = true; + await request('zwave.create_new_primary'); + self.commandRunning = false; + self.refresh(); + }, + }, + + { + text: 'Transfer Primary Role', + disabled: this.commandRunning, + click: async function() { + self.commandRunning = true; + await request('zwave.transfer_primary_role'); + self.commandRunning = false; + self.refresh(); + }, + }, + + { + text: 'Heal Network', + disabled: this.commandRunning, + click: async function() { + self.commandRunning = true; + await request('zwave.heal'); + self.commandRunning = false; + self.refresh(); + }, + }, + + { + text: 'Soft Reset', + disabled: this.commandRunning, + click: async function() { + if (!confirm('Are you sure that you want to do a device soft reset? Network information will not be lost')) { + return; + } + + await request('zwave.soft_reset'); + }, + }, + + { + text: 'Hard Reset', + disabled: this.commandRunning, + click: async function() { + if (!confirm('Are you sure that you want to do a device soft reset? ALL network information will be lost!')) { + return; + } + + await request('zwave.hard_reset'); + }, + }, + ] + }, + }, + + methods: { + refreshNodes: async function () { + this.loading.nodes = true; + this.loading.values = true; + + this.nodes = await request('zwave.get_nodes'); + this.loading.nodes = false; + + this.values = Object.values(this.nodes).reduce((values, node) => { + values = { + ...Object.values(node.values).reduce((values, value) => { + values[value.value_id] = { + node_id: node.node_id, + ...value, + }; + + return values; + }, {}), + ...values + }; + + return values; + }, {}); + + this.loading.values = false; + }, + + refreshGroups: async function () { + this.loading.groups = true; + this.groups = Object.values(await request('zwave.get_groups')) + .filter((group) => group.index) + .reduce((groups, group) => { + groups[group.index] = group; + return groups; + }, {}); + + if (Object.keys(this.groups).length) { + Vue.set(this.views, 'groups', true); + } + + this.loading.groups = false; + }, + + refreshScenes: async function () { + this.loading.scenes = true; + this.scenes = Object.values(await request('zwave.get_scenes')) + .filter((scene) => scene.scene_id) + .reduce((scenes, scene) => { + scenes[scene.scene_id] = scene; + return scenes; + }, {}); + + this.loading.scenes = false; + }, + + refreshSwitches: async function () { + this.switches = new Set(Object.values(await request('zwave.get_switches')) + .filter((sw) => sw.id_on_network).map((sw) => sw.value_id)); + + if (this.switches.size) { + Vue.set(this.views, 'switches', true); + } + }, + + refreshDimmers: async function () { + this.dimmers = new Set(Object.values(await request('zwave.get_dimmers')) + .filter((dimmer) => dimmer.id_on_network).map((dimmer) => dimmer.value_id)); + + if (this.dimmers.size) { + Vue.set(this.views, 'dimmers', true); + } + }, + + refreshSensors: async function () { + this.sensors = new Set(Object.values(await request('zwave.get_sensors')) + .filter((sensor) => sensor.id_on_network).map((sensor) => sensor.value_id)); + + if (this.sensors.size) { + Vue.set(this.views, 'sensors', true); + } + }, + + refreshBatteryLevels: async function () { + this.batteryLevels = new Set(Object.values(await request('zwave.get_battery_levels')) + .filter((battery) => battery.id_on_network).map((battery) => battery.value_id)); + + if (this.batteryLevels.size) { + Vue.set(this.views, 'batteryLevels', true); + } + }, + + refreshPowerLevels: async function () { + this.powerLevels = new Set(Object.values(await request('zwave.get_power_levels')) + .filter((power) => power.id_on_network).map((power) => power.value_id)); + + if (this.powerLevels.size) { + Vue.set(this.views, 'powerLevels', true); + } + }, + + refreshBulbs: async function () { + this.bulbs = new Set(Object.values(await request('zwave.get_bulbs')) + .filter((bulb) => bulb.id_on_network).map((bulb) => bulb.value_id)); + + if (this.bulbs.size) { + Vue.set(this.views, 'bulbs', true); + } + }, + + refreshDoorlocks: async function () { + this.doorlocks = new Set(Object.values(await request('zwave.get_doorlocks')) + .filter((lock) => lock.id_on_network).map((lock) => lock.value_id)); + + if (this.doorlocks.size) { + Vue.set(this.views, 'doorlocks', true); + } + }, + + refreshUsercodes: async function () { + this.doorlocks = new Set(Object.values(await request('zwave.get_usercodes')) + .filter((code) => code.id_on_network).map((code) => code.value_id)); + + if (this.usercodes.size) { + Vue.set(this.views, 'usercodes', true); + } + }, + + refreshThermostats: async function () { + this.thermostats = new Set(Object.values(await request('zwave.get_thermostats')) + .filter((th) => th.id_on_network).map((th) => th.value_id)); + + if (this.thermostats.size) { + Vue.set(this.views, 'thermostats', true); + } + }, + + refreshProtections: async function () { + this.protections = new Set(Object.values(await request('zwave.get_protections')) + .filter((p) => p.id_on_network).map((p) => p.value_id)); + + if (this.protections.size) { + Vue.set(this.views, 'protections', true); + } + }, + + refreshStatus: async function() { + this.loading.status = true; + this.status = await request('zwave.status'); + this.loading.status = false; + }, + + refresh: function () { + this.views = { + nodes: true, + scenes: true, + }; + + this.refreshNodes(); + this.refreshGroups(); + this.refreshScenes(); + this.refreshSwitches(); + this.refreshDimmers(); + this.refreshSensors(); + this.refreshBulbs(); + this.refreshDoorlocks(); + this.refreshUsercodes(); + this.refreshThermostats(); + this.refreshProtections(); + this.refreshBatteryLevels(); + this.refreshPowerLevels(); + this.refreshStatus(); + }, + + onNodeUpdate: function(event) { + Vue.set(this.nodes, event.node.node_id, event.node); + }, + + onViewChange: function(event) { + Vue.set(this.selected, 'view', event.target.value); + }, + + onNodeClicked: function(event) { + Vue.set(this.selected, 'nodeId', event.nodeId === this.selected.nodeId ? undefined : event.nodeId); + }, + + onGroupClicked: function(event) { + Vue.set(this.selected, 'groupId', event.groupId === this.selected.groupId ? undefined : event.groupId); + }, + + onNetworkInfoModalOpen: function() { + this.refreshStatus(); + this.modal.networkInfo.visible = true; + }, + + onCommandEvent: function(event) { + if (event.error && event.error.length) { + createNotification({ + text: event.state_description + ': ' + event.error_description, + error: true, + }); + } + }, + + openNetworkCommandsDropdown: function() { + openDropdown(this.$refs.networkCommandsDropdown); + }, + + addNode: async function() { + this.commandRunning = true; + await request('zwave.add_node'); + this.commandRunning = false; + }, + + addToGroup: async function(nodeId, groupId) { + this.commandRunning = true; + await request('zwave.add_node_to_group', { + node_id: nodeId, + group_index: groupId, + }); + + this.commandRunning = false; + this.refreshGroups(); + }, + + removeNode: async function() { + this.commandRunning = true; + await request('zwave.remove_node'); + this.commandRunning = false; + }, + }, + + created: function() { + const self = this; + this.bus.$on('nodeClicked', this.onNodeClicked); + this.bus.$on('groupClicked', this.onGroupClicked); + this.bus.$on('openAddToGroupModal', () => {self.modal.group.visible = true}); + + registerEventHandler(this.refreshGroups, 'platypush.message.event.zwave.ZwaveNodeGroupEvent'); + registerEventHandler(this.refreshScenes, 'platypush.message.event.zwave.ZwaveNodeSceneEvent'); + registerEventHandler(this.refreshNodes, 'platypush.message.event.zwave.ZwaveNodeRemovedEvent'); + registerEventHandler(this.onCommandEvent, 'platypush.message.event.zwave.ZwaveCommandEvent'); + + registerEventHandler(this.refreshStatus, + 'platypush.message.event.zwave.ZwaveNetworkReadyEvent', + 'platypush.message.event.zwave.ZwaveNetworkStoppedEvent', + 'platypush.message.event.zwave.ZwaveNetworkErrorEvent', + 'platypush.message.event.zwave.ZwaveNetworkResetEvent'); + + registerEventHandler(this.onNodeUpdate, + 'platypush.message.event.zwave.ZwaveNodeEvent', + 'platypush.message.event.zwave.ZwaveNodeAddedEvent', + 'platypush.message.event.zwave.ZwaveNodeRenamedEvent', + 'platypush.message.event.zwave.ZwaveNodeReadyEvent'); + }, + + mounted: function() { + this.refresh(); + }, +}); + diff --git a/platypush/backend/http/static/js/plugins/zwave/node.js b/platypush/backend/http/static/js/plugins/zwave/node.js new file mode 100644 index 0000000000..9dc88a4477 --- /dev/null +++ b/platypush/backend/http/static/js/plugins/zwave/node.js @@ -0,0 +1,96 @@ +Vue.component('zwave-node', { + template: '#tmpl-zwave-node', + props: ['node','bus','selected'], + data: function() { + return { + editMode: { + name: false, + }, + }; + }, + + methods: { + onNodeClicked: function() { + this.bus.$emit('nodeClicked', { + nodeId: this.node.node_id, + }); + }, + + removeFailedNode: async function() { + if (!confirm('Are you sure that you want to remove this node?')) { + return; + } + + await request('zwave.remove_node', { + node_id: this.node.node_id, + }); + }, + + replaceFailedNode: async function() { + if (!confirm('Are you sure that you want to replace this node?')) { + return; + } + + await request('zwave.replace_node', { + node_id: this.node.node_id, + }); + }, + + replicationSend: async function() { + await request('zwave.replication_send', { + node_id: this.node.node_id, + }); + }, + + requestNetworkUpdate: async function() { + await request('zwave.request_network_update', { + node_id: this.node.node_id, + }); + }, + + requestNeighbourUpdate: async function() { + await request('zwave.request_node_neighbour_update', { + node_id: this.node.node_id, + }); + }, + + 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('zwave.set_node_name', { + node_id: this.node.node_id, + new_name: name, + }); + + this.editMode.name = false; + this.enableForm(event.target); + }, + + heal: async function(event) { + await request('zwave.node_heal', { + node_id: this.node.node_id, + }); + }, + }, +}); + diff --git a/platypush/backend/http/templates/nav.html b/platypush/backend/http/templates/nav.html index 9c10552752..72a80ff1a3 100644 --- a/platypush/backend/http/templates/nav.html +++ b/platypush/backend/http/templates/nav.html @@ -19,6 +19,7 @@ 'switches': 'fa fa-toggle-on', 'tts': 'fa fa-comment', 'tts.google': 'fa fa-comment', + 'zwave': 'fa fa-zwave', } %} diff --git a/platypush/backend/http/templates/plugins/zwave/group.html b/platypush/backend/http/templates/plugins/zwave/group.html new file mode 100644 index 0000000000..f2ae849dca --- /dev/null +++ b/platypush/backend/http/templates/plugins/zwave/group.html @@ -0,0 +1,54 @@ + + + + diff --git a/platypush/backend/http/templates/plugins/zwave/index.html b/platypush/backend/http/templates/plugins/zwave/index.html new file mode 100644 index 0000000000..5f0478c215 --- /dev/null +++ b/platypush/backend/http/templates/plugins/zwave/index.html @@ -0,0 +1,99 @@ +{% include 'plugins/zwave/node.html' %} +{% include 'plugins/zwave/group.html' %} + + + diff --git a/platypush/backend/http/templates/plugins/zwave/modals/group.html b/platypush/backend/http/templates/plugins/zwave/modals/group.html new file mode 100644 index 0000000000..668bb309da --- /dev/null +++ b/platypush/backend/http/templates/plugins/zwave/modals/group.html @@ -0,0 +1,19 @@ + +
+
+
+
+
Select nodes to add
+
+ +
+
+
+
+
+
+
+
+
+ diff --git a/platypush/backend/http/templates/plugins/zwave/modals/network.html b/platypush/backend/http/templates/plugins/zwave/modals/network.html new file mode 100644 index 0000000000..1cc7fce988 --- /dev/null +++ b/platypush/backend/http/templates/plugins/zwave/modals/network.html @@ -0,0 +1,35 @@ + +
+
+ Loading status... +
+ +
+
+
State
+
+
+ +
+
Device
+
+
+ +
+
+
Statistics
+
+ +
+
+
+
+
+
+
+
+
+
+ diff --git a/platypush/backend/http/templates/plugins/zwave/node.html b/platypush/backend/http/templates/plugins/zwave/node.html new file mode 100644 index 0000000000..ea59ba14bc --- /dev/null +++ b/platypush/backend/http/templates/plugins/zwave/node.html @@ -0,0 +1,186 @@ + + + + diff --git a/platypush/plugins/zwave/__init__.py b/platypush/plugins/zwave/__init__.py index 2b88c357d7..a08606fe08 100644 --- a/platypush/plugins/zwave/__init__.py +++ b/platypush/plugins/zwave/__init__.py @@ -52,6 +52,22 @@ class ZwavePlugin(Plugin): backend = self._get_backend() backend.stop_network() + @action + def status(self) -> Dict[str, Any]: + """ + Get the status of the controller. + :return: dict + """ + backend = self._get_backend() + network = self._get_network() + controller = self._get_controller() + + return { + 'device': backend.device, + 'state': network.state_str, + 'stats': controller.stats, + } + @action def add_node(self, do_security=False): """ @@ -189,7 +205,7 @@ class ZwavePlugin(Plugin): 'node_id': node.node_id, 'home_id': node.home_id, 'capabilities': list(node.capabilities), - 'command_classes': list(node.command_classes), + 'command_classes': [node.get_command_class_as_string(cc) for cc in node.command_classes], 'device_type': node.device_type, 'groups': { group_id: cls.group_to_dict(group) @@ -367,13 +383,13 @@ class ZwavePlugin(Plugin): self._get_controller().kill_command() @action - def set_controller_name(self, node_name: str): + def set_controller_name(self, name: str): """ Set the name of the controller on the network. - :param node_name: New controller name. + :param name: New controller name. """ - self._get_controller().name = node_name + self._get_controller().name = name self.write_config() @action @@ -613,9 +629,9 @@ class ZwavePlugin(Plugin): return self._get_values('power_levels', node_id=node_id, node_name=node_name) @action - def get_rgb_bulbs(self, node_id: Optional[int] = None, node_name: Optional[str] = None) -> Dict[int, Any]: + def get_bulbs(self, node_id: Optional[int] = None, node_name: Optional[str] = None) -> Dict[int, Any]: """ - Get the RGB bulbs/LEDs on the network or associated to a node. + Get the bulbs/LEDs on the network or associated to a node. :param node_id: Select node by node_id. :param node_name: Select node by name.