mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-28 11:46:42 +00:00
Convert HUI-IMAGE to TypeScript/Lit (#2713)
* Fix gallery demos * Convert HUI-IMAGE to TypeScript/Lit * Clean up
This commit is contained in:
parent
f23258eb8c
commit
310b81de04
@ -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`
|
||||
<demo-cards configs="[[_configs]]"></demo-cards>
|
||||
<demo-cards id="demos" configs="[[_configs]]"></demo-cards>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
@ -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`
|
||||
<demo-cards configs="[[_configs]]"></demo-cards>
|
||||
<demo-cards id="demos" configs="[[_configs]]"></demo-cards>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
@ -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";
|
||||
|
20
src/common/file/b64-to-blob.ts
Normal file
20
src/common/file/b64-to-blob.ts
Normal file
@ -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 });
|
||||
};
|
12
src/data/camera.ts
Normal file
12
src/data/camera.ts
Normal file
@ -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<CameraThumbnail>({
|
||||
type: "camera_thumbnail",
|
||||
entity_id: entityId,
|
||||
});
|
@ -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}
|
||||
<div id="wrapper">
|
||||
<img
|
||||
id="image"
|
||||
src="[[_imageSrc]]"
|
||||
on-error="_onImageError"
|
||||
on-load="_onImageLoad"
|
||||
/>
|
||||
<div id="brokenImage"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styleTemplate() {
|
||||
return html`
|
||||
<style>
|
||||
img {
|
||||
display: block;
|
||||
height: auto;
|
||||
transition: filter 0.2s linear;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
226
src/panels/lovelace/components/hui-image.ts
Normal file
226
src/panels/lovelace/components/hui-image.ts
Normal file
@ -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`
|
||||
<div
|
||||
style=${styleMap({
|
||||
paddingBottom:
|
||||
ratio && ratio.w > 0 && ratio.h > 0
|
||||
? `${((100 * ratio.h) / ratio.w).toFixed(2)}%`
|
||||
: "",
|
||||
})}
|
||||
class=${classMap({
|
||||
ratio: Boolean(ratio && ratio.w > 0 && ratio.h > 0),
|
||||
})}
|
||||
>
|
||||
<img
|
||||
id="image"
|
||||
src=${imageSrc}
|
||||
@error=${this._onImageError}
|
||||
@load=${this._onImageLoad}
|
||||
style=${styleMap({
|
||||
filter,
|
||||
display: this._loadError ? "none" : "block",
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
id="brokenImage"
|
||||
style=${styleMap({
|
||||
height: `${this._lastImageHeight || "100"}px`,
|
||||
display: this._loadError ? "block" : "none",
|
||||
})}
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
Loading…
x
Reference in New Issue
Block a user