Files
scrypted/plugins/core/ui/src/components/Device.vue
2021-12-28 19:59:55 -08:00

849 lines
25 KiB
Vue

<template>
<v-layout wrap>
<v-flex xs12>
<div v-if="deviceAlerts.length" class="pb-5">
<v-alert
dismissible
@input="removeAlert(alert)"
v-for="alert in deviceAlerts"
:key="alert.id"
xs12
md6
lg6
outlined
text
color="primary"
icon="mdi-vuetify"
border="left"
>
<template v-slot:prepend>
<v-icon class="white--text mr-3" size="sm" color="#a9afbb">{{
getAlertIcon(alert)
}}</v-icon>
</template>
<div class="caption">{{ alert.title }}</div>
<div
v-linkified:options="{ className: 'alert-link' }"
v-html="alert.message.replace('origin:', origin)"
style="color: white"
></div>
</v-alert>
</div>
</v-flex>
<v-flex v-for="iface in noCardAboveInterfaces" :key="iface" xs12>
<component :value="deviceState" :device="device" :is="iface"></component>
</v-flex>
<v-flex xs12 v-if="showConsole" ref="consoleEl">
<ConsoleCard :deviceId="id"></ConsoleCard>
</v-flex>
<v-flex xs12 v-if="showRepl" ref="replEl">
<REPLCard :deviceId="id"></REPLCard>
</v-flex>
<v-flex xs12 md7>
<v-layout row wrap>
<v-flex xs12>
<v-card raised>
<v-card-title class="orange-gradient subtitle-1 font-weight-light">
{{ name || "No Device Name" }}
<v-layout
mr-1
row
justify-end
align-center
v-if="cardHeaderInterfaces.length"
>
<component
:value="deviceState"
:device="device"
:is="iface"
v-for="iface in cardHeaderInterfaces"
:key="iface"
></component>
</v-layout>
</v-card-title>
<v-card-subtitle v-if="ownerDevice && pluginData">
<a @click="openDevice(ownerDevice.id)">{{ ownerDevice.name }}</a>
(Native ID: {{ pluginData.nativeId }})
<div></div>
</v-card-subtitle>
<v-flex v-if="cardButtonInterfaces.length">
<v-layout align-center justify-center>
<component
v-for="iface in cardButtonInterfaces"
:key="iface"
:value="deviceState"
:device="device"
:is="iface"
></component>
</v-layout>
</v-flex>
<v-container>
<v-layout>
<v-flex xs12>
<v-text-field
dense
v-model="name"
label="Name"
required
outlined
>
<template v-slot:append-outer>
<v-btn
v-if="name !== device.name"
color="success"
text
class="shift-up"
@click="saveName"
>
<v-icon>send</v-icon>
</v-btn>
</template>
</v-text-field>
<v-select
dense
v-if="inferredTypes.length > 1"
:items="inferredTypes"
label="Type"
outlined
v-model="type"
>
<template v-slot:append-outer>
<v-btn
v-if="type !== device.type"
color="success"
text
class="shift-up"
@click="saveType"
>
<v-icon>send</v-icon>
</v-btn>
</template>
</v-select>
<v-combobox
dense
v-if="
hasFixedPhysicalLocation(type, deviceState.interfaces)
"
:items="existingRooms"
outlined
v-model="room"
label="Room"
required
>
<template v-slot:append-outer>
<v-btn
v-if="room !== device.room"
color="success"
text
class="shift-up"
@click="saveRoom"
>
<v-icon>send</v-icon>
</v-btn>
</template>
</v-combobox>
</v-flex>
</v-layout>
</v-container>
<v-card-actions v-if="!ownerDevice">
<v-spacer></v-spacer>
<v-btn
small
outlined
color="blue"
@click="reloadPlugin"
class="mr-2"
>Reload Plugin</v-btn
>
<PluginAdvancedUpdate
v-if="pluginData"
:pluginData="pluginData"
@installed="reload"
/>
</v-card-actions>
<v-card-actions>
<component
v-for="iface in cardActionInterfaces"
:key="iface"
:value="deviceState"
:device="device"
:is="iface"
></component>
<v-spacer></v-spacer>
<v-btn color="info" text @click="openConsole"
>Console</v-btn
>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn x-small v-on="on" color="info" text @click="openRepl"
><v-icon x-small>fa-terminal</v-icon></v-btn
>
</template>
<span>REPL</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn x-small v-on="on" color="info" text @click="openLogs"
><v-icon x-small>fa-history</v-icon></v-btn
>
</template>
<span>Events</span>
</v-tooltip>
<v-tooltip bottom v-if="pluginData">
<template v-slot:activator="{ on }">
<v-btn
x-small
v-on="on"
color="info"
text
@click="showStorage = !showStorage"
><v-icon x-small>fa-hdd</v-icon></v-btn
>
</template>
<span>Storage</span>
</v-tooltip>
<v-dialog v-model="showDelete" width="500">
<template #activator="{ on: dialog }">
<v-tooltip bottom>
<template #activator="{ on: tooltip }">
<v-btn
x-small
v-on="{ ...tooltip, ...dialog }"
color="error"
text
><v-icon x-small>fa-trash</v-icon></v-btn
>
</template>
<span>Delete</span>
</v-tooltip>
</template>
<v-card>
<v-card-title
style="margin-bottom: 8px"
class="red font-weight-light white--text"
primary-title
>Delete Device</v-card-title
>
<v-card-text
>This will permanently delete the device. It can not be
undone.</v-card-text
>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="showDelete = false"
>Cancel</v-btn
>
<v-btn color="red" text @click="remove"
>Delete Device</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</v-card-actions>
</v-card>
</v-flex>
<v-flex xs12 v-if="deviceComponent && deviceComponent !== 'Script'">
<component
@save="saveStorage"
:is="deviceComponent"
v-model="deviceData"
:id="id"
ref="componentCard"
></component>
</v-flex>
<v-flex xs12 v-if="availableMixins.length">
<v-card raised>
<v-card-title
class="green-gradient subtitle-1 text--white font-weight-light"
>
<font-awesome-icon size="sm" icon="puzzle-piece" />
&nbsp;&nbsp;Integrations and Extensions
</v-card-title>
<v-list-item-group>
<v-list-item
@click="
mixin.enabled = !mixin.enabled;
toggleMixin(mixin);
"
v-for="mixin in availableMixins"
:key="mixin.id"
inactive
>
<v-list-item-action>
<v-checkbox
dense
@click.stop
@change="toggleMixin(mixin)"
v-model="mixin.enabled"
color="primary"
></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>{{ mixin.name }}</v-list-item-title>
</v-list-item-content>
<v-list-item-icon>
<v-list-item-action
><v-btn small @click.stop="openMixin(mixin)"
><v-icon x-small>fa-external-link-alt</v-icon></v-btn
></v-list-item-action
>
</v-list-item-icon>
</v-list-item>
</v-list-item-group>
</v-card>
</v-flex>
<v-flex xs12 v-if="showStorage">
<v-card raised>
<v-card-title
class="green-gradient subtitle-1 text--white font-weight-light"
>Storage</v-card-title
>
<v-container>
<v-layout>
<v-flex xs12>
<Storage
v-model="pluginData.storage"
@input="onChange"
@save="saveStorage"
></Storage>
</v-flex>
</v-layout>
</v-container>
</v-card>
</v-flex>
<v-flex xs12 v-for="iface in cardUnderInterfaces" :key="iface">
<v-card raised>
<v-card-title class="orange-gradient subtitle-1 font-weight-light">
{{ getInterfaceFriendlyName(iface) }}
</v-card-title>
<component
:value="deviceState"
:device="device"
:is="iface"
></component>
</v-card>
</v-flex>
</v-layout>
</v-flex>
<v-flex xs12 md5 lg5>
<v-layout row wrap>
<v-flex xs12 v-for="iface in cardInterfaces" :key="iface">
<v-card>
<v-card-title
class="red-gradient white--text subtitle-1 font-weight-light"
>{{ getInterfaceFriendlyName(iface) }}</v-card-title
>
<component
:value="deviceState"
:device="device"
:is="iface"
></component>
</v-card>
</v-flex>
<v-flex xs12 v-for="iface in noCardInterfaces" :key="iface">
<component
:value="deviceState"
:device="device"
:is="iface"
></component>
</v-flex>
<v-flex v-if="showLogs" ref="logsEl">
<LogCard :rows="15" :logRoute="`/device/${id}/`"></LogCard>
</v-flex>
</v-layout>
</v-flex>
</v-layout>
</template>
<script>
import VueSlider from "vue-slider-component";
import "vue-slider-component/theme/material.css";
import LogCard from "./builtin/LogCard.vue";
import ConsoleCard from "./ConsoleCard.vue";
import REPLCard from "./REPLCard.vue";
import {
inferTypesFromInterfaces,
getComponentWebPath,
getDeviceViewPath,
removeAlert,
getAlertIcon,
hasFixedPhysicalLocation,
getInterfaceFriendlyName,
} from "./helpers";
import { ScryptedInterface } from "@scrypted/sdk/types";
import Notifier from "../interfaces/Notifier.vue";
import OnOff from "../interfaces/OnOff.vue";
import Brightness from "../interfaces/Brightness.vue";
import Battery from "../interfaces/Battery.vue";
import Lock from "../interfaces/Lock.vue";
import ColorSettingHsv from "../interfaces/ColorSettingHsv.vue";
import ColorSettingRgb from "../interfaces/ColorSettingRgb.vue";
import OauthClient from "../interfaces/OauthClient.vue";
import Camera from "../interfaces/Camera.vue";
import VideoCamera from "../interfaces/VideoCamera.vue";
import Thermometer from "../interfaces/sensors/Thermometer.vue";
import HumiditySensor from "../interfaces/sensors/HumiditySensor.vue";
import EntrySensor from "../interfaces/sensors/EntrySensor.vue";
import MotionSensor from "../interfaces/sensors/MotionSensor.vue";
import BinarySensor from "../interfaces/sensors/BinarySensor.vue";
import AudioSensor from "../interfaces/sensors/AudioSensor.vue";
import OccupancySensor from "../interfaces/sensors/OccupancySensor.vue";
import Settings from "../interfaces/Settings.vue";
import StartStop from "../interfaces/StartStop.vue";
import Dock from "../interfaces/Dock.vue";
import Pause from "../interfaces/Pause.vue";
import Program from "../interfaces/Program.vue";
import ColorSettingTemperature from "../interfaces/ColorSettingTemperature.vue";
import Entry from "../interfaces/Entry.vue";
import HttpRequestHandler from "../interfaces/HttpRequestHandler.vue";
import PasswordStore from "../interfaces/PasswordStore.vue";
import Scene from "../interfaces/Scene.vue";
import TemperatureSetting from "../interfaces/TemperatureSetting.vue";
import PositionSensor from "../interfaces/sensors/PositionSensor.vue";
import DeviceProvider from "../interfaces/DeviceProvider.vue";
import MixinProvider from "../interfaces/MixinProvider.vue";
import Readme from "../interfaces/Readme.vue";
import Scriptable from "../interfaces/automation/Scriptable.vue";
import Storage from "../common/Storage.vue";
import { checkUpdate } from "./plugin/plugin";
import AggregateDevice from "./aggregate/AggregateDevice.vue";
import Automation from "./automation/Automation.vue";
import Script from "./script/Script.vue";
import PluginAdvancedUpdate from "./plugin/PluginAdvancedUpdate.vue";
import Vue from "vue";
import {
getDeviceAvailableMixins,
setMixin,
getDeviceMixins,
} from "../common/mixin";
const cardHeaderInterfaces = [
ScryptedInterface.OccupancySensor,
ScryptedInterface.EntrySensor,
ScryptedInterface.MotionSensor,
ScryptedInterface.BinarySensor,
ScryptedInterface.AudioSensor,
ScryptedInterface.HumiditySensor,
ScryptedInterface.Thermometer,
ScryptedInterface.Battery,
ScryptedInterface.Lock,
ScryptedInterface.OnOff,
];
const cardUnderInterfaces = [
ScryptedInterface.DeviceProvider,
ScryptedInterface.MixinProvider,
];
const noCardInterfaces = [ScryptedInterface.Settings, ScryptedInterface.Readme];
const noCardAboveInterfaces = [ScryptedInterface.Scriptable];
const cardInterfaces = [
ScryptedInterface.Brightness,
ScryptedInterface.ColorSettingTemperature,
ScryptedInterface.Notifier,
ScryptedInterface.ColorSettingHsv,
ScryptedInterface.ColorSettingRgb,
ScryptedInterface.Camera,
ScryptedInterface.VideoCamera,
ScryptedInterface.TemperatureSetting,
ScryptedInterface.PasswordStore,
ScryptedInterface.PositionSensor,
ScryptedInterface.Program,
];
const cardActionInterfaces = [
ScryptedInterface.OauthClient,
ScryptedInterface.HttpRequestHandler,
];
const cardButtonInterfaces = [
ScryptedInterface.Dock,
ScryptedInterface.Pause,
ScryptedInterface.StartStop,
ScryptedInterface.Entry,
ScryptedInterface.Scene,
];
function filterInterfaces(interfaces) {
return function () {
if (this.name == null) {
return [];
}
let ret = interfaces.filter((iface) =>
this.$store.state.systemState[this.id].interfaces.value.includes(iface)
);
if (ret.includes(ScryptedInterface.Camera)) {
ret = ret.filter((iface) => iface !== ScryptedInterface.VideoCamera);
}
if (this.pluginData?.nativeId?.startsWith("script:")) {
ret = ret.filter((iface) => iface !== ScryptedInterface.Program);
}
return ret;
};
}
export default {
components: {
DeviceProvider,
MixinProvider,
StartStop,
Dock,
Pause,
Entry,
Scene,
Brightness,
ColorSettingRgb,
ColorSettingHsv,
Notifier,
Camera,
VideoCamera,
PasswordStore,
Settings,
ColorSettingTemperature,
TemperatureSetting,
PositionSensor,
Lock,
OnOff,
Battery,
Thermometer,
HumiditySensor,
EntrySensor,
MotionSensor,
BinarySensor,
AudioSensor,
OccupancySensor,
OauthClient,
HttpRequestHandler,
PluginAdvancedUpdate,
VueSlider,
LogCard,
ConsoleCard,
REPLCard,
Readme,
Storage,
AggregateDevice,
Automation,
Program,
Script,
Scriptable,
},
data() {
return this.initialState();
},
mounted() {
if (this.needsLoad) {
this.reload();
}
this.device?.listen(undefined, (eventSource, eventDetails, eventData) => {
if (eventDetails.eventInterface === "Storage") this.reloadStorage();
});
},
destroyed() {
this.cleanupListener();
},
watch: {
devices() {
// console.log('device change detected.');
},
id() {
Object.assign(this.$data, this.initialState());
},
needsLoad() {
if (this.needsLoad) {
this.reload();
}
},
},
methods: {
getInterfaceFriendlyName,
hasFixedPhysicalLocation,
getComponentWebPath,
removeAlert,
getAlertIcon,
initialState() {
return {
showLogs: false,
showConsole: false,
showRepl: false,
showDelete: false,
pluginData: undefined,
name: undefined,
room: undefined,
type: undefined,
loading: false,
deviceComponent: undefined,
deviceData: undefined,
showStorage: false,
};
},
escapeHtml(html) {
return html
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
},
async openConsole() {
this.showConsole = !this.showConsole;
if (this.showConsole) {
await Vue.nextTick();
this.$vuetify.goTo(this.$refs.consoleEl);
}
},
async openRepl() {
this.showRepl = !this.showRepl;
if (this.showRepl) {
await Vue.nextTick();
this.$vuetify.goTo(this.$refs.replEl);
}
},
async openLogs() {
this.showLogs = !this.showLogs;
if (this.showLogs) {
await Vue.nextTick();
this.$vuetify.goTo(this.$refs.logsEl);
}
},
onChange() {
// console.log(JSON.stringify(this.device));
},
cleanupListener() {
if (this.listener) {
this.listener.removeListener();
return;
}
},
getMetadata(prop) {
const metadata = this.$store.state.systemState[this.id].metadata;
return metadata && metadata.value && metadata.value[prop];
},
async reloadPlugin() {
const plugins = await this.$scrypted.systemManager.getComponent(
"plugins"
);
await plugins.reload(this.pluginData.packageJson.name);
},
async reloadStorage() {
if (!this.pluginData) return;
const plugins = await this.$scrypted.systemManager.getComponent(
"plugins"
);
this.pluginData.storage = await plugins.getStorage(this.id);
},
async reload() {
this.name = this.device.name;
this.room = this.device.room;
this.type = this.device.type;
this.loading = true;
const plugins = await this.$scrypted.systemManager.getComponent(
"plugins"
);
const pluginData = {
updateAvailable: false,
versions: null,
};
pluginData.nativeId = await plugins.getNativeId(this.id);
pluginData.pluginId = await plugins.getPluginId(this.id);
pluginData.storage = await plugins.getStorage(this.id);
pluginData.packageJson = await plugins.getPackageJson(
pluginData.pluginId
);
this.pluginData = pluginData;
checkUpdate(pluginData.pluginId, pluginData.packageJson.version).then(
(result) => Object.assign(pluginData, result)
);
const device = this.device;
if (pluginData.pluginId === "@scrypted/core") {
const storage = await plugins.getStorage(device.id);
this.deviceData = storage["data"];
if (pluginData.nativeId?.startsWith("automation:")) {
this.deviceComponent = "Automation";
} else if (pluginData.nativeId?.startsWith("aggregate:")) {
this.deviceComponent = "AggregateDevice";
} else if (pluginData.nativeId?.startsWith("script:")) {
this.deviceComponent = "Script";
this.showConsole = true;
}
}
this.loading = false;
},
remove() {
const id = this.id;
this.$router.back();
this.$scrypted.systemManager.removeDevice(id);
},
async saveName() {
await this.device.setName(this.name);
},
async saveType() {
await this.device.setType(this.type);
},
async saveRoom() {
await this.device.setRoom(this.room);
},
async saveStorage() {
const plugins = await this.$scrypted.systemManager.getComponent(
"plugins"
);
if (this.deviceData) {
this.pluginData.storage.data = this.deviceData;
}
await plugins.setStorage(this.device.id, this.pluginData.storage);
},
openMixin(mixin) {
this.openDevice(mixin.id);
},
openDevice(id) {
this.$router.push(getDeviceViewPath(id));
},
async toggleMixin(mixin) {
await setMixin(
this.$scrypted.systemManager,
this.device,
mixin.id,
mixin.enabled
);
},
},
asyncComputed: {
availableMixins: {
async get() {
const mixins = await getDeviceMixins(
this.$scrypted.systemManager,
this.device
);
const availableMixins = (
await getDeviceAvailableMixins(
this.$scrypted.systemManager,
this.device
)
).filter((device) => !mixins.includes(device.id));
const allMixins = [
...mixins.map((id) => this.$scrypted.systemManager.getDeviceById(id)).filter(device => !!device),
...availableMixins,
];
const ret = allMixins.map((provider) => ({
id: provider.id,
name: provider.name,
enabled: mixins.includes(provider.id),
}));
return ret;
},
watch: ["id"],
default: [],
},
},
computed: {
origin() {
return window.location.origin;
},
ownerDevice() {
if (this.device.providerId === this.device.id) return;
return this.$scrypted.systemManager.getDeviceById(this.device.providerId);
},
deviceState() {
var ret = {};
Object.entries(this.$store.state.systemState[this.id]).forEach(
([key, property]) => (ret[key] = property.value)
);
return ret;
},
cardButtonInterfaces: filterInterfaces(cardButtonInterfaces),
cardActionInterfaces: filterInterfaces(cardActionInterfaces),
cardInterfaces: filterInterfaces(cardInterfaces),
noCardInterfaces: filterInterfaces(noCardInterfaces),
cardUnderInterfaces: filterInterfaces(cardUnderInterfaces),
cardHeaderInterfaces: filterInterfaces(cardHeaderInterfaces),
noCardAboveInterfaces: filterInterfaces(noCardAboveInterfaces),
inferredTypes() {
return inferTypesFromInterfaces(
this.device.type,
this.device.providedType,
this.device.interfaces
);
},
existingRooms() {
return this.$store.state.scrypted.devices
.map(
(device) => this.$scrypted.systemManager.getDeviceById(device).room
)
.filter((room) => room);
},
deviceAlerts() {
return this.$store.state.scrypted.alerts.filter((alert) =>
alert.path.startsWith(getDeviceViewPath(this.id))
);
},
devices() {
return this.$store.state.scrypted.devices;
},
id() {
return this.$route.params.id;
},
canLoad() {
return this.devices.includes(this.id);
},
needsLoad() {
return !this.pluginData && this.canLoad && !this.loading;
},
device() {
return this.$scrypted.systemManager.getDeviceById(this.id);
},
},
};
</script>
<style>
a.alert-link {
color: white;
}
</style>
</script>
<style scoped>
.shift-up {
margin-top: -8px;
}
</style>