diff --git a/gallery/src/demos/demo-hui-picture-entity-card.ts b/gallery/src/demos/demo-hui-picture-entity-card.ts index 7940a5ad00..1378707e45 100644 --- a/gallery/src/demos/demo-hui-picture-entity-card.ts +++ b/gallery/src/demos/demo-hui-picture-entity-card.ts @@ -2,6 +2,17 @@ import { html } from "@polymer/polymer/lib/utils/html-tag"; import { PolymerElement } from "@polymer/polymer/polymer-element"; import "../components/demo-cards"; +import { provideHass } from "../../../src/fake_data/provide_hass"; +import { getEntity } from "../../../src/fake_data/entity"; + +const ENTITIES = [ + getEntity("light", "kitchen_lights", "on", { + friendly_name: "Kitchen Lights", + }), + getEntity("light", "bed_light", "off", { + friendly_name: "Bed Light", + }), +]; const CONFIGS = [ { @@ -10,6 +21,8 @@ const CONFIGS = [ - type: picture-entity image: /images/kitchen.png entity: light.kitchen_lights + tap_action: + action: toggle `, }, { @@ -18,6 +31,8 @@ const CONFIGS = [ - type: picture-entity image: /images/bed.png entity: light.bed_light + tap_action: + action: toggle `, }, { @@ -68,7 +83,7 @@ const CONFIGS = [ class DemoPicEntity extends PolymerElement { static get template() { return html` - + `; } @@ -80,6 +95,12 @@ class DemoPicEntity extends PolymerElement { }, }; } + + public ready() { + super.ready(); + const hass = provideHass(this.$.demos); + hass.addEntities(ENTITIES); + } } customElements.define("demo-hui-picture-entity-card", DemoPicEntity); diff --git a/gallery/src/demos/demo-hui-picture-glance-card.ts b/gallery/src/demos/demo-hui-picture-glance-card.ts index 2ab8d7c32c..faae58bd7b 100644 --- a/gallery/src/demos/demo-hui-picture-glance-card.ts +++ b/gallery/src/demos/demo-hui-picture-glance-card.ts @@ -2,6 +2,25 @@ import { html } from "@polymer/polymer/lib/utils/html-tag"; import { PolymerElement } from "@polymer/polymer/polymer-element"; import "../components/demo-cards"; +import { getEntity } from "../../../src/fake_data/entity"; +import { provideHass } from "../../../src/fake_data/provide_hass"; + +const ENTITIES = [ + getEntity("switch", "decorative_lights", "on", { + friendly_name: "Decorative Lights", + }), + getEntity("light", "ceiling_lights", "on", { + friendly_name: "Ceiling Lights", + }), + getEntity("binary_sensor", "movement_backyard", "on", { + friendly_name: "Movement Backyard", + device_class: "moving", + }), + getEntity("binary_sensor", "basement_floor_wet", "off", { + friendly_name: "Basement Floor Wet", + device_class: "moisture", + }), +]; const CONFIGS = [ { @@ -105,7 +124,7 @@ const CONFIGS = [ class DemoPicGlance extends PolymerElement { static get template() { return html` - + `; } @@ -117,6 +136,12 @@ class DemoPicGlance extends PolymerElement { }, }; } + + public ready() { + super.ready(); + const hass = provideHass(this.$.demos); + hass.addEntities(ENTITIES); + } } customElements.define("demo-hui-picture-glance-card", DemoPicGlance); diff --git a/gallery/webpack.config.js b/gallery/webpack.config.js index 763bed55c7..82a7008709 100644 --- a/gallery/webpack.config.js +++ b/gallery/webpack.config.js @@ -1,7 +1,7 @@ const path = require("path"); const CopyWebpackPlugin = require("copy-webpack-plugin"); const { babelLoaderConfig } = require("../config/babel.js"); -const webpackBase = require("../config/babel.js"); +const webpackBase = require("../config/webpack.js"); const isProd = process.env.NODE_ENV === "production"; const chunkFilename = isProd ? "chunk.[chunkhash].js" : "[name].chunk.js"; diff --git a/src/common/file/b64-to-blob.ts b/src/common/file/b64-to-blob.ts new file mode 100644 index 0000000000..969cfcae1a --- /dev/null +++ b/src/common/file/b64-to-blob.ts @@ -0,0 +1,20 @@ +// https://stackoverflow.com/a/16245768 +export const b64toBlob = (b64Data, contentType = "", sliceSize = 512) => { + const byteCharacters = atob(b64Data); + const byteArrays: Uint8Array[] = []; + + for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { + const slice = byteCharacters.slice(offset, offset + sliceSize); + + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + + const byteArray = new Uint8Array(byteNumbers); + + byteArrays.push(byteArray); + } + + return new Blob(byteArrays, { type: contentType }); +}; diff --git a/src/data/camera.ts b/src/data/camera.ts new file mode 100644 index 0000000000..cde6abb596 --- /dev/null +++ b/src/data/camera.ts @@ -0,0 +1,12 @@ +import { HomeAssistant } from "../types"; + +export interface CameraThumbnail { + content_type: string; + content: string; +} + +export const fetchThumbnail = (hass: HomeAssistant, entityId: string) => + hass.callWS({ + type: "camera_thumbnail", + entity_id: entityId, + }); diff --git a/src/panels/lovelace/components/hui-image.js b/src/panels/lovelace/components/hui-image.js deleted file mode 100644 index 1ad89c3bac..0000000000 --- a/src/panels/lovelace/components/hui-image.js +++ /dev/null @@ -1,199 +0,0 @@ -import { html } from "@polymer/polymer/lib/utils/html-tag"; -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import "@polymer/paper-toggle-button/paper-toggle-button"; - -import { STATES_OFF } from "../../../common/const"; -import LocalizeMixin from "../../../mixins/localize-mixin"; - -import parseAspectRatio from "../../../common/util/parse-aspect-ratio"; - -const UPDATE_INTERVAL = 10000; -const DEFAULT_FILTER = "grayscale(100%)"; - -/* - * @appliesMixin LocalizeMixin - */ -class HuiImage extends LocalizeMixin(PolymerElement) { - static get template() { - return html` - ${this.styleTemplate} -
- -
-
- `; - } - - static get styleTemplate() { - return html` - - `; - } - - static get properties() { - return { - hass: { - type: Object, - observer: "_hassChanged", - }, - entity: String, - image: String, - stateImage: Object, - cameraImage: String, - aspectRatio: String, - filter: String, - stateFilter: Object, - _imageSrc: String, - }; - } - - static get observers() { - return ["_configChanged(image, stateImage, cameraImage, aspectRatio)"]; - } - - connectedCallback() { - super.connectedCallback(); - if (this.cameraImage) { - this.timer = setInterval( - () => this._updateCameraImageSrc(), - UPDATE_INTERVAL - ); - } - } - - disconnectedCallback() { - super.disconnectedCallback(); - clearInterval(this.timer); - } - - _configChanged(image, stateImage, cameraImage, aspectRatio) { - const ratio = parseAspectRatio(aspectRatio); - - if (ratio && ratio.w > 0 && ratio.h > 0) { - this.$.wrapper.style.paddingBottom = `${( - (100 * ratio.h) / - ratio.w - ).toFixed(2)}%`; - this.$.wrapper.classList.add("ratio"); - } - - if (cameraImage) { - this._updateCameraImageSrc(); - } else if (image && !stateImage) { - this._imageSrc = image; - } - } - - _onImageError() { - this._imageSrc = null; - this.$.image.classList.add("hidden"); - if (!this.$.wrapper.classList.contains("ratio")) { - this.$.brokenImage.style.setProperty( - "height", - `${this._lastImageHeight || "100"}px` - ); - } - this.$.brokenImage.classList.remove("hidden"); - } - - _onImageLoad() { - this.$.image.classList.remove("hidden"); - this.$.brokenImage.classList.add("hidden"); - if (!this.$.wrapper.classList.contains("ratio")) { - this._lastImageHeight = this.$.image.offsetHeight; - } - } - - _hassChanged(hass) { - if (this.cameraImage || !this.entity) { - return; - } - - const stateObj = hass.states[this.entity]; - const newState = !stateObj ? "unavailable" : stateObj.state; - - if (newState === this._currentState) return; - this._currentState = newState; - - this._updateStateImage(); - this._updateStateFilter(stateObj); - } - - _updateStateImage() { - if (!this.stateImage) { - this._imageFallback = true; - return; - } - const stateImg = this.stateImage[this._currentState]; - this._imageSrc = stateImg || this.image; - this._imageFallback = !stateImg; - } - - _updateStateFilter(stateObj) { - let filter; - if (!this.stateFilter) { - filter = this.filter; - } else { - filter = this.stateFilter[this._currentState] || this.filter; - } - - const isOff = !stateObj || STATES_OFF.includes(stateObj.state); - this.$.image.style.filter = - filter || (isOff && this._imageFallback && DEFAULT_FILTER) || ""; - } - - async _updateCameraImageSrc() { - try { - const { content_type: contentType, content } = await this.hass.callWS({ - type: "camera_thumbnail", - entity_id: this.cameraImage, - }); - this._imageSrc = `data:${contentType};base64, ${content}`; - this._onImageLoad(); - } catch (err) { - this._onImageError(); - } - } -} - -customElements.define("hui-image", HuiImage); diff --git a/src/panels/lovelace/components/hui-image.ts b/src/panels/lovelace/components/hui-image.ts new file mode 100644 index 0000000000..cdc7b06aa1 --- /dev/null +++ b/src/panels/lovelace/components/hui-image.ts @@ -0,0 +1,226 @@ +import "@polymer/paper-toggle-button/paper-toggle-button"; + +import { STATES_OFF } from "../../../common/const"; + +import parseAspectRatio from "../../../common/util/parse-aspect-ratio"; +import { + LitElement, + TemplateResult, + html, + property, + CSSResult, + css, + PropertyValues, + query, +} from "lit-element"; +import { HomeAssistant } from "../../../types"; +import { styleMap } from "lit-html/directives/style-map"; +import { classMap } from "lit-html/directives/class-map"; +import { b64toBlob } from "../../../common/file/b64-to-blob"; +import { fetchThumbnail } from "../../../data/camera"; + +const UPDATE_INTERVAL = 10000; +const DEFAULT_FILTER = "grayscale(100%)"; + +export interface StateSpecificConfig { + [state: string]: string; +} + +/* + * @appliesMixin LocalizeMixin + */ +class HuiImage extends LitElement { + @property() public hass?: HomeAssistant; + @property() public entity?: string; + @property() public image?: string; + @property() public stateImage?: StateSpecificConfig; + @property() public cameraImage?: string; + @property() public aspectRatio?: string; + @property() public filter?: string; + @property() public stateFilter?: StateSpecificConfig; + + @property() private _loadError?: boolean; + @property() private _cameraImageSrc?: string; + @query("img") private _image!: HTMLImageElement; + private _lastImageHeight?: number; + private _cameraUpdater?: number; + private _attached?: boolean; + + public connectedCallback() { + super.connectedCallback(); + this._attached = true; + this._startUpdateCameraInterval(); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this._attached = false; + this._stopUpdateCameraInterval(); + } + + protected render(): TemplateResult | void { + const ratio = this.aspectRatio ? parseAspectRatio(this.aspectRatio) : null; + const stateObj = + this.hass && this.entity ? this.hass.states[this.entity] : undefined; + const state = stateObj ? stateObj.state : "unavailable"; + + // Figure out image source to use + let imageSrc: string | undefined; + // Track if we are we using a fallback image, used for filter. + let imageFallback = !this.stateImage; + + if (this.cameraImage) { + imageSrc = this._cameraImageSrc; + } else if (this.stateImage) { + const stateImage = this.stateImage[state]; + + if (stateImage) { + imageSrc = stateImage; + } else { + imageSrc = this.image; + imageFallback = true; + } + } else { + imageSrc = this.image; + } + + // Figure out filter to use + let filter = this.filter || ""; + + if (this.stateFilter && this.stateFilter[state]) { + filter = this.stateFilter[state]; + } + + if (!filter && this.entity) { + const isOff = !stateObj || STATES_OFF.includes(state); + filter = isOff && imageFallback ? DEFAULT_FILTER : ""; + } + + return html` +
0 && ratio.h > 0 + ? `${((100 * ratio.h) / ratio.w).toFixed(2)}%` + : "", + })} + class=${classMap({ + ratio: Boolean(ratio && ratio.w > 0 && ratio.h > 0), + })} + > + +
+
+ `; + } + + protected updated(changedProps: PropertyValues): void { + if (changedProps.has("cameraImage")) { + this._updateCameraImageSrc(); + this._startUpdateCameraInterval(); + return; + } + } + + private _startUpdateCameraInterval() { + this._stopUpdateCameraInterval(); + if (this.cameraImage && this._attached) { + this._cameraUpdater = window.setInterval( + () => this._updateCameraImageSrc(), + UPDATE_INTERVAL + ); + } + } + + private _stopUpdateCameraInterval() { + if (this._cameraUpdater) { + clearInterval(this._cameraUpdater); + } + } + + private _onImageError() { + this._loadError = true; + } + + private async _onImageLoad() { + this._loadError = false; + await this.updateComplete; + this._lastImageHeight = this._image.offsetHeight; + } + + private async _updateCameraImageSrc() { + if (!this.hass || !this.cameraImage) { + return; + } + if (this._cameraImageSrc) { + URL.revokeObjectURL(this._cameraImageSrc); + this._cameraImageSrc = undefined; + } + try { + const { content_type: contentType, content } = await fetchThumbnail( + this.hass, + this.cameraImage + ); + this._cameraImageSrc = URL.createObjectURL( + b64toBlob(content, contentType) + ); + this._onImageLoad(); + } catch (err) { + this._onImageError(); + } + } + + static get styles(): CSSResult { + return css` + img { + display: block; + height: auto; + transition: filter 0.2s linear; + width: 100%; + } + + .ratio { + position: relative; + width: 100%; + height: 0; + } + + .ratio img, + .ratio div { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + #brokenImage { + background: grey url("/static/images/image-broken.svg") center/36px + no-repeat; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-image": HuiImage; + } +} + +customElements.define("hui-image", HuiImage);