Files
advanced-camera-card/src/editor.ts
T
2021-11-01 22:20:35 -07:00

664 lines
25 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helpers';
import { localize } from './localize/localize.js';
import {
FrigateCardConfig,
frigateCardConfigDefaults,
frigateCardConfigSchema,
} from './types.js';
import frigate_card_editor_style from './scss/editor.scss';
const options = {
basic: {
icon: 'cog',
name: localize('editor.basic'),
secondary: localize('editor.basic_secondary'),
show: true,
},
frigate: {
icon: 'alpha-f-box',
name: localize('editor.frigate'),
secondary: localize('editor.frigate_secondary'),
show: false,
},
view: {
icon: 'eye',
name: localize('editor.view'),
secondary: localize('editor.view_secondary'),
show: false,
},
menu: {
icon: 'menu',
name: localize('editor.menu'),
secondary: localize('editor.menu_secondary'),
show: false,
},
live: {
icon: 'cctv',
name: localize('editor.live'),
secondary: localize('editor.live_secondary'),
show: false,
},
event_viewer: {
icon: 'filmstrip',
name: localize('editor.event_viewer'),
secondary: localize('editor.event_viewer_secondary'),
show: false,
},
image: {
icon: 'image',
name: localize('editor.image'),
secondary: localize('editor.image_secondary'),
show: false,
},
dimensions: {
icon: 'aspect-ratio',
name: localize('editor.dimensions'),
secondary: localize('editor.dimensions'),
show: false,
},
};
@customElement('frigate-card-editor')
export class FrigateCardEditor extends LitElement implements LovelaceCardEditor {
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: FrigateCardConfig;
@state() private _toggle?: boolean;
@state() private _helpers?: any;
private _initialized = false;
public setConfig(config: FrigateCardConfig): void {
// Note: This does not use Zod to parse the configuration, so it may be
// partially or completely invalid. It's more useful to have a partially
// valid configuration here, to allow the user to fix the broken parts.
this._config = config;
this.loadCardHelpers();
}
protected shouldUpdate(): boolean {
if (!this._initialized) {
this._initialize();
}
return true;
}
protected _getEntities(domain: string): string[] {
if (!this.hass) {
return [];
}
const entities = Object.keys(this.hass.states).filter(
(eid) => eid.substr(0, eid.indexOf('.')) === domain,
);
entities.sort();
// Add a blank entry to unset a selection.
entities.unshift('');
return entities;
}
protected render(): TemplateResult | void {
if (!this.hass || !this._helpers) {
return html``;
}
// The climate more-info has ha-switch and paper-dropdown-menu elements that
// are lazy loaded unless explicitly loaded via climate here.
this._helpers.importMoreInfoControl('climate');
const cameraEntities = this._getEntities('camera');
const webrtcCameraEntity =
this._config?.live?.webrtc && (this._config?.live.webrtc as any).entity
? (this._config?.live.webrtc as any).entity
: '';
const viewModes = {
'': '',
live: localize('config.view.views.live'),
clips: localize('config.view.views.clips'),
snapshots: localize('config.view.views.snapshots'),
clip: localize('config.view.views.clip'),
snapshot: localize('config.view.views.snapshot'),
image: localize('config.view.views.image'),
};
const menuModes = {
'': '',
none: localize('config.menu.modes.none'),
'hidden-top': localize('config.menu.modes.hidden-top'),
'hidden-left': localize('config.menu.modes.hidden-left'),
'hidden-bottom': localize('config.menu.modes.hidden-bottom'),
'hidden-right': localize('config.menu.modes.hidden-right'),
'overlay-top': localize('config.menu.modes.overlay-top'),
'overlay-left': localize('config.menu.modes.overlay-left'),
'overlay-bottom': localize('config.menu.modes.overlay-bottom'),
'overlay-right': localize('config.menu.modes.overlay-right'),
'hover-top': localize('config.menu.modes.hover-top'),
'hover-left': localize('config.menu.modes.hover-left'),
'hover-bottom': localize('config.menu.modes.hover-bottom'),
'hover-right': localize('config.menu.modes.hover-right'),
above: localize('config.menu.modes.above'),
below: localize('config.menu.modes.below'),
};
const liveProviders = {
'': '',
frigate: localize('config.live.providers.frigate'),
'frigate-jsmpeg': localize('config.live.providers.frigate-jsmpeg'),
webrtc: localize('config.live.providers.webrtc'),
};
const eventViewerNextPreviousControlStyles = {
'': '',
thumbnails: localize(
'config.event_viewer.controls.next_previous.styles.thumbnails',
),
chevrons: localize('config.event_viewer.controls.next_previous.styles.chevrons'),
none: localize('config.event_viewer.controls.next_previous.styles.none'),
};
const aspectRatioModes = {
'': '',
dynamic: localize('config.dimensions.aspect_ratio_modes.dynamic'),
static: localize('config.dimensions.aspect_ratio_modes.static'),
unconstrained: localize('config.dimensions.aspect_ratio_modes.unconstrained'),
};
const defaults = frigateCardConfigDefaults;
return html`
<div class="card-config">
<div class="option" @click=${this._toggleOption} .option=${'basic'}>
<div class="row">
<ha-icon .icon=${`mdi:${options.basic.icon}`}></ha-icon>
<div class="title">${options.basic.name}</div>
</div>
<div class="secondary">${options.basic.secondary}</div>
</div>
${options.basic.show
? html`
<div class="values">
<paper-dropdown-menu
label=${localize('config.camera_entity')}
@value-changed=${this._valueChanged}
.configValue=${'camera_entity'}
>
<paper-listbox
slot="dropdown-content"
.selected=${cameraEntities.indexOf(
this._config?.camera_entity || '',
)}
>
${cameraEntities.map((entity) => {
return html` <paper-item>${entity}</paper-item> `;
})}
</paper-listbox>
</paper-dropdown-menu>
</div>
`
: ''}
<div class="option" @click=${this._toggleOption} .option=${'frigate'}>
<div class="row">
<ha-icon .icon=${`mdi:${options.frigate.icon}`}></ha-icon>
<div class="title">${options.frigate.name}</div>
</div>
<div class="secondary">${options.frigate.secondary}</div>
</div>
${options.frigate.show
? html`
<div class="values">
<paper-input
label=${localize('config.frigate.camera_name')}
.value=${this._config?.frigate?.camera_name || ''}
.configValue=${'frigate.camera_name'}
@value-changed=${this._valueChanged}
></paper-input>
<paper-input
label=${localize('config.frigate.url')}
.value=${this._config?.frigate?.url || ''}
.configValue=${'frigate.url'}
@value-changed=${this._valueChanged}
></paper-input>
<paper-input
.label=${localize('config.frigate.label')}
.value=${this._config?.frigate?.label || ''}
.configValue=${'frigate.label'}
@value-changed=${this._valueChanged}
></paper-input>
<paper-input
.label=${localize('config.frigate.zone')}
.value=${this._config?.frigate?.zone || ''}
.configValue=${'frigate.zone'}
@value-changed=${this._valueChanged}
></paper-input>
<paper-input
label=${localize('config.frigate.client_id')}
.value=${this._config?.frigate?.client_id || ''}
.configValue=${'frigate.client_id'}
@value-changed=${this._valueChanged}
></paper-input>
</div>
`
: ''}
<div class="option" @click=${this._toggleOption} .option=${'view'}>
<div class="row">
<ha-icon .icon=${`mdi:${options.view.icon}`}></ha-icon>
<div class="title">${options.view.name}</div>
</div>
<div class="secondary">${options.view.secondary}</div>
</div>
${options.view.show
? html`
<div class="values">
<paper-dropdown-menu
label=${localize('config.view.default')}
@value-changed=${this._valueChanged}
.configValue=${'view.default'}
>
<paper-listbox
slot="dropdown-content"
.selected=${Object.keys(viewModes).indexOf(
this._config?.view?.default || '',
)}
>
${Object.keys(viewModes).map((key) => {
return html`
<paper-item .label="${key}"> ${viewModes[key]} </paper-item>
`;
})}
</paper-listbox>
</paper-dropdown-menu>
<paper-input
label=${localize('config.view.timeout')}
prevent-invalid-input
allowed-pattern="[0-9]"
.value=${this._config?.view?.timeout
? String(this._config?.view?.timeout)
: ''}
.configValue=${'view.timeout'}
@value-changed=${this._valueChanged}
></paper-input>
</div>
`
: ''}
<div class="option" @click=${this._toggleOption} .option=${'menu'}>
<div class="row">
<ha-icon .icon=${`mdi:${options.menu.icon}`}></ha-icon>
<div class="title">${options.menu.name}</div>
</div>
<div class="secondary">${options.menu.secondary}</div>
</div>
${options.menu.show
? html`
<div class="values">
<paper-dropdown-menu
.label=${localize('config.menu.mode')}
@value-changed=${this._valueChanged}
.configValue=${'menu.mode'}
>
<paper-listbox
slot="dropdown-content"
.selected=${Object.keys(menuModes).indexOf(
this._config?.menu?.mode || '',
)}
>
${Object.keys(menuModes).map((key) => {
return html`
<paper-item .label="${key}"> ${menuModes[key]} </paper-item>
`;
})}
</paper-listbox>
</paper-dropdown-menu>
<paper-input
label=${localize('config.menu.button_size')}
.value=${this._config?.menu?.button_size || ''}
.configValue=${'menu.button_size'}
@value-changed=${this._valueChanged}
></paper-input>
<ha-formfield
.label=${localize('editor.show_button') +
': ' +
localize('config.menu.buttons.frigate')}
>
<ha-switch
.checked=${this._config?.menu?.buttons?.frigate ??
defaults.menu.buttons.frigate}
.configValue=${'menu.buttons.frigate'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<ha-formfield
.label=${localize('editor.show_button') +
': ' +
localize('config.view.views.live')}
>
<ha-switch
.checked=${this._config?.menu?.buttons?.live ??
defaults.menu.buttons.live}
.configValue=${'menu.buttons.live'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<ha-formfield
.label=${localize('editor.show_button') +
': ' +
localize('config.view.views.clips')}
>
<ha-switch
.checked=${this._config?.menu?.buttons?.clips ??
defaults.menu.buttons.clips}
.configValue=${'menu.buttons.clips'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<ha-formfield
.label=${localize('editor.show_button') +
': ' +
localize('config.view.views.snapshots')}
>
<ha-switch
.checked=${this._config?.menu?.buttons?.snapshots ??
defaults.menu.buttons.snapshots}
.configValue=${'menu.buttons.snapshots'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<ha-formfield
.label=${localize('editor.show_button') +
': ' +
localize('config.view.views.image')}
>
<ha-switch
.checked=${this._config?.menu?.buttons?.image ??
defaults.menu.buttons.image}
.configValue=${'menu.buttons.image'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<ha-formfield
.label=${localize('editor.show_button') +
': ' +
localize('config.menu.buttons.download')}
>
<ha-switch
.checked=${this._config?.menu?.buttons?.download ??
defaults.menu.buttons.download}
.configValue=${'menu.buttons.download'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<ha-formfield
.label=${localize('editor.show_button') +
': ' +
localize('config.menu.buttons.frigate_ui')}
>
<ha-switch
.checked=${this._config?.menu?.buttons?.frigate_ui ??
defaults.menu.buttons.frigate_ui}
.configValue=${'menu.buttons.frigate_ui'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<ha-formfield
.label=${localize('editor.show_button') +
': ' +
localize('config.menu.buttons.fullscreen')}
>
<ha-switch
.checked=${this._config?.menu?.buttons?.fullscreen ??
defaults.menu.buttons.fullscreen}
.configValue=${'menu.buttons.fullscreen'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
</div>
`
: ''}
<div class="option" @click=${this._toggleOption} .option=${'live'}>
<div class="row">
<ha-icon .icon=${`mdi:${options.live.icon}`}></ha-icon>
<div class="title">${options.live.name}</div>
</div>
<div class="secondary">${options.live.secondary}</div>
</div>
${options.live.show
? html`
<div class="values">
<br />
<ha-formfield .label=${localize('config.live.preload')}>
<ha-switch
.checked=${this._config?.live?.preload ?? defaults.live.preload}
.configValue=${'live.preload'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<paper-dropdown-menu
.label=${localize('config.live.provider')}
@value-changed=${this._valueChanged}
.configValue=${'live.provider'}
>
<paper-listbox
slot="dropdown-content"
.selected=${Object.keys(liveProviders).indexOf(
this._config?.live?.provider || '',
)}
>
${Object.keys(liveProviders).map((key) => {
return html`
<paper-item .label="${key}">${liveProviders[key]} </paper-item>
`;
})}
</paper-listbox>
</paper-dropdown-menu>
<paper-dropdown-menu
.label=${localize('config.live.webrtc.entity')}
@value-changed=${this._valueChanged}
.configValue=${'live.webrtc.entity'}
>
<paper-listbox
slot="dropdown-content"
.selected=${cameraEntities.indexOf(webrtcCameraEntity)}
>
${cameraEntities.map((entity) => {
return html` <paper-item>${entity}</paper-item> `;
})}
</paper-listbox>
</paper-dropdown-menu>
<paper-input
label=${localize('config.live.webrtc.url')}
.value=${this._config?.live?.webrtc?.url || ''}
.configValue=${'live.webrtc.url'}
@value-changed=${this._valueChanged}
></paper-input>
</div>
`
: ''}
<div class="option" @click=${this._toggleOption} .option=${'event_viewer'}>
<div class="row">
<ha-icon .icon=${`mdi:${options.event_viewer.icon}`}></ha-icon>
<div class="title">${options.event_viewer.name}</div>
</div>
<div class="secondary">${options.event_viewer.secondary}</div>
</div>
${options.event_viewer.show
? html` <div class="values">
<br />
<ha-formfield .label=${localize('config.event_viewer.autoplay_clip')}>
<ha-switch
.checked=${this._config?.event_viewer?.autoplay_clip ??
defaults.event_viewer.autoplay_clip}
.configValue=${'event_viewer.autoplay_clip'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<ha-formfield .label=${localize('config.event_viewer.lazy_load')}>
<ha-switch
.checked=${this._config?.event_viewer?.lazy_load ??
defaults.event_viewer.lazy_load}
.configValue=${'event_viewer.lazy_load'}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<paper-dropdown-menu
.label=${localize('config.event_viewer.controls.next_previous.style')}
@value-changed=${this._valueChanged}
.configValue=${'event_viewer.controls.next_previous.style'}
>
<paper-listbox
slot="dropdown-content"
.selected=${Object.keys(eventViewerNextPreviousControlStyles).indexOf(
this._config?.event_viewer?.controls?.next_previous?.style || '',
)}
>
${Object.keys(eventViewerNextPreviousControlStyles).map((key) => {
return html`
<paper-item .label="${key}">
${eventViewerNextPreviousControlStyles[key]}
</paper-item>
`;
})}
</paper-listbox>
</paper-dropdown-menu>
<paper-input
label=${localize('config.event_viewer.controls.next_previous.size')}
.value=${this._config?.event_viewer?.controls?.next_previous?.size || ''}
.configValue=${'event_viewer.controls.next_previous.size'}
@value-changed=${this._valueChanged}
></paper-input>
</div>`
: ''}
<div class="option" @click=${this._toggleOption} .option=${'image'}>
<div class="row">
<ha-icon .icon=${`mdi:${options.image.icon}`}></ha-icon>
<div class="title">${options.image.name}</div>
</div>
<div class="secondary">${options.image.secondary}</div>
</div>
${options.image.show
? html` <div class="values">
<paper-input
label=${localize('config.image.src')}
prevent-invalid-input
.value=${this._config?.image?.src
? String(this._config?.image?.src)
: ''}
.configValue=${'image.src'}
@value-changed=${this._valueChanged}
></paper-input>
</div>`
: ''}
<div class="option" @click=${this._toggleOption} .option=${'dimensions'}>
<div class="row">
<ha-icon .icon=${`mdi:${options.dimensions.icon}`}></ha-icon>
<div class="title">${options.dimensions.name}</div>
</div>
<div class="secondary">${options.dimensions.secondary}</div>
</div>
${options.dimensions.show
? html` <div class="values">
<paper-dropdown-menu
.label=${localize('config.dimensions.aspect_ratio_mode')}
@value-changed=${this._valueChanged}
.configValue=${'dimensions.aspect_ratio_mode'}
>
<paper-listbox
slot="dropdown-content"
.selected=${Object.keys(aspectRatioModes).indexOf(
this._config?.dimensions?.aspect_ratio_mode || '',
)}
>
${Object.keys(aspectRatioModes).map((key) => {
return html`
<paper-item .label="${key}"> ${aspectRatioModes[key]} </paper-item>
`;
})}
</paper-listbox>
</paper-dropdown-menu>
<paper-input
label=${localize('config.dimensions.aspect_ratio')}
prevent-invalid-input
.value=${this._config?.dimensions?.aspect_ratio
? String(this._config?.dimensions?.aspect_ratio)
: ''}
.configValue=${'dimensions.aspect_ratio'}
@value-changed=${this._valueChanged}
></paper-input>
</div>`
: ''}
</div>
`;
}
private _initialize(): void {
if (this.hass === undefined) return;
if (this._config === undefined) return;
if (this._helpers === undefined) return;
this._initialized = true;
}
private async loadCardHelpers(): Promise<void> {
this._helpers = await (window as any).loadCardHelpers();
}
private _toggleOption(ev): void {
this._toggleThing(ev, options);
}
private _toggleThing(ev, optionList): void {
const show = !optionList[ev.target.option].show;
for (const [key] of Object.entries(optionList)) {
optionList[key].show = false;
}
optionList[ev.target.option].show = show;
this._toggle = !this._toggle;
}
private _valueChanged(ev): void {
if (!this._config || !this.hass) {
return;
}
const target = ev.target;
const value = target.value?.trim();
let key: string = target.configValue;
if (!key) {
return;
}
// Need to deep copy the config so cannot use Object.assign.
const newConfig = JSON.parse(JSON.stringify(this._config));
let objectTarget = newConfig;
if (key.includes('.')) {
const parts = key.split('.');
objectTarget = parts.slice(0, -1).reduce((obj, key) => {
if (!(key in obj)) {
obj[key] = {};
}
return obj[key];
}, newConfig);
key = parts[parts.length - 1];
}
if (value !== undefined && objectTarget[key] === value) {
return;
} else if (value === '') {
delete objectTarget[key];
} else {
objectTarget[key] = target.checked !== undefined ? target.checked : value;
}
this._config = newConfig;
fireEvent(this, 'config-changed', { config: this._config });
}
// Return compiled CSS styles (thus safe to use with unsafeCSS).
static get styles(): CSSResultGroup {
return unsafeCSS(frigate_card_editor_style);
}
}