diff --git a/build-scripts/gulp/demo.js b/build-scripts/gulp/demo.js index b2d40e17ae..2c8070962d 100644 --- a/build-scripts/gulp/demo.js +++ b/build-scripts/gulp/demo.js @@ -1,4 +1,4 @@ -// Run HA develop mode +// Run demo develop mode const gulp = require("gulp"); require("./clean.js"); diff --git a/build-scripts/gulp/webpack.js b/build-scripts/gulp/webpack.js index 2a21a14011..b1675ad076 100644 --- a/build-scripts/gulp/webpack.js +++ b/build-scripts/gulp/webpack.js @@ -84,12 +84,12 @@ gulp.task("webpack-dev-server-demo", () => { open: true, watchContentBase: true, contentBase: path.resolve(paths.demo_dir, "dist"), - }).listen(8080, "localhost", function(err) { + }).listen(8090, "localhost", function(err) { if (err) { throw err; } // Server listening - log("[webpack-dev-server]", "http://localhost:8080"); + log("[webpack-dev-server]", "http://localhost:8090"); }); }); diff --git a/build-scripts/webpack.js b/build-scripts/webpack.js index 031c72433d..7ee1a03714 100644 --- a/build-scripts/webpack.js +++ b/build-scripts/webpack.js @@ -96,7 +96,7 @@ const createAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) => { // Create an object mapping browser urls to their paths during build const translationMetadata = require("../build-translations/translationMetadata.json"); const workBoxTranslationsTemplatedURLs = {}; - const englishFP = translationMetadata["translations"]["en"]["fingerprints"]; + const englishFP = translationMetadata.translations.en.fingerprints; Object.keys(englishFP).forEach((key) => { workBoxTranslationsTemplatedURLs[ `/static/translations/${englishFP[key]}` @@ -192,7 +192,7 @@ const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) => { new webpack.DefinePlugin({ __DEV__: !isProdBuild, __BUILD__: JSON.stringify(latestBuild ? "latest" : "es5"), - __VERSION__: JSON.stringify("DEMO"), + __VERSION__: JSON.stringify(`DEMO-${version}`), __DEMO__: true, __STATIC_PATH__: "/static/", "process.env.NODE_ENV": JSON.stringify( diff --git a/demo/src/ha-demo.ts b/demo/src/ha-demo.ts index 0391b4df6d..3672b17ff5 100644 --- a/demo/src/ha-demo.ts +++ b/demo/src/ha-demo.ts @@ -16,6 +16,7 @@ import { mockEvents } from "./stubs/events"; import { mockMediaPlayer } from "./stubs/media_player"; import { HomeAssistant } from "../../src/types"; import { mockFrontend } from "./stubs/frontend"; +import { mockPersistentNotification } from "./stubs/persistent_notification"; class HaDemo extends HomeAssistantAppEl { protected async _initialize() { @@ -43,6 +44,7 @@ class HaDemo extends HomeAssistantAppEl { mockEvents(hass); mockMediaPlayer(hass); mockFrontend(hass); + mockPersistentNotification(hass); // Once config is loaded AND localize, set entities and apply theme. Promise.all([selectedDemoConfig, localizePromise]).then( diff --git a/demo/src/stubs/persistent_notification.ts b/demo/src/stubs/persistent_notification.ts new file mode 100644 index 0000000000..2dcd4f61bd --- /dev/null +++ b/demo/src/stubs/persistent_notification.ts @@ -0,0 +1,16 @@ +import { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; +import { PersistentNotification } from "../../../src/data/persistent_notification"; + +export const mockPersistentNotification = (hass: MockHomeAssistant) => { + hass.mockWS("persistent_notification/get", () => + Promise.resolve([ + { + created_at: new Date().toISOString(), + message: "There was motion detected in the backyard.", + notification_id: "demo-1", + title: "Motion Detected!", + status: "unread", + }, + ] as PersistentNotification[]) + ); +}; diff --git a/package.json b/package.json index 150504ac51..97757ab7f4 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "fuse.js": "^3.4.4", "google-timezones-json": "^1.0.2", "hls.js": "^0.12.4", - "home-assistant-js-websocket": "^4.2.2", + "home-assistant-js-websocket": "4.3.1", "intl-messageformat": "^2.2.0", "jquery": "^3.3.1", "js-yaml": "^3.13.0", @@ -112,6 +112,8 @@ "@babel/preset-typescript": "^7.3.3", "@gfx/zopfli": "^1.0.11", "@types/chai": "^4.1.7", + "@types/chromecast-caf-receiver": "^3.0.12", + "@types/chromecast-caf-sender": "^1.0.1", "@types/hls.js": "^0.12.3", "@types/leaflet": "^1.4.3", "@types/memoize-one": "4.1.0", diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index 53f0b74db2..2614d8ffb7 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -199,3 +199,9 @@ class HaEntityPicker extends LitElement { } customElements.define("ha-entity-picker", HaEntityPicker); + +declare global { + interface HTMLElementTagNameMap { + "ha-entity-picker": HaEntityPicker; + } +} diff --git a/src/components/entity/state-badge.ts b/src/components/entity/state-badge.ts index 0d73d6fcd0..211331ae98 100644 --- a/src/components/entity/state-badge.ts +++ b/src/components/entity/state-badge.ts @@ -15,8 +15,10 @@ import { HassEntity } from "home-assistant-js-websocket"; // Not duplicate, this is for typing. // tslint:disable-next-line import { HaIcon } from "../ha-icon"; +import { HomeAssistant } from "../../types"; class StateBadge extends LitElement { + public hass?: HomeAssistant; @property() public stateObj?: HassEntity; @property() public overrideIcon?: string; @query("ha-icon") private _icon!: HaIcon; @@ -54,8 +56,11 @@ class StateBadge extends LitElement { if (stateObj) { // hide icon if we have entity picture if (stateObj.attributes.entity_picture && !this.overrideIcon) { - hostStyle.backgroundImage = - "url(" + stateObj.attributes.entity_picture + ")"; + let imageUrl = stateObj.attributes.entity_picture; + if (this.hass) { + imageUrl = this.hass.hassUrl(imageUrl); + } + hostStyle.backgroundImage = `url(${imageUrl})`; iconStyle.display = "none"; } else { if (stateObj.attributes.hs_color) { diff --git a/src/data/camera.ts b/src/data/camera.ts index ab40da3dd8..94c5853ee5 100644 --- a/src/data/camera.ts +++ b/src/data/camera.ts @@ -35,8 +35,13 @@ export const fetchThumbnailUrlWithCache = ( entityId ); -export const fetchThumbnailUrl = (hass: HomeAssistant, entityId: string) => - getSignedPath(hass, `/api/camera_proxy/${entityId}`).then(({ path }) => path); +export const fetchThumbnailUrl = async ( + hass: HomeAssistant, + entityId: string +) => { + const path = await getSignedPath(hass, `/api/camera_proxy/${entityId}`); + return hass.hassUrl(path.path); +}; export const fetchThumbnail = (hass: HomeAssistant, entityId: string) => { // tslint:disable-next-line: no-console @@ -47,7 +52,7 @@ export const fetchThumbnail = (hass: HomeAssistant, entityId: string) => { }); }; -export const fetchStreamUrl = ( +export const fetchStreamUrl = async ( hass: HomeAssistant, entityId: string, format?: "hls" @@ -60,7 +65,9 @@ export const fetchStreamUrl = ( // @ts-ignore data.format = format; } - return hass.callWS(data); + const stream = await hass.callWS(data); + stream.url = hass.hassUrl(stream.url); + return stream; }; export const fetchCameraPrefs = (hass: HomeAssistant, entityId: string) => diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index 246dadb038..8b30d83ad1 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -1,5 +1,5 @@ import { HomeAssistant } from "../types"; -import { Connection } from "home-assistant-js-websocket"; +import { Connection, getCollection } from "home-assistant-js-websocket"; export interface LovelaceConfig { title?: string; @@ -83,6 +83,17 @@ export const subscribeLovelaceUpdates = ( onChange: () => void ) => conn.subscribeEvents(onChange, "lovelace_updated"); +export const getLovelaceCollection = (conn: Connection) => + getCollection( + conn, + "_lovelace", + (conn2) => fetchConfig(conn2, false), + (_conn, store) => + subscribeLovelaceUpdates(conn, () => + fetchConfig(conn, false).then((config) => store.setState(config, true)) + ) + ); + export interface WindowWithLovelaceProm extends Window { llConfProm?: Promise; } diff --git a/src/fake_data/entity.ts b/src/fake_data/entity.ts index 857222781e..a386355eb6 100644 --- a/src/fake_data/entity.ts +++ b/src/fake_data/entity.ts @@ -203,6 +203,25 @@ class CoverEntity extends Entity { } } +class InputNumberEntity extends Entity { + public async handleService( + domain, + service, + // @ts-ignore + data + ) { + if (domain !== this.domain) { + return; + } + + if (service === "set_value") { + this.update("" + data.value); + } else { + super.handleService(domain, service, data); + } + } +} + class ClimateEntity extends Entity { public async handleService(domain, service, data) { if (domain !== this.domain) { @@ -256,6 +275,7 @@ const TYPES = { cover: CoverEntity, group: GroupEntity, input_boolean: ToggleEntity, + input_number: InputNumberEntity, light: LightEntity, lock: LockEntity, media_player: MediaPlayerEntity, diff --git a/src/fake_data/provide_hass.ts b/src/fake_data/provide_hass.ts index c14b03111d..7d27a5d1ca 100644 --- a/src/fake_data/provide_hass.ts +++ b/src/fake_data/provide_hass.ts @@ -90,7 +90,11 @@ export const provideHass = ( const hassObj: MockHomeAssistant = { // Home Assistant properties - auth: {} as any, + auth: { + data: { + hassUrl: "", + }, + } as any, connection: { addEventListener: () => undefined, removeEventListener: () => undefined, @@ -182,6 +186,7 @@ export const provideHass = ( ? response[1](hass(), method, path, parameters) : Promise.reject(`API Mock for ${path} is not implemented`); }, + hassUrl: (path?) => path, fetchWithAuth: () => Promise.reject("Not implemented"), sendWS: (msg) => hassObj.connection.sendMessage(msg), callWS: (msg) => hassObj.connection.sendMessagePromise(msg), diff --git a/src/html/_header.html.template b/src/html/_header.html.template index 5a6868678f..95e1dc9c6b 100644 --- a/src/html/_header.html.template +++ b/src/html/_header.html.template @@ -1,15 +1,4 @@ - - +<%= renderTemplate('_style_base') %> diff --git a/src/html/_style_base.html.template b/src/html/_style_base.html.template new file mode 100644 index 0000000000..1c9fd3851e --- /dev/null +++ b/src/html/_style_base.html.template @@ -0,0 +1,12 @@ + + diff --git a/src/panels/lovelace/cards/hui-glance-card.ts b/src/panels/lovelace/cards/hui-glance-card.ts index f62730ffaa..69dd4a3199 100644 --- a/src/panels/lovelace/cards/hui-glance-card.ts +++ b/src/panels/lovelace/cards/hui-glance-card.ts @@ -198,8 +198,9 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard { ${this._config!.show_icon !== false ? html` ` : ""} diff --git a/src/panels/lovelace/cards/hui-map-card.ts b/src/panels/lovelace/cards/hui-map-card.ts index e3de8190b8..21541f48c8 100644 --- a/src/panels/lovelace/cards/hui-map-card.ts +++ b/src/panels/lovelace/cards/hui-map-card.ts @@ -148,6 +148,27 @@ class HuiMapCard extends LitElement implements LovelaceCard { `; } + protected shouldUpdate(changedProps) { + if (!changedProps.has("hass") || changedProps.size > 1) { + return true; + } + + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + + if (!oldHass || !this._configEntities) { + return true; + } + + // Check if any state has changed + for (const entity of this._configEntities) { + if (oldHass.states[entity.entity] !== this.hass!.states[entity.entity]) { + return true; + } + } + + return false; + } + protected firstUpdated(changedProps: PropertyValues): void { super.firstUpdated(changedProps); this.loadMap(); diff --git a/src/panels/lovelace/cards/hui-markdown-card.ts b/src/panels/lovelace/cards/hui-markdown-card.ts index 35a92d9231..35720b343b 100644 --- a/src/panels/lovelace/cards/hui-markdown-card.ts +++ b/src/panels/lovelace/cards/hui-markdown-card.ts @@ -59,15 +59,6 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard { static get styles(): CSSResult { return css` - :host { - /* start paper-font-body1 style */ - font-family: "Roboto", "Noto", sans-serif; - -webkit-font-smoothing: antialiased; /* OS X subpixel AA bleed bug */ - font-size: 14px; - font-weight: 400; - line-height: 20px; - /* end paper-font-body1 style */ - } ha-markdown { display: block; padding: 0 16px 16px; diff --git a/src/panels/lovelace/cards/hui-picture-card.ts b/src/panels/lovelace/cards/hui-picture-card.ts index 4d6c4fda48..df98aba342 100644 --- a/src/panels/lovelace/cards/hui-picture-card.ts +++ b/src/panels/lovelace/cards/hui-picture-card.ts @@ -64,7 +64,7 @@ export class HuiPictureCard extends LitElement implements LovelaceCard { ), })}" > - + `; } diff --git a/src/panels/lovelace/cards/hui-thermostat-card.ts b/src/panels/lovelace/cards/hui-thermostat-card.ts index 3217930940..73b1d853f0 100644 --- a/src/panels/lovelace/cards/hui-thermostat-card.ts +++ b/src/panels/lovelace/cards/hui-thermostat-card.ts @@ -492,7 +492,8 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard { } #thermostat .rs-handle { background-color: var(--paper-card-background-color, white); - padding: 7px; + padding: 10px; + margin: -10px 0 0 -8px !important; border: 2px solid var(--disabled-text-color); } #thermostat .rs-handle.rs-focus { diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 06bd48569a..1ad9f31c53 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -30,6 +30,7 @@ export interface EntitiesCardEntityConfig extends EntityConfig { } export interface EntitiesCardConfig extends LovelaceCardConfig { + type: "entities"; show_header_toggle?: boolean; title?: string; entities: EntitiesCardEntityConfig[]; @@ -104,6 +105,7 @@ export interface LightCardConfig extends LovelaceCardConfig { } export interface MapCardConfig extends LovelaceCardConfig { + type: "map"; title: string; aspect_ratio: string; default_zoom?: number; @@ -113,6 +115,7 @@ export interface MapCardConfig extends LovelaceCardConfig { } export interface MarkdownCardConfig extends LovelaceCardConfig { + type: "markdown"; content: string; title?: string; } diff --git a/src/panels/lovelace/common/create-row-element.ts b/src/panels/lovelace/common/create-row-element.ts index 7269247bb6..f4ee67a922 100644 --- a/src/panels/lovelace/common/create-row-element.ts +++ b/src/panels/lovelace/common/create-row-element.ts @@ -129,6 +129,10 @@ export const createRowElement = ( return element; } + if (!config.entity) { + return _createErrorElement("Invalid config given.", config); + } + const domain = config.entity.split(".", 1)[0]; tag = `hui-${DOMAIN_TO_ELEMENT_TYPE[domain] || "text"}-entity-row`; diff --git a/src/panels/lovelace/common/load-resources.ts b/src/panels/lovelace/common/load-resources.ts new file mode 100644 index 0000000000..c318a2dff2 --- /dev/null +++ b/src/panels/lovelace/common/load-resources.ts @@ -0,0 +1,44 @@ +import { loadModule, loadCSS, loadJS } from "../../../common/dom/load_resource"; + +import { LovelaceConfig } from "../../../data/lovelace"; + +// CSS and JS should only be imported once. Modules and HTML are safe. +const CSS_CACHE = {}; +const JS_CACHE = {}; + +export const loadLovelaceResources = ( + resources: NonNullable, + hassUrl: string +) => + resources.forEach((resource) => { + const normalizedUrl = new URL(resource.url, hassUrl).toString(); + switch (resource.type) { + case "css": + if (normalizedUrl in CSS_CACHE) { + break; + } + CSS_CACHE[normalizedUrl] = loadCSS(normalizedUrl); + break; + + case "js": + if (normalizedUrl in JS_CACHE) { + break; + } + JS_CACHE[normalizedUrl] = loadJS(normalizedUrl); + break; + + case "module": + loadModule(normalizedUrl); + break; + + case "html": + import(/* webpackChunkName: "import-href-polyfill" */ "../../../resources/html-import/import-href").then( + ({ importHref }) => importHref(normalizedUrl) + ); + break; + + default: + // tslint:disable-next-line + console.warn(`Unknown resource type specified: ${resource.type}`); + } + }); diff --git a/src/panels/lovelace/components/hui-generic-entity-row.ts b/src/panels/lovelace/components/hui-generic-entity-row.ts index 515eafdd6b..fa37f98076 100644 --- a/src/panels/lovelace/components/hui-generic-entity-row.ts +++ b/src/panels/lovelace/components/hui-generic-entity-row.ts @@ -47,8 +47,9 @@ class HuiGenericEntityRow extends LitElement { return html`
@@ -63,8 +64,8 @@ class HuiGenericEntityRow extends LitElement { : this.config.secondary_info === "last-changed" ? html` ` : ""} diff --git a/src/panels/lovelace/components/hui-image.ts b/src/panels/lovelace/components/hui-image.ts index 870ff7b51c..35be160e27 100644 --- a/src/panels/lovelace/components/hui-image.ts +++ b/src/panels/lovelace/components/hui-image.ts @@ -104,6 +104,10 @@ export class HuiImage extends LitElement { imageSrc = this.image; } + if (imageSrc) { + imageSrc = this.hass!.hassUrl(imageSrc); + } + // Figure out filter to use let filter = this.filter || ""; diff --git a/src/panels/lovelace/entity-rows/types.ts b/src/panels/lovelace/entity-rows/types.ts index 48e9363a14..35b733e330 100644 --- a/src/panels/lovelace/entity-rows/types.ts +++ b/src/panels/lovelace/entity-rows/types.ts @@ -7,20 +7,21 @@ export interface EntityConfig { icon?: string; } export interface DividerConfig { - type: string; + type: "divider"; style: string; } export interface SectionConfig { - type: string; + type: "section"; label: string; } export interface WeblinkConfig { - type: string; + type: "weblink"; name?: string; icon?: string; url: string; } export interface CallServiceConfig extends EntityConfig { + type: "call-service"; action_name?: string; service: string; service_data?: { [key: string]: any }; diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index b61eef4071..712ab2f400 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -28,7 +28,6 @@ import "../../components/ha-start-voice-button"; import "../../components/ha-paper-icon-button-arrow-next"; import "../../components/ha-paper-icon-button-arrow-prev"; import "../../components/ha-icon"; -import { loadModule, loadCSS, loadJS } from "../../common/dom/load_resource"; import { debounce } from "../../common/util/debounce"; import { HomeAssistant } from "../../types"; import { LovelaceConfig } from "../../data/lovelace"; @@ -47,10 +46,7 @@ import { Lovelace } from "./types"; import { afterNextRender } from "../../common/util/render-status"; import { haStyle } from "../../resources/styles"; import { computeRTLDirection } from "../../common/util/compute_rtl"; - -// CSS and JS should only be imported once. Modules and HTML are safe. -const CSS_CACHE = {}; -const JS_CACHE = {}; +import { loadLovelaceResources } from "./common/load-resources"; class HUIRoot extends LitElement { @property() public hass?: HomeAssistant; @@ -349,10 +345,14 @@ class HUIRoot extends LitElement { * https://www.w3.org/TR/CSS2/visudet.html#the-height-property */ position: relative; + display: flex; } #view.tabs-hidden { min-height: calc(100vh - 64px); } + #view > * { + flex: 1; + } paper-item { cursor: pointer; } @@ -408,7 +408,12 @@ class HUIRoot extends LitElement { | undefined; if (!oldLovelace || oldLovelace.config !== this.lovelace!.config) { - this._loadResources(this.lovelace!.config.resources || []); + if (this.lovelace!.config.resources) { + loadLovelaceResources( + this.lovelace!.config.resources, + this.hass!.auth.data.hassUrl + ); + } // On config change, recreate the current view from scratch. force = true; // Recalculate to see if we need to adjust content area for tab bar @@ -595,40 +600,6 @@ class HUIRoot extends LitElement { viewConfig.background || this.config.background || ""; root.append(view); } - - private _loadResources(resources) { - resources.forEach((resource) => { - switch (resource.type) { - case "css": - if (resource.url in CSS_CACHE) { - break; - } - CSS_CACHE[resource.url] = loadCSS(resource.url); - break; - - case "js": - if (resource.url in JS_CACHE) { - break; - } - JS_CACHE[resource.url] = loadJS(resource.url); - break; - - case "module": - loadModule(resource.url); - break; - - case "html": - import(/* webpackChunkName: "import-href-polyfill" */ "../../resources/html-import/import-href").then( - ({ importHref }) => importHref(resource.url) - ); - break; - - default: - // tslint:disable-next-line - console.warn(`Unknown resource type specified: ${resource.type}`); - } - }); - } } declare global { diff --git a/src/panels/lovelace/hui-view.ts b/src/panels/lovelace/hui-view.ts index bb70b8be06..f70fec8818 100644 --- a/src/panels/lovelace/hui-view.ts +++ b/src/panels/lovelace/hui-view.ts @@ -57,8 +57,8 @@ export class HUIView extends LitElement { return { hass: {}, lovelace: {}, - columns: {}, - index: {}, + columns: { type: Number }, + index: { type: Number }, _cards: {}, _badges: {}, }; @@ -116,10 +116,10 @@ export class HUIView extends LitElement {