Lights panel migration - WIP

This commit is contained in:
Fabio Manganiello 2020-12-09 21:16:07 +01:00
parent 79179746a7
commit 2de1e3ebe6
22 changed files with 1193 additions and 152 deletions

View File

@ -1 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>platypush</title><link href="/static/css/chunk-0a24466a.09555bc4.css" rel="prefetch"><link href="/static/css/chunk-16a3f845.79277e60.css" rel="prefetch"><link href="/static/css/chunk-24ff873d.c68a1871.css" rel="prefetch"><link href="/static/css/chunk-4bbbb9a3.c0cffcb7.css" rel="prefetch"><link href="/static/css/chunk-5710a9bc.b05a2ff9.css" rel="prefetch"><link href="/static/css/chunk-62a3d08e.698b2d60.css" rel="prefetch"><link href="/static/css/chunk-ac6aae98.a322204e.css" rel="prefetch"><link href="/static/css/chunk-e8078048.67cca65c.css" rel="prefetch"><link href="/static/js/chunk-0a24466a.ebe2c04f.js" rel="prefetch"><link href="/static/js/chunk-16a3f845.3bdbbdb5.js" rel="prefetch"><link href="/static/js/chunk-24ff873d.0f916e0f.js" rel="prefetch"><link href="/static/js/chunk-2d2091df.377ea7b0.js" rel="prefetch"><link href="/static/js/chunk-4bbbb9a3.c1ba820e.js" rel="prefetch"><link href="/static/js/chunk-5710a9bc.5aba1b9a.js" rel="prefetch"><link href="/static/js/chunk-62a3d08e.cd0ca5eb.js" rel="prefetch"><link href="/static/js/chunk-ac6aae98.dda16597.js" rel="prefetch"><link href="/static/js/chunk-e8078048.bc52467d.js" rel="prefetch"><link href="/static/css/app.ac911816.css" rel="preload" as="style"><link href="/static/js/app.b4cc8001.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.e8b1896c.js" rel="preload" as="script"><link href="/static/css/app.ac911816.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.e8b1896c.js"></script><script src="/static/js/app.b4cc8001.js"></script></body></html>
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>platypush</title><link href="/static/css/chunk-0a24466a.2793b349.css" rel="prefetch"><link href="/static/css/chunk-16a3f845.79277e60.css" rel="prefetch"><link href="/static/css/chunk-24ff873d.c68a1871.css" rel="prefetch"><link href="/static/css/chunk-4bbbb9a3.c0cffcb7.css" rel="prefetch"><link href="/static/css/chunk-5710a9bc.b05a2ff9.css" rel="prefetch"><link href="/static/css/chunk-62a3d08e.698b2d60.css" rel="prefetch"><link href="/static/css/chunk-ac6aae98.a322204e.css" rel="prefetch"><link href="/static/css/chunk-e8078048.67cca65c.css" rel="prefetch"><link href="/static/js/chunk-0a24466a.ebe2c04f.js" rel="prefetch"><link href="/static/js/chunk-16a3f845.3bdbbdb5.js" rel="prefetch"><link href="/static/js/chunk-24ff873d.0f916e0f.js" rel="prefetch"><link href="/static/js/chunk-2d2091df.377ea7b0.js" rel="prefetch"><link href="/static/js/chunk-4bbbb9a3.c1ba820e.js" rel="prefetch"><link href="/static/js/chunk-5710a9bc.5aba1b9a.js" rel="prefetch"><link href="/static/js/chunk-62a3d08e.cd0ca5eb.js" rel="prefetch"><link href="/static/js/chunk-ac6aae98.dda16597.js" rel="prefetch"><link href="/static/js/chunk-e8078048.bc52467d.js" rel="prefetch"><link href="/static/css/app.ac911816.css" rel="preload" as="style"><link href="/static/js/app.9f4771bd.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.e8b1896c.js" rel="preload" as="script"><link href="/static/css/app.ac911816.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.e8b1896c.js"></script><script src="/static/js/app.9f4771bd.js"></script></body></html>

View File

@ -0,0 +1,192 @@
<template>
<div class="controls light-controls" @click="$event.stopPropagation()">
<Loading v-if="loading" />
<div class="row" v-if="state.bri != null">
<div class="col-1 icon">
<i class="fas fa-sun" />
</div>
<div class="col-11 control">
<Slider :range="colorConverter.ranges.bri" :disabled="loading" :value="state.bri"
@mouseup.stop="$emit(light ? 'set-light' : 'set-group', {brightness: parseInt($event.target.value)})" />
</div>
</div>
<div class="row" v-if="state.ct != null">
<div class="col-1 icon">
<i class="fas fa-thermometer-half" />
</div>
<div class="col-11 control">
<Slider :range="colorConverter.ranges.ct" :disabled="loading" :value="state.ct"
@change.stop="$emit(light ? 'set-light' : 'set-group', {temperature: parseInt($event.target.value)})" />
</div>
</div>
<label class="row" v-if="rgbColor">
<span class="col-1 icon">
<i class="fas fa-palette" />
</span>
<span class="col-11 control">
<input type="color" :value="rgbColor" @change.stop="onColorSelect" />
</span>
</label>
</div>
</template>
<script>
import Slider from "@/components/elements/Slider";
import Loading from "@/components/Loading";
import {ColorConverter} from "@/components/panels/Light/color";
export default {
name: "Controls",
components: {Loading, Slider},
emits: ['set-light', 'set-group'],
props: {
light: {
type: Object,
},
lights: {
type: Object,
},
group: {
type: Object,
},
loading: {
type: Boolean,
default: false,
},
colorConverter: {
type: Object,
default: () => new ColorConverter(),
},
},
computed: {
state() {
if (this.light?.state)
return this.light.state
const state = this.group?.state || {}
if (!this.lights)
return state
const avg = (values) => {
if (!(values && values.length))
return 0
if (values[0] instanceof Array)
return [...values[0].keys()].map((i) => {
return avg(values.map((value) => value[i]))
})
return values.reduce((sum, value) => sum+value, 0) / values.length
}
return {
...state,
...Object.entries(
Object.values(this.lights).reduce((obj, light) => {
['bri', 'hue', 'sat', 'rgb', 'xy', 'red', 'green', 'blue', 'ct'].forEach((attr) => {
if (light.state?.[attr] != null) {
obj[attr] = [...(obj[attr] || []), light.state[attr]]
}
})
return obj
}, {})
).reduce((obj, [attr, values]) => {
obj[attr] = avg(values)
return obj
}, {})
}
},
color() {
return this.getColor(this.state)
},
rgbColor() {
const rgb = this.colorConverter.toRGB(this.state)
if (rgb)
return '#' + rgb.map((x) => {
let hex = x.toString(16)
if (hex.length < 2)
hex = '0' + hex
return hex
}).join('')
return null
},
},
methods: {
onColorSelect(event) {
const rgb = event.target.value.slice(1).split(/(?=(?:..)*$)/).map((t) => parseInt(`0x${t}`))
this.$emit(this.light ? 'set-light' : 'set-group', {
rgb: rgb,
xy: this.colorConverter.rgbToXY(...rgb),
hsl: this.colorConverter.rgbToHsl(...rgb),
brightness: this.colorConverter.rgbToBri(...rgb),
})
},
getColor(state) {
return {
rgb: this.colorConverter.toRGB(state),
xy: this.colorConverter.toXY(state),
hsl: this.colorConverter.toHSL(state),
}
},
},
}
</script>
<style lang="scss" scoped>
$light-controls-bg: white;
.controls {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin-top: 2.25em;
background: $light-controls-bg;
padding: 0.5em 1em;
border-radius: 1em;
box-shadow: $plugin-panel-shadow;
.row {
width: 100%;
display: flex;
align-items: center;
}
.control {
padding-top: 0.25em;
}
.icon {
opacity: 0.7;
}
input[type=color] {
width: 100%;
border: 0;
}
}
</style>
<style lang="scss">
.light-controls {
.row {
.slider {
margin-top: 0.4em;
}
}
}
</style>

View File

@ -1,31 +1,79 @@
<template>
<div class="light-group-container">
<MenuPanel>
<li class="header">
<button class="back-btn" title="Back" @click="close" v-if="group">
<i class="fas fa-chevron-left" />
</button>
</li>
<div class="panel-row header">
<div class="col-3" v-if="group">
<button class="back-btn" title="Back" @click="close">
<i class="fas fa-chevron-left" />
</button>
</div>
<div class="col-6 name" :class="{selected: selectedView === 'group'}"
v-text="groupName" @click="selectedView = selectedView === 'group' ? null : 'group'" />
<div class="col-3 pull-right" v-if="group">
<ToggleSwitch :value="group.state.any_on" @input="$emit('group-toggle', group)" />
</div>
</div>
<div class="no-lights" v-if="!lights || !Object.keys(lights).length">
No lights found
</div>
<li v-for="(light, id) in lightsSorted" :key="id" v-else>
<Light :light="light" />
</li>
<div class="lights-view" v-else>
<div class="row view-selector">
<button :class="{selected: selectedView === 'lights'}" title="Lights" @click="selectedView = 'lights'">
<i class="fas fa-lightbulb" /> &nbsp; Lights
</button>
<button :class="{selected: selectedView === 'scenes'}" title="Scenes" @click="selectedView = 'scenes'">
<i class="far fa-image" /> &nbsp; Scenes
</button>
</div>
<div class="view" v-if="selectedView === 'lights'">
<keep-alive>
<div class="panel-row row" :class="{expanded: light.id === selectedLight}"
v-for="(light, id) in lightsSorted" :key="id"
@click="selectedLight = selectedLight === light.id ? null : light.id">
<Light :light="light" :group="group" :collapsed="light.id !== selectedLight"
:color-converter="colorConverter" @toggle="$emit('light-toggle', light)"
@set-light="$emit('set-light', {light: light, value: $event})" />
</div>
</keep-alive>
</div>
<div class="view" v-else-if="selectedView === 'scenes'">
<keep-alive>
<div class="panel-row row" :class="{selected: scene.id === selectedScene}"
v-for="(scene, id) in scenesSorted" :key="id" @click="onSceneSelected(scene.id)">
<Scene :scene="scene" :group="group" />
</div>
</keep-alive>
</div>
<div class="view group-controls" v-else-if="selectedView === 'group'">
<keep-alive>
<Controls :group="group" :lights="lights" :color-converter="colorConverter"
@set-group="$emit('set-group', $event)" />
</keep-alive>
</div>
</div>
</MenuPanel>
</div>
</template>
<script>
import Light from "@/components/Light/Light";
import Scene from "@/components/Light/Scene";
import Controls from "@/components/Light/Controls";
import MenuPanel from "@/components/MenuPanel";
import ToggleSwitch from "@/components/elements/ToggleSwitch";
import {ColorConverter} from "@/components/panels/Light/color";
export default {
name: "Group",
emits: ['close'],
components: {MenuPanel, Light},
emits: ['close', 'group-toggle', 'light-toggle', 'set-light', 'select-scene'],
components: {ToggleSwitch, MenuPanel, Light, Scene, Controls},
props: {
lights: {
type: Object,
@ -34,6 +82,23 @@ export default {
group: {
type: Object,
},
scenes: {
type: Object,
},
colorConverter: {
type: Object,
default: () => new ColorConverter(),
},
},
data() {
return {
selectedLight: null,
selectedScene: null,
selectedView: 'lights',
}
},
computed: {
@ -50,6 +115,28 @@ export default {
}
})
},
scenesSorted() {
if (!this.scenes)
return []
return Object.entries(this.scenes)
.sort((a, b) => a[1].name.localeCompare(b[1].name))
.map(([id, scene]) => {
return {
...scene,
id: id,
}
})
},
groupName() {
if (this.group?.name)
return this.group.name
if (this.group?.id != null)
return `[Group ${this.group.id}]`
return 'Lights'
},
},
methods: {
@ -57,6 +144,11 @@ export default {
event.stopPropagation()
this.$emit('close')
},
onSceneSelected(sceneId) {
this.selectedScene = sceneId
this.$emit('select-scene', sceneId)
},
},
}
</script>
@ -64,23 +156,80 @@ export default {
<style lang="scss">
.light-group-container {
width: 100%;
height: 100%;
min-height: 100%;
.row.panel-row {
flex-direction: column;
&.expanded,
&.selected {
background: $selected-bg;
}
}
.header {
padding: 0.5em !important;
display: flex;
align-items: center;
.back-btn {
border: 0;
background: none;
&:hover {
border: 0;
color: $default-hover-fg;
}
}
.name {
text-align: center;
&.selected {
color: $selected-fg;
}
&:hover {
color: $default-hover-fg;
}
}
}
li.header {
.back-btn {
background: none;
margin-left: -0.75em;
.view-selector {
width: 100%;
border-radius: 0;
button {
width: 50%;
padding: 1.5em;
text-align: left;
opacity: 0.8;
box-shadow: $plugin-panel-entry-shadow;
&:first-child {
border-right: 0;
}
&.selected {
background: $selected-bg;
}
&:hover {
background: $hover-bg;
}
}
}
}
</style>
<style lang="scss">
.light-group-container {
.group-controls {
margin: 1em;
.controls {
margin: 0;
padding: 1em;
}
}
}

View File

@ -1,23 +1,33 @@
<template>
<MenuPanel>
<li class="header">
<i class="icon fas fa-home" />
<span class="name">Rooms</span>
</li>
<li class="row group" v-for="group in groupsSorted" :key="group.id" @click="$emit('select', group.id)">
<div class="panel-row header">
<div class="col-3">
<i class="icon fas fa-home" />
</div>
<div class="col-6 name">
Rooms
</div>
<div class="col-3 pull-right">
<ToggleSwitch :value="anyLightsOn" @input="$emit('toggle')" />
</div>
</div>
<div class="panel-row row group" v-for="group in groupsSorted" :key="group.id" @click="$emit('select', group.id)">
<span class="name col-9">
{{ group.name || `[Group #${group.id}]` }}
{{ group.name || `[Group ${group.id}]` }}
</span>
<span class="controls col-3 pull-right">
<ToggleSwitch :value="group.state.any_on" :disabled="group.id in (loadingGroups || {})" @input="toggleGroup(group)" />
<ToggleSwitch :value="group.state.any_on" :disabled="group.id in (loadingGroups || {})"
@input="$emit('toggle', group)" />
</span>
</li>
</div>
</MenuPanel>
</template>
<script>
import MenuPanel from "@/components/MenuPanel";
import ToggleSwitch from "@/components/elements/ToggleSwitch";
import {ColorConverter} from "@/components/panels/Light/color";
export default {
name: "Groups",
@ -32,7 +42,12 @@ export default {
loadingGroups: {
type: Object,
default: () => {},
}
},
colorConverter: {
type: Object,
default: () => new ColorConverter(),
},
},
computed: {
@ -46,22 +61,31 @@ export default {
}
})
},
},
methods: {
toggleGroup(group) {
this.$emit('toggle', group)
}
}
anyLightsOn() {
for (const group of Object.values(this.groups))
if (group?.state?.any_on)
return true
return false
},
},
}
</script>
<style lang="scss" scoped>
.header {
display: flex;
align-items: center;
padding-top: 0.75em !important;
padding-bottom: 0.75em !important;
.icon {
margin-right: 1em;
margin-left: 0.5em;
}
.name {
text-align: center;
}
}

View File

@ -1,21 +1,69 @@
<template>
<div class="light">
{{ light.name || light.id }}
<div class="light" :class="{expanded: !collapsed}" ref="element">
<div class="row">
<span class="name col-9" @click="expandToggle">
{{ light.name || `[Light ${light.id}]` }}
</span>
<span class="toggle col-3 pull-right">
<ToggleSwitch :value="light.state.on" :disabled="loading" @input="$emit('toggle', light)" />
</span>
</div>
<div class="row fade-in" v-if="!collapsed">
<Controls :light="light" :loading="loading" :color-converter="colorConverter"
@set-light="$emit('set-light', $event)" />
</div>
</div>
</template>
<script>
import ToggleSwitch from "@/components/elements/ToggleSwitch";
import Controls from "@/components/Light/Controls";
import {ColorConverter} from "@/components/panels/Light/color";
export default {
name: "Light",
components: {Controls, ToggleSwitch},
emits: ['toggle', 'set-light', 'collapsed', 'expanded'],
props: {
light: {
type: Object,
default: () => {},
},
group: {
type: Object,
default: () => {},
},
loading: {
type: Boolean,
default: false,
},
collapsed: {
type: Boolean,
default: true,
},
colorConverter: {
type: Object,
default: () => new ColorConverter(),
},
},
methods: {
expandToggle() {
this.$emit(this.collapsed ? 'expanded' : 'collapsed')
},
},
}
</script>
<style scoped>
</style>
<style lang="scss" scoped>
.expanded {
.name {
font-size: 1.25em;
}
}
</style>

View File

@ -0,0 +1,38 @@
<template>
<Loading v-if="loading" />
<div class="scene row name" @click="onSelect">
{{ scene.name || `[Scene ${scene.id}]` }}
</div>
</template>
<script>
export default {
name: "Light",
emits: ['select'],
props: {
scene: {
type: Object,
default: () => {},
},
group: {
type: Object,
default: () => {},
},
loading: {
type: Boolean,
default: false,
},
},
methods: {
onSelect() {
if (this.loading)
return false
this.$emit('select')
},
},
}
</script>

View File

@ -1,19 +1,14 @@
<template>
<div class="menu-panel">
<ul :style="style">
<div class="content">
<slot />
</ul>
</div>
</div>
</template>
<script>
export default {
name: "MenuPanel",
props: {
style: {
type: [String, Object, Array],
},
},
}
</script>
@ -27,15 +22,16 @@ export default {
background: $menu-panel-bg;
padding-top: 2em;
ul {
.content {
background: $menu-panel-content-bg;
border-radius: 15px;
box-shadow: $plugin-panel-shadow;
border: 0;
li {
.panel-row {
display: flex;
padding: 0.5em;
margin: 0 !important;
padding: 1em;
box-shadow: $plugin-panel-entry-shadow;
cursor: pointer;
border: 0;
@ -54,15 +50,15 @@ export default {
border-radius: 0 0 15px 15px;
box-shadow: $plugin-panel-last-entry-shadow;
}
}
&.header {
.header {
background: $menu-header-bg;
font-weight: bold;
box-shadow: $menu-header-shadow;
&:hover {
background: $menu-header-bg;
font-weight: bold;
box-shadow: $menu-header-shadow;
&:hover {
background: $menu-header-bg;
}
}
}
}
@ -71,11 +67,12 @@ export default {
@media screen and (max-width: $tablet) {
.menu-panel {
padding-top: 0;
ul {
.content {
min-width: 100%;
border-radius: 0;
li {
.row {
&:first-child {
border-radius: 0;
}
@ -90,32 +87,32 @@ export default {
@media screen and (min-width: $tablet) {
.menu-panel {
ul {
min-width: 65%;
.content {
min-width: 75%;
}
}
}
@media screen and (min-width: $desktop) {
.menu-panel {
ul {
min-width: 40%;
.content {
min-width: 50%;
}
}
}
@media screen and (min-width: $widescreen) {
.menu-panel {
ul {
min-width: 30%;
.content {
min-width: 40%;
}
}
}
@media screen and (min-width: $fullhd) {
.menu-panel {
ul {
min-width: 25%;
.content {
min-width: 35%;
}
}
}

View File

@ -47,7 +47,7 @@ export default {
data() {
return {
collapsed: false,
collapsed: true,
icons: icons,
host: null,
}

View File

@ -0,0 +1,90 @@
<template>
<label>
<input class="slider" type="range" :min="range[0]" :max="range[1]" :value="value"
@change="$emit('input', $event)" @mouseup="$emit('mouseup', $event)"
@mousedown="$emit('mousedown', $event)">
</label>
</template>
<script>
export default {
name: "Slider",
emits: ['input', 'mouseup', 'mousedown'],
props: {
value: {
type: Number,
},
disabled: {
type: Boolean,
default: false,
},
range: {
type: Array,
default: () => [0, 100],
},
},
}
</script>
<style lang="scss" scoped>
.slider {
@include appearance(none);
@include transition(opacity .2s);
width: 100%;
height: 1em;
border-radius: 0.33em;
background: $slider-bg;
outline: none;
&::-webkit-slider-thumb {
@include appearance(none);
width: 1.5em;
height: 1.5em;
border-radius: 50%;
border: 0;
background: $slider-thumb-bg;
cursor: pointer;
}
&::-moz-range-thumb {
@include appearance(none);
width: 1.5em;
height: 1.5em;
border-radius: 50%;
border: 0;
background: $slider-thumb-bg;
cursor: pointer;
}
&[disabled]::-webkit-slider-thumb {
display: none;
width: 0;
}
&[disabled]::-moz-range-thumb {
display: none;
width: 0;
}
&.disabled { opacity: 0.3; }
&::-moz-range-track {
@include appearance(none);
}
&::-moz-range-progress {
background: $slider-progress-bg;
height: 1em;
}
&[disabled]::-webkit-progress-value {
background: none;
}
&[disabled]::-moz-range-progress {
background: none;
}
}
</style>

View File

@ -109,7 +109,7 @@ export default {
border-radius: 50%;
box-shadow: $toggle-dot-shadow;
left: -0.25em;
top: -1px;
top: -0.05em;
transform: translateX(var(--offset, 0));
transition: transform .4s, box-shadow .4s;
@ -152,7 +152,7 @@ export default {
font-size: 1em;
font-weight: 500;
color: #A6ACCD;
transform: translateY(4px);
transform: translateY(0.2em);
transition: opacity .4s, transform .4s;
}
}
@ -162,7 +162,7 @@ export default {
pointer-events: none;
& + span {
opacity: 1;
transform: translateY(12px);
transform: translateY(0.6em);
}
}
}

View File

@ -1,10 +1,15 @@
<template>
<div class="plugin lights-plugin">
<div class="panel" v-if="selectedGroup == null && groups && Object.keys(groups).length">
<Groups :groups="groups" :loading-groups="loadingGroups" @select="selectedGroup = $event" @toggle="toggleGroup" />
<Groups :groups="groups" :loading-groups="loadingGroups" :color-converter="colorConverter"
@select="selectedGroup = $event" @toggle="$emit('group-toggle', $event)" />
</div>
<div class="panel" v-else>
<Group :group="groups[selectedGroup]" :lights="displayedLights" @close="closeGroup" />
<Group :group="groups[selectedGroup]" :lights="displayedLights" :scenes="scenesByGroup[selectedGroup]"
:color-converter="colorConverter" @close="closeGroup" @light-toggle="$emit('light-toggle', $event)"
@group-toggle="$emit('group-toggle', $event)" @set-light="$emit('set-light', $event)"
@set-group="$emit('set-group', {groupId: selectedGroup, value: $event})"
@select-scene="$emit('select-scene', {groupId: selectedGroup, sceneId: $event})" />
</div>
</div>
</template>
@ -14,6 +19,7 @@ import Utils from "@/Utils";
import Panel from "@/components/panels/Panel";
import Groups from "@/components/Light/Groups";
import Group from "@/components/Light/Group";
import {ColorConverter} from "@/components/panels/Light/color";
/**
* Generic component for light plugins panels.
@ -22,7 +28,7 @@ export default {
name: "Light",
components: {Group, Groups},
mixins: [Utils, Panel],
emits: ['group-toggle'],
emits: ['group-toggle', 'light-toggle', 'set-light', 'set-group', 'select-scene'],
props: {
lights: {
@ -41,6 +47,11 @@ export default {
type: Object,
},
colorConverter: {
type: Object,
default: () => new ColorConverter(),
},
loadingLights: {
type: Object,
default: () => {},
@ -78,6 +89,51 @@ export default {
return lights
}, {})
},
lightsByGroup() {
if (!this.groups)
return {}
const self = this
return Object.entries(this.groups).reduce((obj, [groupId, group]) => {
obj[groupId] = group.lights.map((lightId) => self.lights[lightId])
return obj
}, {})
},
groupsByLight() {
if (!this.groups)
return {}
return Object.entries(this.groups).reduce((obj, [groupId, group]) => {
group.lights.forEach((lightId) => {
if (!obj[lightId])
obj[lightId] = {}
obj[lightId][groupId] = group
})
return obj
}, {})
},
scenesByGroup() {
if (!this.scenes)
return {}
const self = this
return Object.entries(this.scenes).reduce((obj, [sceneId, scene]) => {
scene.lights.forEach((lightId) => {
Object.keys(self.groupsByLight[lightId]).forEach((groupId) => {
if (!obj[groupId])
obj[groupId] = {}
obj[groupId][sceneId] = scene
})
})
return obj
}, {})
},
},
methods: {
@ -97,10 +153,6 @@ export default {
closeGroup() {
this.selectedGroup = null
},
toggleGroup(group) {
this.$emit('group-toggle', group)
},
},
mounted() {

View File

@ -0,0 +1,104 @@
<script>
export default {
name: "Utils",
data() {
return {
lights: {},
groups: {},
scenes: {},
animations: {},
loadingLights: {},
loadingGroups: {},
}
},
computed: {
groupsByName() {
if (!this.groups)
return {}
return Object.entries(this.groups).reduce((groups, [id, group]) => {
groups[group.name || id] = {
...group,
id: id,
}
return groups
}, {})
},
},
methods: {
_getGroups(groupIds) {
const groups = groupIds.filter((id) => id != null)
if (!groups.length)
return Object.values(this.groups)
const self = this
return groups.map((id) => id instanceof Object ? id : self.groups[id])
},
_getLights(lightIds) {
const lights = lightIds.filter((id) => id != null)
if (!lights.length)
return Object.values(this.lights)
const self = this
return lights.map((id) => id instanceof Object ? id : self.lights[id])
},
setGroupsLoading(groupsIds) {
const self = this
this._getGroups(groupsIds).forEach((group) => {
self.loadingGroups[group.id] = true
if (group.lights)
self.setLightsLoading(group.lights)
})
},
unsetGroupsLoading(groupsIds) {
const self = this
this._getGroups(groupsIds).forEach((group) => {
if (group.id in self.loadingGroups)
delete self.loadingGroups[group.id]
if (group.lights)
self.setLightsLoading(group.lights)
})
},
setLightsLoading(lightIds) {
const self = this
this._getLights(lightIds).forEach((light) => {
self.loadingLights[light.id] = true
})
},
unsetLightsLoading(lightIds) {
const self = this
this._getLights(lightIds).forEach((light) => {
if (light.id in self.loadingLights)
delete self.loadingLights[light.id]
})
},
async groupAction(action, args, ...groups) {
this.setGroupsLoading(groups)
try {
return await this.request(action, args)
} finally {
this.unsetGroupsLoading(groups)
}
},
async lightAction(action, args, ...lights) {
this.setLightsLoading(lights)
try {
return await this.request(action, args)
} finally {
this.unsetLightsLoading(lights)
}
},
},
}
</script>

View File

@ -0,0 +1,213 @@
export class ColorConverter {
constructor(ranges) {
this.ranges = {
hue: [0, 360],
sat: [0, 100],
bri: [0, 100],
ct: [154, 500],
}
if (ranges)
for (const attr of Object.keys(this.ranges))
if (ranges[attr])
this.ranges[attr] = ranges[attr]
}
normalize(x, xRange, yRange) {
return yRange[0] + (((x-xRange[0]) * (yRange[1]-yRange[0])) / (xRange[1]-xRange[0]))
}
hslToRgb(h, s, l) {
[h, s, l] = [
this.normalize(h, this.ranges.hue, [0, 360]),
this.normalize(s, this.ranges.sat, [0, 100]),
this.normalize(l, this.ranges.bri, [0, 100]),
]
l /= 100
const a = s * Math.min(l, 1 - l) / 100
const f = n => {
const k = (n + h / 30) % 12
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
return Math.round(255 * color)
}
return [f(0), f(8), f(4)]
}
rgbToHsl(r, g, b){
r /= 255
g /= 255
b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if(max === min){
h = s = 0; // achromatic
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch(max){
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [
parseInt(this.normalize(h, [0, 1], this.ranges.hue)),
parseInt(this.normalize(s, [0, 1], this.ranges.sat)),
parseInt(this.normalize(l, [0, 1], this.ranges.bri)),
]
}
xyToRgb(x, y, brightness) {
// Set to maximum brightness if no custom value was given (Not the slick ECMAScript 6 way for compatibility reasons)
if (brightness == null)
brightness = this.ranges.bri[1];
const z = 1.0 - x - y;
const Y = (brightness / (this.ranges.bri[1]-1)).toFixed(2);
const X = (Y / y) * x;
const Z = (Y / y) * z;
//Convert to RGB using Wide RGB D65 conversion
let red = X * 1.656492 - Y * 0.354851 - Z * 0.255038;
let green = -X * 0.707196 + Y * 1.655397 + Z * 0.036152;
let blue = X * 0.051713 - Y * 0.121364 + Z * 1.011530;
//If red, green or blue is larger than 1.0 set it back to the maximum of 1.0
if (red > blue && red > green && red > 1.0) {
green = green / red;
blue = blue / red;
red = 1.0;
} else if (green > blue && green > red && green > 1.0) {
red = red / green;
blue = blue / green;
green = 1.0;
} else if (blue > red && blue > green && blue > 1.0) {
red = red / blue;
green = green / blue;
blue = 1.0;
}
//Reverse gamma correction
red = red <= 0.0031308 ? 12.92 * red : (1.0 + 0.055) * Math.pow(red, (1.0 / 2.4)) - 0.055;
green = green <= 0.0031308 ? 12.92 * green : (1.0 + 0.055) * Math.pow(green, (1.0 / 2.4)) - 0.055;
blue = blue <= 0.0031308 ? 12.92 * blue : (1.0 + 0.055) * Math.pow(blue, (1.0 / 2.4)) - 0.055;
//Convert normalized decimal to decimal
red = Math.round(red * 255);
green = Math.round(green * 255);
blue = Math.round(blue * 255);
if (isNaN(red))
red = 0;
if (isNaN(green))
green = 0;
if (isNaN(blue))
blue = 0;
return [red, green, blue].map((c) => Math.min(Math.max(0, c), 255))
}
rgbToXY(red, green, blue) {
if (red > 1) { red /= 255; }
if (green > 1) { green /= 255; }
if (blue > 1) { blue /= 255; }
//Apply a gamma correction to the RGB values, which makes the color more vivid and more the like the color displayed on the screen of your device
red = (red > 0.04045) ? Math.pow((red + 0.055) / (1.0 + 0.055), 2.4) : (red / 12.92);
green = (green > 0.04045) ? Math.pow((green + 0.055) / (1.0 + 0.055), 2.4) : (green / 12.92);
blue = (blue > 0.04045) ? Math.pow((blue + 0.055) / (1.0 + 0.055), 2.4) : (blue / 12.92);
//RGB values to XYZ using the Wide RGB D65 conversion formula
const X = red * 0.664511 + green * 0.154324 + blue * 0.162028;
const Y = red * 0.283881 + green * 0.668433 + blue * 0.047685;
const Z = red * 0.000088 + green * 0.072310 + blue * 0.986039;
//Calculate the xy values from the XYZ values
let x = parseFloat((X / (X + Y + Z)).toFixed(4));
let y = parseFloat((Y / (X + Y + Z)).toFixed(4));
if (isNaN(x))
x = 0;
if (isNaN(y))
y = 0;
return [x, y];
}
rgbToBri(red, green, blue) {
return Math.min(2 * this.rgbToHsl(red, green, blue)[2], this.ranges.bri[1])
}
getRGB(color) {
if (color.red != null && color.green != null && color.blue != null)
return [color.red, color.green, color.blue]
if (color.r != null && color.g != null && color.b != null)
return [color.r, color.g, color.b]
if (color.rgb)
return color.rgb
}
getXY(color) {
if (color.x != null && color.y != null)
return [color.x, color.y]
if (color.xy)
return color.xy
}
toRGB(color) {
const rgb = this.getRGB(color)
if (rgb)
return rgb
const xy = this.getXY(color)
if (xy && color.bri)
return this.xyToRgb(...xy, color.bri)
if (color.hue && color.sat && color.bri)
return this.hslToRgb(color.hue, color.sat, color.bri)
console.debug('Could not determine color space')
console.debug(color)
}
toXY(color) {
const xy = this.getXY(color)
if (xy && color.bri)
return [xy[0], xy[1], color.bri]
const rgb = this.getRGB(color)
if (rgb)
return this.rgbToXY(...rgb)
if (color.hue && color.sat && color.bri) {
const rgb = this.hslToRgb(color.hue, color.sat, color.bri)
return this.rgbToXY(...rgb)
}
console.debug('Could not determine color space')
console.debug(color)
}
toHSL(color) {
if (color.hue && color.sat && color.bri)
return [color.hue, color.sat, color.bri]
const rgb = this.getRGB(color)
if (rgb)
return this.rgbToHsl(...rgb)
const xy = this.getXY(color)
if (xy && color.bri) {
const rgb = this.xyToRgb(...xy, color.bri)
return this.rgbToHsl(...rgb)
}
console.debug('Could not determine color space')
console.debug(color)
}
}

View File

@ -2,18 +2,21 @@
<Loading v-if="loading" />
<LightPlugin plugin-name="light.hue" :config="config" :lights="lights" :groups="groups" :scenes="scenes"
:animations="animations" :initial-group="initialGroup" :loading-groups="loadingGroups"
@group-toggle="toggleGroup" />
:color-converter="colorConverter" @group-toggle="toggleGroup"
@light-toggle="toggleLight" @set-light="setLight" @set-group="setGroup" @select-scene="setScene" />
</template>
<script>
import LightPlugin from "@/components/panels/Light/Index";
import LightUtils from "@/components/panels/Light/Utils";
import { ColorConverter } from "@/components/panels/Light/color"
import Utils from "@/Utils";
import Loading from "@/components/Loading";
export default {
name: "LightHue",
components: {Loading, LightPlugin},
mixins: [Utils],
mixins: [Utils, LightUtils],
props: {
config: {
type: Object,
@ -23,31 +26,17 @@ export default {
data() {
return {
lights: {},
groups: {},
scenes: {},
animations: {},
loading: false,
loadingLights: {},
loadingGroups: {},
colorConverter: new ColorConverter({
hue: [0, 65535],
sat: [0, 255],
bri: [0, 255],
ct: [150, 500],
})
}
},
computed: {
groupsByName() {
if (!this.groups)
return {}
return Object.entries(this.groups).reduce((groups, [id, group]) => {
groups[group.name || id] = {
...group,
id: id,
}
return groups
}, {})
},
initialGroup() {
if (!this.config.groups || !Object.keys(this.config.groups).length)
return null
@ -76,28 +65,128 @@ export default {
},
async getScenes() {
return await this.request('light.hue.get_scenes')
// return await this.request('light.hue.get_scenes')
return Object.entries(await this.request('light.hue.get_scenes'))
.filter((scene) => !scene[1].recycle && scene[1].type.toLowerCase() === 'lightscene')
.reduce((obj, [id, scene]) => {
obj[id] = scene
return obj
}, {})
},
async toggleGroup(group) {
const groups = []
if (group != null)
groups.push(group.name)
this.setGroupsLoading(group)
try {
await this.request('light.hue.toggle', {
groups: groups,
})
await this.refresh()
} finally {
this.unsetGroupsLoading(group)
let groups = Object.values(this.groups)
let args = {
groups: groups.map((group) => group.name)
}
if (group != null) {
groups = [group]
args = {
groups: [group.name],
}
}
await this.groupAction('light.hue.toggle', args, ...groups)
await this.refresh(true)
},
async refresh() {
this.loading = true
async toggleLight(light) {
const lights = [light]
const args = light != null ? {
lights: [light.name],
} : {}
await this.lightAction('light.hue.toggle', args, ...lights)
await this.refresh(true)
},
async setLight(event) {
let lights = Object.keys(this.lights)
const light = event.light
const args = {}
if (light) {
args.lights = [light.name]
lights = [light]
}
const self = this
const requests = Object.entries(event.value).map(([attr, value]) => {
let method = null;
args.value = value
switch (attr) {
case 'brightness':
method = 'light.hue.bri'
break
case 'temperature':
method = 'light.hue.ct'
break
case 'xy':
method = 'light.hue.xy'
break
}
if (method)
return self.lightAction(method, args, ...lights)
}).filter((req) => req != null)
await Promise.all(requests)
await this.refresh(true)
},
async setGroup(event) {
if (!event.groupId)
return this.setLight(event)
const group = this.groups[event.groupId]
const args = {
groups: [group.name],
}
const self = this
const requests = Object.entries(event.value).map(([attr, value]) => {
let method = null;
args.value = value
switch (attr) {
case 'brightness':
method = 'light.hue.bri'
break
case 'temperature':
method = 'light.hue.ct'
break
case 'xy':
method = 'light.hue.xy'
break
}
if (method)
return self.lightAction(method, args, group)
}).filter((req) => req != null)
await Promise.all(requests)
await this.refresh(true)
},
async setScene(event) {
await this.groupAction('light.hue.scene', {
name: this.scenes[event.sceneId].name,
groups: [this.groups[event.groupId].name],
}, this.groups[event.groupId])
await this.refresh(true)
},
async refresh(background) {
if (!background)
this.loading = true
try {
[this.lights, this.groups, this.scenes] = await Promise.all([
this.getLights(),
@ -105,26 +194,8 @@ export default {
this.getScenes(),
])
} finally {
this.loading = false
}
},
setGroupsLoading(...groups) {
let loadingGroups = {}
if (groups.length && groups[0]) {
for (const group of groups)
loadingGroups[group.id] = true
} else {
loadingGroups = Object.keys(this.groups)
}
this.loadingGroups = {...this.loadingGroups, ...loadingGroups}
},
unsetGroupsLoading(...groups) {
for (const group of groups) {
if (group.id in this.loadingGroups)
delete this.loadingGroups[group.id]
if (!background)
this.loading = false
}
},
},

View File

@ -0,0 +1,36 @@
@mixin appearance($value) {
-webkit-appearance: $value;
-ms-appearance: $value;
-o-appearance: $value;
-ms-appearance: $value;
appearance: $value;
}
@mixin transition($value) {
-webkit-transition: $value;
-ms-transition: $value;
-o-transition: $value;
-ms-transition: $value;
transition: $value;
}
@mixin animation($value) {
-webkit-animation: $value;
-ms-animation: $value;
-o-animation: $value;
-ms-animation: $value;
animation: $value;
}
@mixin box-shadow($value) {
-webkit-box-shadow: $value;
-o-box-shadow: $value;
-ms-box-shadow: $value;
box-shadow: $value;
}
@mixin calc($property, $expression) {
#{$property}: -webkit-calc( #{$expression} );
#{$property}: -moz-calc( #{$expression} );
#{$property}: calc( #{$expression} );
}

View File

@ -41,7 +41,7 @@ $modal-body-bg: white !default;
/// Selected
$selected-bg: #c8ffd0 !default;
$selected-fg: #426448;
$selected-fg: #32b646;
$selected-border: 1px solid #98cfa0 !default;
/// Links
@ -84,4 +84,11 @@ $toggle-selected-bg: linear-gradient(90deg, #4fef97, #27ee5e) !default;
$toggle-dot-bg: #d4d8d6 !default;
$toggle-shadow: inset 0 0 2px 1px #c8c8c8 !default;
$toggle-dot-shadow: inset 0 0 2px 1px #d0d0d0 !default;
$toggle-selected-dot-bg: linear-gradient(160deg, #ecfff0, #e4fff8);
$toggle-selected-dot-bg: white;
//// Slider element
$slider-bg: #e4e4e4 !default;
$slider-thumb-bg: rgba(0,215,80,1.0) !default;
$slider-thumb-disabled-bg: rgba(0,215,80,0.3) !default;
$slider-hover-on-hover-bg: #d2d2d2 !default;
$slider-progress-bg: rgba(0,215,80,0.2) !default;

View File

@ -4,7 +4,7 @@
<Nav :panels="components" :selected-panel="selectedPanel" :hostname="hostname"
@select="selectedPanel = $event" v-else />
<div class="panel-container">
<div class="canvas">
<div class="panel" v-for="(panel, name) in components" :key="name">
<component :is="panel.component" :config="panel.config" :plugin-name="name" v-if="name === selectedPanel" />
</div>
@ -99,18 +99,19 @@ main {
height: 100%;
display: flex;
.panel-container {
.canvas {
display: flex;
flex-grow: 100;
}
background: $menu-panel-bg;
.panel {
width: 100%;
height: 100%;
display: flex;
margin: 0 !important;
box-shadow: none !important;
overflow: auto;
.panel {
width: 100%;
height: 100%;
display: flex;
margin: 0 !important;
box-shadow: none !important;
overflow: auto;
}
}
}
</style>

View File

@ -6,6 +6,7 @@ module.exports = {
sass: {
additionalData: `
@import '~bulma';
@import "@/style/mixins.scss";
@import "@/style/themes/light.scss";
@import "@/style/layout.scss";
@import "@/style/components.scss";

View File

@ -154,7 +154,13 @@ class LightHuePlugin(LightPlugin):
"""
return self.bridge.get_scene()
return {
id: {
'id': id,
**scene,
}
for id, scene in self.bridge.get_scene().items()
}
@action
def get_lights(self):
@ -195,7 +201,13 @@ class LightHuePlugin(LightPlugin):
"""
return self.bridge.get_light()
return {
id: {
'id': id,
**light,
}
for id, light in self.bridge.get_light().items()
}
@action
def get_groups(self):
@ -247,7 +259,13 @@ class LightHuePlugin(LightPlugin):
"""
return self.bridge.get_group()
return {
id: {
'id': id,
**group,
}
for id, group in self.bridge.get_group().items()
}
@action
def get_animations(self):