continuous-integration/drone/push Build is passing
* Hide nanoleaf:IP devices in UI * Color clicking behavior is more clear * small icons for mega-groups * common tags/roles for groups * fix default names in UI state * hexagon corner * secondary lamp icon * fix selection bugs in device form * select all/none hotkey * quick assign modal
250 lines
7.1 KiB
Svelte
250 lines
7.1 KiB
Svelte
<script lang="ts" context="module">
|
|
import { dev } from "$app/environment";
|
|
import { fetchUIState } from "$lib/client/lucifer";
|
|
import type { DeviceIconName } from "$lib/components/DeviceIcon.svelte";
|
|
import type Assignment from "$lib/models/assignment";
|
|
import type Device from "$lib/models/device";
|
|
import type { UIStatePatch, UIStatePatch2 } from "$lib/models/uistate";
|
|
import type UIState from "$lib/models/uistate";
|
|
import { getContext, onMount, setContext } from "svelte";
|
|
import { derived, writable, type Readable } from "svelte/store";
|
|
|
|
const ctxKey = {ctx: "StateContext"};
|
|
|
|
export interface StateContextData {
|
|
reload(): Promise<void>
|
|
state: Readable<UIState>
|
|
error: Readable<string | null>
|
|
deviceList: Readable<Device[]>
|
|
assignmentList: Readable<Assignment[]>
|
|
roomList: Readable<{name: string, groups: {name: string, devices: Device[]}[], devices: Device[]}[]>
|
|
maskList: Readable<string[]>
|
|
}
|
|
|
|
export function getStateContext(): StateContextData {
|
|
return getContext(ctxKey) as StateContextData;
|
|
}
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
const state = writable<UIState>({
|
|
assignments: {},
|
|
devices: {},
|
|
scripts: {},
|
|
triggers: {},
|
|
});
|
|
const error = writable<string | null>(null);
|
|
|
|
let socket: WebSocket | null = null;
|
|
|
|
const deviceList = derived(state, state => {
|
|
if (state == null) {
|
|
return [];
|
|
}
|
|
|
|
return Object.keys(state.devices)
|
|
.map(k => state.devices[k])
|
|
.sort((a,b) => a.name.localeCompare(b.name));
|
|
});
|
|
|
|
const maskList = derived(state, state => {
|
|
return Object.keys(state.devices)
|
|
.flatMap(k => state.devices[k].aliases.filter(a => ["tag", "room", "role", "name"].find(p => a.startsWith(`lucifer:${p}:`))))
|
|
.sort()
|
|
.filter((e, i, a) => a[i - 1] !== e)
|
|
});
|
|
|
|
const assignmentList = derived(state, state => {
|
|
if (state == null) {
|
|
return [];
|
|
}
|
|
|
|
return Object.keys(state.assignments)
|
|
.map(k => state.assignments[k])
|
|
.sort((a,b) => a.id.localeCompare(b.id));
|
|
});
|
|
|
|
const roomList = derived(state, state => {
|
|
const roomMap: Record<string, Device[]> = {};
|
|
const groupMap: Record<string, string> = {};
|
|
for (const id in state.devices) {
|
|
if (!state.devices.hasOwnProperty(id)) {
|
|
continue;
|
|
}
|
|
|
|
const roomAlias = state.devices[id].aliases.find(a => a.startsWith("lucifer:room:"));
|
|
const groupAlias = state.devices[id].aliases.find(a => a.startsWith("lucifer:group:"));
|
|
const roomName = roomAlias?.slice("lucifer:room:".length) || "Unroomed";
|
|
const groupName = groupAlias?.slice("lucifer:group:".length) || "";
|
|
|
|
if (!roomMap[roomName]) {
|
|
roomMap[roomName] = [];
|
|
}
|
|
if (groupAlias) {
|
|
groupMap[id] = groupName;
|
|
}
|
|
|
|
roomMap[roomName].push(state.devices[id]);
|
|
}
|
|
|
|
return Object.keys(roomMap)
|
|
.map(k => ({
|
|
name: k,
|
|
devices: roomMap[k].filter(d => !groupMap[d.id]).sort((a,b) => a.name.localeCompare(b.name)),
|
|
groups: roomMap[k].filter(d => !!groupMap[d.id]).reduce<{name: string, devices: Device[]}[]>((p, d) => {
|
|
const g = p.find(e => e.name === groupMap[d.id]);
|
|
if (g == null) {
|
|
p.push({name:groupMap[d.id], devices: [d]})
|
|
} else {
|
|
g.devices.push(d);
|
|
}
|
|
|
|
return p;
|
|
}, [])
|
|
.map(g => ({...g, devices: g.devices.sort((a,b) => a.name.localeCompare(b.name))}))
|
|
.sort((a,b) => a.name.localeCompare(b.name))
|
|
}))
|
|
.sort((a,b) => a.name.localeCompare(b.name));
|
|
});
|
|
|
|
async function reload() {
|
|
error.set(null);
|
|
|
|
try {
|
|
const newState = await fetchUIState();
|
|
state.set(newState);
|
|
} catch(err) {
|
|
error.set(err?.toString ? err.toString() : String(err))
|
|
}
|
|
}
|
|
|
|
async function connectSocket() {
|
|
let url = `ws://${window.location.host}/subscribe-simple`;
|
|
if (import.meta.env.VITE_LUCIFER4_BACKEND_URL != null) {
|
|
url = import.meta.env.VITE_LUCIFER4_BACKEND_URL.replace("http", "ws") +"/subscribe-simple";
|
|
}
|
|
|
|
const currSocket = new WebSocket(url);
|
|
|
|
currSocket.onmessage = (msg) => {
|
|
const patch: UIStatePatch2 = JSON.parse(msg.data);
|
|
|
|
state.update(state => {
|
|
if (patch.full != null) {
|
|
state = patch.full;
|
|
for (const deviceId in state.devices) {
|
|
if (deviceId.startsWith("nanoleaf:") && deviceId.split(":").length === 2) {
|
|
delete state.devices[deviceId];
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const deviceId in patch.devices) {
|
|
if (!patch.devices.hasOwnProperty(deviceId)) {
|
|
continue;;
|
|
}
|
|
if (deviceId.startsWith("nanoleaf:") && deviceId.split(":").length === 2) {
|
|
continue;
|
|
}
|
|
|
|
state.devices = {...state.devices}
|
|
if (patch.devices[deviceId] !== null) {
|
|
state.devices[deviceId] = patch.devices[deviceId]!
|
|
} else {
|
|
delete state.devices[deviceId]
|
|
}
|
|
}
|
|
|
|
for (const assignmentId in patch.assignments) {
|
|
if (!patch.assignments.hasOwnProperty(assignmentId)) {
|
|
continue
|
|
}
|
|
|
|
state.assignments = {...state.assignments}
|
|
if (patch.assignments[assignmentId] != null) {
|
|
state.assignments[assignmentId] = patch.assignments[assignmentId]!
|
|
} else {
|
|
delete state.assignments[assignmentId]
|
|
}
|
|
}
|
|
|
|
for (const scriptName in patch.scripts) {
|
|
if (!patch.scripts.hasOwnProperty(scriptName)) {
|
|
continue
|
|
}
|
|
|
|
state.scripts = {...state.scripts}
|
|
if (patch.scripts[scriptName] != null) {
|
|
state.scripts[scriptName] = patch.scripts[scriptName]!
|
|
} else {
|
|
delete state.scripts[scriptName]
|
|
}
|
|
}
|
|
|
|
for (const triggerId in patch.triggers) {
|
|
if (!patch.triggers.hasOwnProperty(triggerId)) {
|
|
continue
|
|
}
|
|
|
|
state.triggers = {...state.triggers}
|
|
if (patch.triggers[triggerId] != null) {
|
|
state.triggers[triggerId] = patch.triggers[triggerId]!
|
|
} else {
|
|
delete state.triggers[triggerId]
|
|
}
|
|
}
|
|
|
|
return state;
|
|
})
|
|
}
|
|
|
|
currSocket.onopen = () => {
|
|
if (socket !== null) {
|
|
socket.close();
|
|
}
|
|
|
|
socket = currSocket;
|
|
}
|
|
|
|
currSocket.onerror = err => {
|
|
console.warn("Socket failed:", err);
|
|
currSocket.close();
|
|
|
|
setTimeout(() => connectSocket(), 3000);
|
|
}
|
|
|
|
currSocket.onclose = () => {
|
|
if (currSocket === socket) {
|
|
socket = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
connectSocket();
|
|
|
|
window.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState == "visible") {
|
|
console.log("Reconnecting");
|
|
connectSocket();
|
|
} else {
|
|
if (socket != null) {
|
|
console.log("Disconnecting")
|
|
socket.close();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
setContext<StateContextData>(ctxKey, {
|
|
reload,
|
|
error: { subscribe: error.subscribe },
|
|
state: { subscribe: state.subscribe },
|
|
deviceList,
|
|
assignmentList,
|
|
roomList,
|
|
maskList,
|
|
});
|
|
</script>
|
|
|
|
<slot></slot> |