Convert HUI-IMAGE to TypeScript/Lit (#2713)

* Fix gallery demos

* Convert HUI-IMAGE to TypeScript/Lit

* Clean up
This commit is contained in:
Paulus Schoutsen 2019-02-11 14:14:29 -08:00 committed by GitHub
parent f23258eb8c
commit 310b81de04
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 307 additions and 202 deletions

View File

@ -2,6 +2,17 @@ import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element"; import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../components/demo-cards"; 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 = [ const CONFIGS = [
{ {
@ -10,6 +21,8 @@ const CONFIGS = [
- type: picture-entity - type: picture-entity
image: /images/kitchen.png image: /images/kitchen.png
entity: light.kitchen_lights entity: light.kitchen_lights
tap_action:
action: toggle
`, `,
}, },
{ {
@ -18,6 +31,8 @@ const CONFIGS = [
- type: picture-entity - type: picture-entity
image: /images/bed.png image: /images/bed.png
entity: light.bed_light entity: light.bed_light
tap_action:
action: toggle
`, `,
}, },
{ {
@ -68,7 +83,7 @@ const CONFIGS = [
class DemoPicEntity extends PolymerElement { class DemoPicEntity extends PolymerElement {
static get template() { static get template() {
return html` 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); customElements.define("demo-hui-picture-entity-card", DemoPicEntity);

View File

@ -2,6 +2,25 @@ import { html } from "@polymer/polymer/lib/utils/html-tag";
import { PolymerElement } from "@polymer/polymer/polymer-element"; import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../components/demo-cards"; 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 = [ const CONFIGS = [
{ {
@ -105,7 +124,7 @@ const CONFIGS = [
class DemoPicGlance extends PolymerElement { class DemoPicGlance extends PolymerElement {
static get template() { static get template() {
return html` 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); customElements.define("demo-hui-picture-glance-card", DemoPicGlance);

View File

@ -1,7 +1,7 @@
const path = require("path"); const path = require("path");
const CopyWebpackPlugin = require("copy-webpack-plugin"); const CopyWebpackPlugin = require("copy-webpack-plugin");
const { babelLoaderConfig } = require("../config/babel.js"); 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 isProd = process.env.NODE_ENV === "production";
const chunkFilename = isProd ? "chunk.[chunkhash].js" : "[name].chunk.js"; const chunkFilename = isProd ? "chunk.[chunkhash].js" : "[name].chunk.js";

View 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
View 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,
});

View File

@ -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);

View 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);