diff --git a/build-scripts/gulp/cast.js b/build-scripts/gulp/cast.js new file mode 100644 index 0000000000..974d2c29cf --- /dev/null +++ b/build-scripts/gulp/cast.js @@ -0,0 +1,37 @@ +// Run cast develop mode +const gulp = require("gulp"); + +require("./clean.js"); +require("./translations.js"); +require("./gen-icons.js"); +require("./gather-static.js"); +require("./webpack.js"); +require("./service-worker.js"); +require("./entry-html.js"); + +gulp.task( + "develop-cast", + gulp.series( + async function setEnv() { + process.env.NODE_ENV = "development"; + }, + "clean-cast", + gulp.parallel("gen-icons", "gen-index-cast-dev", "build-translations"), + "copy-static-cast", + "webpack-dev-server-cast" + ) +); + +gulp.task( + "build-cast", + gulp.series( + async function setEnv() { + process.env.NODE_ENV = "production"; + }, + "clean-cast", + gulp.parallel("gen-icons", "build-translations"), + "copy-static-cast", + "webpack-prod-cast", + "gen-index-cast-prod" + ) +); diff --git a/build-scripts/gulp/clean.js b/build-scripts/gulp/clean.js index fa26682819..f81728fcb3 100644 --- a/build-scripts/gulp/clean.js +++ b/build-scripts/gulp/clean.js @@ -15,3 +15,9 @@ gulp.task( return del([config.demo_root, config.build_dir]); }) ); +gulp.task( + "clean-cast", + gulp.parallel("clean-translations", function cleanOutputAndBuildDir() { + return del([config.cast_root, config.build_dir]); + }) +); diff --git a/build-scripts/gulp/entry-html.js b/build-scripts/gulp/entry-html.js index c322aac4ec..33abdff712 100644 --- a/build-scripts/gulp/entry-html.js +++ b/build-scripts/gulp/entry-html.js @@ -14,6 +14,9 @@ const templatePath = (tpl) => const demoTemplatePath = (tpl) => path.resolve(config.demo_dir, "src/html/", `${tpl}.html.template`); +const castTemplatePath = (tpl) => + path.resolve(config.cast_dir, "src/html/", `${tpl}.html.template`); + const readFile = (pth) => fs.readFileSync(pth).toString(); const renderTemplate = (pth, data = {}, pathFunc = templatePath) => { @@ -24,6 +27,9 @@ const renderTemplate = (pth, data = {}, pathFunc = templatePath) => { const renderDemoTemplate = (pth, data = {}) => renderTemplate(pth, data, demoTemplatePath); +const renderCastTemplate = (pth, data = {}) => + renderTemplate(pth, data, castTemplatePath); + const minifyHtml = (content) => minify(content, { collapseWhitespace: true, @@ -113,17 +119,64 @@ gulp.task("gen-index-app-prod", (done) => { done(); }); -gulp.task("gen-index-demo-dev", (done) => { - // In dev mode we don't mangle names, so we hardcode urls. That way we can - // run webpack as last in watch mode, which blocks output. - const content = renderDemoTemplate("index", { - latestDemoJS: "/frontend_latest/main.js", - - es5Compatibility: "/frontend_es5/compatibility.js", - es5DemoJS: "/frontend_es5/main.js", +gulp.task("gen-index-cast-dev", (done) => { + const contentReceiver = renderCastTemplate("receiver", { + latestReceiverJS: "/frontend_latest/receiver.js", }); + fs.outputFileSync( + path.resolve(config.cast_root, "receiver.html"), + contentReceiver + ); - fs.outputFileSync(path.resolve(config.demo_root, "index.html"), content); + const contentFAQ = renderCastTemplate("launcher-faq", { + latestLauncherJS: "/frontend_latest/launcher.js", + es5LauncherJS: "/frontend_es5/launcher.js", + }); + fs.outputFileSync(path.resolve(config.cast_root, "faq.html"), contentFAQ); + + const contentLauncher = renderCastTemplate("launcher", { + latestLauncherJS: "/frontend_latest/launcher.js", + es5LauncherJS: "/frontend_es5/launcher.js", + }); + fs.outputFileSync( + path.resolve(config.cast_root, "index.html"), + contentLauncher + ); + done(); +}); + +gulp.task("gen-index-cast-prod", (done) => { + const latestManifest = require(path.resolve( + config.cast_output, + "manifest.json" + )); + const es5Manifest = require(path.resolve( + config.cast_output_es5, + "manifest.json" + )); + + const contentReceiver = renderCastTemplate("receiver", { + latestReceiverJS: latestManifest["receiver.js"], + }); + fs.outputFileSync( + path.resolve(config.cast_root, "receiver.html"), + contentReceiver + ); + + const contentFAQ = renderCastTemplate("launcher-faq", { + latestLauncherJS: latestManifest["launcher.js"], + es5LauncherJS: es5Manifest["launcher.js"], + }); + fs.outputFileSync(path.resolve(config.cast_root, "faq.html"), contentFAQ); + + const contentLauncher = renderCastTemplate("launcher", { + latestLauncherJS: latestManifest["launcher.js"], + es5LauncherJS: es5Manifest["launcher.js"], + }); + fs.outputFileSync( + path.resolve(config.cast_root, "index.html"), + contentLauncher + ); done(); }); diff --git a/build-scripts/gulp/gather-static.js b/build-scripts/gulp/gather-static.js index 2bdfda5f75..d1b5ff0090 100644 --- a/build-scripts/gulp/gather-static.js +++ b/build-scripts/gulp/gather-static.js @@ -114,3 +114,15 @@ gulp.task("copy-static-demo", (done) => { copyTranslations(paths.demo_static); done(); }); + +gulp.task("copy-static-cast", (done) => { + // Copy app static files + fs.copySync(polyPath("public/static"), paths.cast_static); + // Copy cast static files + fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_root); + + copyMapPanel(paths.cast_static); + copyFonts(paths.cast_static); + copyTranslations(paths.cast_static); + done(); +}); diff --git a/build-scripts/gulp/webpack.js b/build-scripts/gulp/webpack.js index b1675ad076..6015009d2d 100644 --- a/build-scripts/gulp/webpack.js +++ b/build-scripts/gulp/webpack.js @@ -5,7 +5,11 @@ const webpack = require("webpack"); const WebpackDevServer = require("webpack-dev-server"); const log = require("fancy-log"); const paths = require("../paths"); -const { createAppConfig, createDemoConfig } = require("../webpack"); +const { + createAppConfig, + createDemoConfig, + createCastConfig, +} = require("../webpack"); const handler = (done) => (err, stats) => { if (err) { @@ -114,3 +118,53 @@ gulp.task( ) ) ); + +gulp.task("webpack-dev-server-cast", () => { + const compiler = webpack([ + createCastConfig({ + isProdBuild: false, + latestBuild: false, + }), + createCastConfig({ + isProdBuild: false, + latestBuild: true, + }), + ]); + + new WebpackDevServer(compiler, { + open: true, + watchContentBase: true, + contentBase: path.resolve(paths.cast_dir, "dist"), + }).listen( + 8080, + // Accessible from the network, because that's how Cast hits it. + "0.0.0.0", + function(err) { + if (err) { + throw err; + } + // Server listening + log("[webpack-dev-server]", "http://localhost:8080"); + } + ); +}); + +gulp.task( + "webpack-prod-cast", + () => + new Promise((resolve) => + webpack( + [ + createCastConfig({ + isProdBuild: true, + latestBuild: false, + }), + createCastConfig({ + isProdBuild: true, + latestBuild: true, + }), + ], + handler(resolve) + ) + ) +); diff --git a/build-scripts/paths.js b/build-scripts/paths.js index 356a3fa38a..f59dc35399 100644 --- a/build-scripts/paths.js +++ b/build-scripts/paths.js @@ -14,4 +14,10 @@ module.exports = { demo_static: path.resolve(__dirname, "../demo/dist/static"), demo_output: path.resolve(__dirname, "../demo/dist/frontend_latest"), demo_output_es5: path.resolve(__dirname, "../demo/dist/frontend_es5"), + + cast_dir: path.resolve(__dirname, "../cast"), + cast_root: path.resolve(__dirname, "../cast/dist"), + cast_static: path.resolve(__dirname, "../cast/dist/static"), + cast_output: path.resolve(__dirname, "../cast/dist/frontend_latest"), + cast_output_es5: path.resolve(__dirname, "../cast/dist/frontend_es5"), }; diff --git a/build-scripts/webpack.js b/build-scripts/webpack.js index 7ee1a03714..f41b318f2c 100644 --- a/build-scripts/webpack.js +++ b/build-scripts/webpack.js @@ -214,10 +214,56 @@ const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) => { }; }; +const createCastConfig = ({ isProdBuild, latestBuild }) => { + const isStatsBuild = false; + const entry = { + launcher: "./cast/src/launcher/entrypoint.ts", + }; + + if (latestBuild) { + entry.receiver = "./cast/src/receiver/entrypoint.ts"; + } + + return { + mode: genMode(isProdBuild), + devtool: genDevTool(isProdBuild), + entry, + module: { + rules: [babelLoaderConfig({ latestBuild }), cssLoader, htmlLoader], + }, + optimization: optimization(latestBuild), + plugins: [ + new ManifestPlugin(), + new webpack.DefinePlugin({ + __DEV__: !isProdBuild, + __BUILD__: JSON.stringify(latestBuild ? "latest" : "es5"), + __VERSION__: JSON.stringify(version), + __DEMO__: false, + __STATIC_PATH__: "/static/", + "process.env.NODE_ENV": JSON.stringify( + isProdBuild ? "production" : "development" + ), + }), + ...plugins, + ].filter(Boolean), + resolve, + output: { + filename: genFilename(isProdBuild), + chunkFilename: genChunkFilename(isProdBuild, isStatsBuild), + path: path.resolve( + paths.cast_root, + latestBuild ? "frontend_latest" : "frontend_es5" + ), + publicPath: latestBuild ? "/frontend_latest/" : "/frontend_es5/", + }, + }; +}; + module.exports = { resolve, plugins, optimization, createAppConfig, createDemoConfig, + createCastConfig, }; diff --git a/cast/README.md b/cast/README.md new file mode 100644 index 0000000000..aaf3141759 --- /dev/null +++ b/cast/README.md @@ -0,0 +1,56 @@ +# Home Assistant Cast + +Home Assistant Cast is made up of two separate applications: + +- Chromecast receiver application that can connect to Home Assistant and display relevant information. +- Launcher website that allows users to authorize with their Home Assistant installation and launch the receiver app on their Chromecast. + +## Development + +- Run `script/develop_cast` to launch the Cast receiver dev server. Keep this running. +- Navigate to http://localhost:8080 to start the launcher +- Debug the receiver running on the Chromecast via [chrome://inspect/#devices](chrome://inspect/#devices) + +## Setting up development environment + +### Registering development cast app + +- Go to https://cast.google.com/publish and enroll your account for the Google Cast SDK (costs \$5) +- Register your Chromecast as a testing device by entering the serial +- Add new application -> Custom Receiver + - Name: Home Assistant Dev + - Receiver Application URL: http://IP-OF-DEV-MACHINE:8080/receiver.html + - Guest Mode: off + - Google Case for Audio: off + +### Setting dev variables + +Open `src/cast/const.ts` and change `CAST_DEV` to `true` and `CAST_DEV_APP_ID` to the ID of the app you just created. + +### Changing configuration + +In `configuration.yaml`, configure CORS for the HTTP integration: + +```yaml +http: + cors_allowed_origins: + - https://cast.home-assistant.io + - http://IP-OF-DEV-MACHINE:8080 +``` + +## Running development + +```bash +cd cast +script/develop_cast +``` + +The launcher application will be accessible at [http://localhost:8080](http://localhost:8080) and the receiver application will be accessible at [http://localhost:8080/receiver.html](http://localhost:8080/receiver.html) (but only works if accessed by a Chromecast). + +### Developing cast widgets in HA ui + +If your work involves interaction with the Cast parts from the normal Home Assistant UI, you will need to have that development script running too (`script/develop`). + +### Developing the cast demo + +The cast demo is triggered from the Home Assistant demo. To work on that, you will also need to run the development script for the demo (`script/develop_demo`). diff --git a/cast/public/images/arsaboo.jpg b/cast/public/images/arsaboo.jpg new file mode 100644 index 0000000000..be04da7bf1 Binary files /dev/null and b/cast/public/images/arsaboo.jpg differ diff --git a/cast/public/images/favicon.ico b/cast/public/images/favicon.ico new file mode 100644 index 0000000000..6d12158c18 Binary files /dev/null and b/cast/public/images/favicon.ico differ diff --git a/cast/public/images/google-nest-hub.png b/cast/public/images/google-nest-hub.png new file mode 100644 index 0000000000..d5a4ccf937 Binary files /dev/null and b/cast/public/images/google-nest-hub.png differ diff --git a/cast/public/images/ha-cast-icon.png b/cast/public/images/ha-cast-icon.png new file mode 100644 index 0000000000..52db6718f4 Binary files /dev/null and b/cast/public/images/ha-cast-icon.png differ diff --git a/cast/public/images/melody.jpg b/cast/public/images/melody.jpg new file mode 100644 index 0000000000..c008087cf2 Binary files /dev/null and b/cast/public/images/melody.jpg differ diff --git a/cast/public/manifest.json b/cast/public/manifest.json new file mode 100644 index 0000000000..6700625b91 --- /dev/null +++ b/cast/public/manifest.json @@ -0,0 +1,18 @@ +{ + "background_color": "#FFFFFF", + "description": "Show Home Assistant on your Chromecast or Google Assistant devices with a screen.", + "dir": "ltr", + "display": "standalone", + "icons": [ + { + "src": "/images/ha-cast-icon.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "lang": "en-US", + "name": "Home Assistant Cast", + "short_name": "HA Cast", + "start_url": "/?homescreen=1", + "theme_color": "#03A9F4" +} diff --git a/cast/public/service_worker.js b/cast/public/service_worker.js new file mode 100644 index 0000000000..9cd535c9fc --- /dev/null +++ b/cast/public/service_worker.js @@ -0,0 +1,3 @@ +self.addEventListener("fetch", function(event) { + event.respondWith(fetch(event.request)); +}); diff --git a/cast/script/build_cast b/cast/script/build_cast new file mode 100755 index 0000000000..60e5160296 --- /dev/null +++ b/cast/script/build_cast @@ -0,0 +1,9 @@ +#!/bin/sh +# Build the cast receiver + +# Stop on errors +set -e + +cd "$(dirname "$0")/../.." + +./node_modules/.bin/gulp build-cast diff --git a/cast/script/develop_cast b/cast/script/develop_cast new file mode 100755 index 0000000000..a40de523b9 --- /dev/null +++ b/cast/script/develop_cast @@ -0,0 +1,9 @@ +#!/bin/sh +# Develop the cast receiver + +# Stop on errors +set -e + +cd "$(dirname "$0")/../.." + +./node_modules/.bin/gulp develop-cast diff --git a/cast/script/upload b/cast/script/upload new file mode 100755 index 0000000000..95f46032cf --- /dev/null +++ b/cast/script/upload @@ -0,0 +1,3 @@ +# Run it twice, second time we just delete. +aws s3 sync dist s3://cast.home-assistant.io --acl public-read +aws s3 sync dist s3://cast.home-assistant.io --acl public-read --delete diff --git a/cast/src/html/launcher-faq.html.template b/cast/src/html/launcher-faq.html.template new file mode 100644 index 0000000000..85ecbb0079 --- /dev/null +++ b/cast/src/html/launcher-faq.html.template @@ -0,0 +1,226 @@ + + + + Home Assistant Cast - FAQ + + <%= renderTemplate('_style_base') %> + + + + + + + + + + + + + + + + <%= renderTemplate('_js_base') %> + + + + + + + +
+

« Back to Home Assistant Cast

+
+ +
What is Home Assistant Cast?
+
+

+ Home Assistant Cast allows you to show your Home Assistant data on a + Chromecast device and allows you to interact with Home Assistant on + Google Assistant devices with a screen. +

+
+ +
+ What are the Home Assistant Cast requirements? +
+
+

+ Home Assistant Cast requires a Home Assistant installation that is + accessible via HTTPS (the url starts with "https://"). +

+
+ +
What is Home Assistant?
+
+

+ Home Assistant is worlds biggest open source home automation platform + with a focus on privacy and local control. You can install Home + Assistant for free. +

+

+ Visit the Home Assistant website. +

+
+ +
+ Why does my Home Assistant needs to be served using HTTPS? +
+
+

+ The Chromecast only works with websites served over HTTPS. This means + that the Home Assistant Cast app that runs on your Chromecast is + served over HTTPS. Websites served over HTTPS are restricted on what + content can be accessed on websites served over HTTP. This is called + mixed active content (learn more @ MDN). +

+

+ The easiest way to get your Home Assistant installation served over + HTTPS is by signing up for + Home Assistant Cloud by Nabu Casa. +

+
+ +
How does Home Assistant Cast work?
+
+

+ Home Assistant Cast is a receiver application for the Chromecast. When + loaded, it will make a direct connection to your Home Assistant + instance. +

+

+ Home Assistant Cast is able to render any of your Lovelace views on + your Chromecast. Things that work in Lovelace in Home Assistant will + work in Home Assistant Cast: +

+ +

Things that currently do not work:

+ +
+ +
+ How do I change what is shown on my Chromecast? +
+
+

+ Home Assistant Cast allows you to show your Lovelace view on your + Chromecast. So to edit what is shown, you need to edit your Lovelace + UI. +

+

+ To edit your Lovelace UI, open Home Assistant, click on the three-dot + menu in the top right and click on "Configure UI". +

+
+ +
+ What browsers are supported? +
+
+

+ Chromecast is a technology developed by Google, and is available on: +

+ +
+ +
Why do some custom cards not work?
+
+

+ Home Assistant needs to be configured to allow Home Assistant Cast to + load custom cards. Starting with Home Assistant 0.97, this is done + automatically. If you are on an older version, or have manually + configured CORS for the HTTP integration, add the following to your + configuration.yaml file: +

+
+http:
+  cors_allowed_origins:
+    - https://cast.home-assistant.io
+

+ Some custom cards rely on things that are only available in the normal + Home Assistant interface. This requires an update by the custom card + developer. +

+

+ If you're a custom card developer: the most common mistake is that + LitElement is extracted from an element that is not available on the + page. +

+
+
+ + + + diff --git a/cast/src/html/launcher.html.template b/cast/src/html/launcher.html.template new file mode 100644 index 0000000000..da036e54eb --- /dev/null +++ b/cast/src/html/launcher.html.template @@ -0,0 +1,51 @@ + + + + Home Assistant Cast + + + <%= renderTemplate('_style_base') %> + + + + + + + + + + + + + + + + <%= renderTemplate('_js_base') %> + + + + + + + + + diff --git a/cast/src/html/receiver.html.template b/cast/src/html/receiver.html.template new file mode 100644 index 0000000000..9b6170abcd --- /dev/null +++ b/cast/src/html/receiver.html.template @@ -0,0 +1,12 @@ + + + + + <%= renderTemplate('_style_base') %> + + diff --git a/cast/src/launcher/entrypoint.ts b/cast/src/launcher/entrypoint.ts new file mode 100644 index 0000000000..74330ec866 --- /dev/null +++ b/cast/src/launcher/entrypoint.ts @@ -0,0 +1,5 @@ +import "../../../src/resources/ha-style"; +import "../../../src/resources/roboto"; +import "../../../src/components/ha-iconset-svg"; +import "../../../src/resources/hass-icons"; +import "./layout/hc-connect"; diff --git a/cast/src/launcher/layout/hc-cast.ts b/cast/src/launcher/layout/hc-cast.ts new file mode 100644 index 0000000000..02a5ed2b5b --- /dev/null +++ b/cast/src/launcher/layout/hc-cast.ts @@ -0,0 +1,290 @@ +import { + customElement, + LitElement, + property, + TemplateResult, + html, + CSSResult, + css, +} from "lit-element"; +import { Connection, Auth } from "home-assistant-js-websocket"; +import "@polymer/iron-icon"; +import "@polymer/paper-listbox/paper-listbox"; +import "@polymer/paper-item/paper-icon-item"; +import "../../../../src/components/ha-icon"; +import { + enableWrite, + askWrite, + saveTokens, +} from "../../../../src/common/auth/token_storage"; +import { + ensureConnectedCastSession, + castSendShowLovelaceView, +} from "../../../../src/cast/receiver_messages"; +import "../../../../src/layouts/loading-screen"; +import { CastManager } from "../../../../src/cast/cast_manager"; +import { + LovelaceConfig, + getLovelaceCollection, +} from "../../../../src/data/lovelace"; +import "./hc-layout"; +import { generateDefaultViewConfig } from "../../../../src/panels/lovelace/common/generate-lovelace-config"; + +@customElement("hc-cast") +class HcCast extends LitElement { + @property() public auth!: Auth; + @property() public connection!: Connection; + @property() public castManager!: CastManager; + @property() private askWrite = false; + @property() private lovelaceConfig?: LovelaceConfig | null; + + protected render(): TemplateResult | void { + if (this.lovelaceConfig === undefined) { + return html` + > + `; + } + + const error = + this.castManager.castState === "NO_DEVICES_AVAILABLE" + ? html` +

+ There were no suitable Chromecast devices to cast to found. +

+ ` + : undefined; + + return html` + + ${this.askWrite + ? html` +

+ Stay logged in? + + + YES + + + NO + + +

+ ` + : ""} + ${error + ? html` +
${error}
+ ` + : !this.castManager.status + ? html` +

+ + + Start Casting + +

+ ` + : html` +
PICK A VIEW
+ + ${(this.lovelaceConfig + ? this.lovelaceConfig.views + : [generateDefaultViewConfig([], [], [], {}, () => "")] + ).map( + (view, idx) => html` + + ${view.icon + ? html` + + ` + : ""} + ${view.title || view.path} + + ` + )} + + `} +
+ ${this.castManager.status + ? html` + + + Manage + + ` + : ""} +
+ Log out +
+
+ `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + + const llColl = getLovelaceCollection(this.connection); + // We first do a single refresh because we need to check if there is LL + // configuration. + llColl.refresh().then( + () => { + llColl.subscribe((config) => { + this.lovelaceConfig = config; + }); + }, + async () => { + this.lovelaceConfig = null; + } + ); + + this.askWrite = askWrite(); + + this.castManager.addEventListener("state-changed", () => { + this.requestUpdate(); + }); + this.castManager.addEventListener("connection-changed", () => { + this.requestUpdate(); + }); + } + + protected updated(changedProps) { + super.updated(changedProps); + if (this.castManager && this.castManager.status) { + const selectEl = this.shadowRoot!.querySelector("select"); + if (selectEl) { + this.shadowRoot!.querySelector("select")!.value = + this.castManager.castConnectedToOurHass && + !this.castManager.status.showDemo + ? this.castManager.status.lovelacePath || "" + : ""; + } + } + this.toggleAttribute( + "hide-icons", + this.lovelaceConfig + ? !this.lovelaceConfig.views.some((view) => view.icon) + : true + ); + } + + private async _handleSkipSaveTokens() { + this.askWrite = false; + } + + private async _handleSaveTokens() { + enableWrite(); + this.askWrite = false; + } + + private _handleLaunch() { + this.castManager.requestSession(); + } + + private async _handlePickView(ev: Event) { + const path = (ev.currentTarget as any).getAttribute("data-path"); + await ensureConnectedCastSession(this.castManager!, this.auth!); + castSendShowLovelaceView(this.castManager, path); + } + + private async _handleLogout() { + try { + await this.auth.revoke(); + saveTokens(null); + if (this.castManager.castSession) { + this.castManager.castContext.endCurrentSession(true); + } + this.connection.close(); + location.reload(); + } catch (err) { + alert("Unable to log out!"); + } + } + + static get styles(): CSSResult { + return css` + .center-item { + display: flex; + justify-content: space-around; + } + + .action-item { + display: flex; + align-items: center; + justify-content: space-between; + } + + .question { + position: relative; + padding: 8px 16px; + } + + .question:before { + border-radius: 4px; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + content: ""; + background-color: var(--primary-color); + opacity: 0.12; + will-change: opacity; + } + + .connection, + .connection a { + color: var(--secondary-text-color); + } + + mwc-button iron-icon { + margin-right: 8px; + height: 18px; + } + + paper-listbox { + padding-top: 0; + } + + paper-listbox ha-icon { + padding: 12px; + color: var(--secondary-text-color); + } + + paper-icon-item { + cursor: pointer; + } + + paper-icon-item[disabled] { + cursor: initial; + } + + :host([hide-icons]) paper-icon-item { + --paper-item-icon-width: 0px; + } + + .spacer { + flex: 1; + } + + .card-content a { + color: var(--primary-color); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hc-cast": HcCast; + } +} diff --git a/cast/src/launcher/layout/hc-connect.ts b/cast/src/launcher/layout/hc-connect.ts new file mode 100644 index 0000000000..a2a56403d1 --- /dev/null +++ b/cast/src/launcher/layout/hc-connect.ts @@ -0,0 +1,333 @@ +import { + LitElement, + customElement, + property, + TemplateResult, + html, + CSSResult, + css, +} from "lit-element"; +import { + getAuth, + createConnection, + Auth, + getAuthOptions, + ERR_HASS_HOST_REQUIRED, + ERR_INVALID_HTTPS_TO_HTTP, + Connection, + ERR_CANNOT_CONNECT, + ERR_INVALID_AUTH, +} from "home-assistant-js-websocket"; +import "@polymer/iron-icon"; +import "@material/mwc-button"; +import "@polymer/paper-input/paper-input"; +import { + loadTokens, + saveTokens, +} from "../../../../src/common/auth/token_storage"; +import "../../../../src/layouts/loading-screen"; +import { CastManager, getCastManager } from "../../../../src/cast/cast_manager"; +import "./hc-layout"; +import { castSendShowDemo } from "../../../../src/cast/receiver_messages"; +import { registerServiceWorker } from "../../../../src/util/register-service-worker"; + +const seeFAQ = (qid) => html` + See the FAQ for more + information. +`; +const translateErr = (err) => + err === ERR_CANNOT_CONNECT + ? "Unable to connect" + : err === ERR_HASS_HOST_REQUIRED + ? "Please enter a Home Assistant URL." + : err === ERR_INVALID_HTTPS_TO_HTTP + ? html` + Cannot connect to Home Assistant instances over "http://". + ${seeFAQ("https")} + ` + : `Unknown error (${err}).`; + +const INTRO = html` +

+ Home Assistant Cast allows you to cast your Home Assistant installation to + Chromecast video devices and to Google Assistant devices with a screen. +

+

+ For more information, see the + frequently asked questions. +

+`; + +@customElement("hc-connect") +export class HcConnect extends LitElement { + @property() private loading = false; + // If we had stored credentials but we cannot connect, + // show a screen asking retry or logout. + @property() private cannotConnect = false; + @property() private error?: string | TemplateResult; + @property() private auth?: Auth; + @property() private connection?: Connection; + @property() private castManager?: CastManager | null; + private openDemo = false; + + protected render(): TemplateResult | void { + if (this.cannotConnect) { + const tokens = loadTokens(); + return html` + +
+ Unable to connect to ${tokens!.hassUrl}. +
+
+ + + Retry + + +
+ Log out +
+
+ `; + } + + if (this.castManager === undefined || this.loading) { + return html` + + `; + } + + if (this.castManager === null) { + return html` + +
+ ${INTRO} +

+ The Cast API is not available in your browser. + ${seeFAQ("browser")} +

+
+
+ `; + } + + if (!this.auth) { + return html` + +
+ ${INTRO} +

+ To get started, enter your Home Assistant URL and click authorize. + If you want a preview instead, click the show demo button. +

+

+ +

+ ${this.error + ? html` +

${this.error}

+ ` + : ""} +
+
+ + Show Demo + + +
+ Authorize +
+
+ `; + } + + return html` + + `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + import("./hc-cast"); + + getCastManager().then( + async (mgr) => { + this.castManager = mgr; + mgr.addEventListener("connection-changed", () => { + this.requestUpdate(); + }); + mgr.addEventListener("state-changed", () => { + if (this.openDemo && mgr.castState === "CONNECTED" && !this.auth) { + castSendShowDemo(mgr); + } + }); + + if (location.search.indexOf("auth_callback=1") !== -1) { + this._tryConnection("auth-callback"); + } else if (loadTokens()) { + this._tryConnection("saved-tokens"); + } + }, + () => { + this.castManager = null; + } + ); + registerServiceWorker(false); + } + + private async _handleDemo() { + this.openDemo = true; + if (this.castManager!.status && !this.castManager!.status.showDemo) { + castSendShowDemo(this.castManager!); + } else { + this.castManager!.requestSession(); + } + } + + private _handleInputKeyDown(ev: KeyboardEvent) { + // Handle pressing enter. + if (ev.keyCode === 13) { + this._handleConnect(); + } + } + + private async _handleConnect() { + const inputEl = this.shadowRoot!.querySelector("paper-input")!; + const value = inputEl.value || ""; + this.error = undefined; + + if (value === "") { + this.error = "Please enter a Home Assistant URL."; + return; + } else if (value.indexOf("://") === -1) { + this.error = + "Please enter your full URL, including the protocol part (https://)."; + return; + } + + let url: URL; + try { + url = new URL(value); + } catch (err) { + this.error = "Invalid URL"; + return; + } + + if (url.protocol === "http:" && url.hostname !== "localhost") { + this.error = translateErr(ERR_INVALID_HTTPS_TO_HTTP); + return; + } + await this._tryConnection("user-request", `${url.protocol}//${url.host}`); + } + + private async _tryConnection( + init: "auth-callback" | "user-request" | "saved-tokens", + hassUrl?: string + ) { + const options: getAuthOptions = { + saveTokens, + loadTokens: () => Promise.resolve(loadTokens()), + }; + if (hassUrl) { + options.hassUrl = hassUrl; + } + let auth: Auth; + + try { + this.loading = true; + auth = await getAuth(options); + } catch (err) { + if (init === "saved-tokens" && err === ERR_CANNOT_CONNECT) { + this.cannotConnect = true; + return; + } + this.error = translateErr(err); + this.loading = false; + return; + } finally { + // Clear url if we have a auth callback in url. + if (location.search.includes("auth_callback=1")) { + history.replaceState(null, "", location.pathname); + } + } + + let conn: Connection; + + try { + conn = await createConnection({ auth }); + } catch (err) { + // In case of saved tokens, silently solve problems. + if (init === "saved-tokens") { + if (err === ERR_CANNOT_CONNECT) { + this.cannotConnect = true; + } else if (err === ERR_INVALID_AUTH) { + saveTokens(null); + } + } else { + this.error = translateErr(err); + } + + return; + } finally { + this.loading = false; + } + + this.auth = auth; + this.connection = conn; + this.castManager!.auth = auth; + } + + private async _handleLogout() { + try { + saveTokens(null); + location.reload(); + } catch (err) { + alert("Unable to log out!"); + } + } + + static get styles(): CSSResult { + return css` + .card-content a { + color: var(--primary-color); + } + .card-actions a { + text-decoration: none; + } + .error { + color: red; + font-weight: bold; + } + + .error a { + color: darkred; + } + + mwc-button iron-icon { + margin-left: 8px; + } + + .spacer { + flex: 1; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hc-connect": HcConnect; + } +} diff --git a/cast/src/launcher/layout/hc-layout.ts b/cast/src/launcher/layout/hc-layout.ts new file mode 100644 index 0000000000..5decc615a4 --- /dev/null +++ b/cast/src/launcher/layout/hc-layout.ts @@ -0,0 +1,166 @@ +import { + customElement, + LitElement, + TemplateResult, + html, + CSSResult, + css, + property, +} from "lit-element"; +import { + Auth, + Connection, + HassUser, + getUser, +} from "home-assistant-js-websocket"; +import "../../../../src/components/ha-card"; + +@customElement("hc-layout") +class HcLayout extends LitElement { + @property() public subtitle?: string | undefined; + @property() public auth?: Auth; + @property() public connection?: Connection; + @property() public user?: HassUser; + + protected render(): TemplateResult | void { + return html` + +
+ +
+ Home Assistant Cast${this.subtitle ? ` – ${this.subtitle}` : ""} + ${this.auth + ? html` +
+ ${this.auth.data.hassUrl.substr( + this.auth.data.hassUrl.indexOf("//") + 2 + )} + ${this.user + ? html` + – ${this.user.name} + ` + : ""} +
+ ` + : ""} +
+ +
+
+ + `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + + if (this.connection) { + getUser(this.connection).then((user) => { + this.user = user; + }); + } + } + + static get styles(): CSSResult { + return css` + :host { + display: flex; + min-height: 100%; + align-items: center; + justify-content: center; + flex-direction: column; + } + + ha-card { + display: flex; + width: 100%; + max-width: 500px; + } + + .layout { + display: flex; + flex-direction: column; + } + + .card-header { + color: var(--ha-card-header-color, --primary-text-color); + font-family: var(--ha-card-header-font-family, inherit); + font-size: var(--ha-card-header-font-size, 24px); + letter-spacing: -0.012em; + line-height: 32px; + padding: 24px 16px 16px; + display: block; + } + + .subtitle { + font-size: 14px; + color: var(--secondary-text-color); + line-height: initial; + } + .subtitle a { + color: var(--secondary-text-color); + } + + :host ::slotted(.card-content:not(:first-child)), + slot:not(:first-child)::slotted(.card-content) { + padding-top: 0px; + margin-top: -8px; + } + + :host ::slotted(.section-header) { + font-weight: 500; + padding: 4px 16px; + text-transform: uppercase; + } + + :host ::slotted(.card-content) { + padding: 16px; + flex: 1; + } + + :host ::slotted(.card-actions) { + border-top: 1px solid #e8e8e8; + padding: 5px 16px; + display: flex; + } + + img { + width: 100%; + } + + .footer { + text-align: center; + font-size: 12px; + padding: 8px 0 24px; + color: var(--secondary-text-color); + } + .footer a { + color: var(--secondary-text-color); + } + + @media all and (max-width: 500px) { + :host { + justify-content: flex-start; + min-height: 90%; + margin-bottom: 30px; + } + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hc-layout": HcLayout; + } +} diff --git a/cast/src/receiver/cast_context.ts b/cast/src/receiver/cast_context.ts new file mode 100644 index 0000000000..587624c74f --- /dev/null +++ b/cast/src/receiver/cast_context.ts @@ -0,0 +1 @@ +export const castContext = cast.framework.CastReceiverContext.getInstance(); diff --git a/cast/src/receiver/demo/cast-demo-entities.ts b/cast/src/receiver/demo/cast-demo-entities.ts new file mode 100644 index 0000000000..c0e8089e16 --- /dev/null +++ b/cast/src/receiver/demo/cast-demo-entities.ts @@ -0,0 +1,141 @@ +import { Entity, convertEntities } from "../../../../src/fake_data/entity"; + +export const castDemoEntities: () => Entity[] = () => + convertEntities({ + "light.reading_light": { + entity_id: "light.reading_light", + state: "on", + attributes: { + friendly_name: "Reading Light", + }, + }, + "light.ceiling": { + entity_id: "light.ceiling", + state: "on", + attributes: { + friendly_name: "Ceiling lights", + }, + }, + "light.standing_lamp": { + entity_id: "light.standing_lamp", + state: "off", + attributes: { + friendly_name: "Standing Lamp", + }, + }, + "sensor.temperature_inside": { + entity_id: "sensor.temperature_inside", + state: "22.7", + attributes: { + battery_level: 78, + unit_of_measurement: "\u00b0C", + friendly_name: "Inside", + device_class: "temperature", + }, + }, + "sensor.temperature_outside": { + entity_id: "sensor.temperature_outside", + state: "31.4", + attributes: { + battery_level: 53, + unit_of_measurement: "\u00b0C", + friendly_name: "Outside", + device_class: "temperature", + }, + }, + "person.arsaboo": { + entity_id: "person.arsaboo", + state: "not_home", + attributes: { + radius: 50, + friendly_name: "Arsaboo", + latitude: 52.3579946, + longitude: 4.8664597, + entity_picture: "/images/arsaboo.jpg", + }, + }, + "person.melody": { + entity_id: "person.melody", + state: "not_home", + attributes: { + radius: 50, + friendly_name: "Melody", + latitude: 52.3408927, + longitude: 4.8711073, + entity_picture: "/images/melody.jpg", + }, + }, + "zone.home": { + entity_id: "zone.home", + state: "zoning", + attributes: { + hidden: true, + latitude: 52.3631339, + longitude: 4.8903147, + radius: 100, + friendly_name: "Home", + icon: "hass:home", + }, + }, + "input_number.harmonyvolume": { + entity_id: "input_number.harmonyvolume", + state: "18.0", + attributes: { + initial: 30, + min: 1, + max: 100, + step: 1, + mode: "slider", + friendly_name: "Volume", + icon: "hass:volume-high", + }, + }, + "climate.upstairs": { + entity_id: "climate.upstairs", + state: "auto", + attributes: { + current_temperature: 24, + min_temp: 15, + max_temp: 30, + temperature: null, + target_temp_high: 26, + target_temp_low: 18, + fan_mode: "auto", + fan_modes: ["auto", "on"], + hvac_modes: ["auto", "cool", "heat", "off"], + aux_heat: "off", + actual_humidity: 30, + fan: "on", + operation: "fan", + fan_min_on_time: 10, + friendly_name: "Upstairs", + supported_features: 27, + preset_mode: "away", + preset_modes: ["home", "away", "eco", "sleep"], + }, + }, + "climate.downstairs": { + entity_id: "climate.downstairs", + state: "auto", + attributes: { + current_temperature: 22, + min_temp: 15, + max_temp: 30, + temperature: null, + target_temp_high: 24, + target_temp_low: 20, + fan_mode: "auto", + fan_modes: ["auto", "on"], + hvac_modes: ["auto", "cool", "heat", "off"], + aux_heat: "off", + actual_humidity: 30, + fan: "on", + operation: "fan", + fan_min_on_time: 10, + friendly_name: "Downstairs", + supported_features: 27, + preset_mode: "home", + preset_modes: ["home", "away", "eco", "sleep"], + }, + }, + }); diff --git a/cast/src/receiver/demo/cast-demo-lovelace.ts b/cast/src/receiver/demo/cast-demo-lovelace.ts new file mode 100644 index 0000000000..62f739e793 --- /dev/null +++ b/cast/src/receiver/demo/cast-demo-lovelace.ts @@ -0,0 +1,93 @@ +import { + LovelaceConfig, + LovelaceCardConfig, +} from "../../../../src/data/lovelace"; +import { castContext } from "../cast_context"; + +export const castDemoLovelace: () => LovelaceConfig = () => { + const touchSupported = castContext.getDeviceCapabilities() + .touch_input_supported; + return { + views: [ + { + path: "overview", + cards: [ + { + type: "markdown", + title: "Home Assistant Cast", + content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Assistant devices with a screen.${ + touchSupported + ? "\n\nYou are able to interact with this demo using the touch screen." + : "\n\nOn a Google Nest Hub you are able to interact with Home Assistant Cast via the touch screen." + }`, + }, + { + type: touchSupported ? "entities" : "glance", + title: "Living Room", + entities: [ + "light.reading_light", + "light.ceiling", + "light.standing_lamp", + "input_number.harmonyvolume", + ], + }, + { + cards: [ + { + graph: "line", + type: "sensor", + entity: "sensor.temperature_inside", + }, + { + graph: "line", + type: "sensor", + entity: "sensor.temperature_outside", + }, + ], + type: "horizontal-stack", + }, + { + type: "map", + entities: ["person.arsaboo", "person.melody", "zone.home"], + aspect_ratio: touchSupported ? "16:9.3" : "16:11", + }, + touchSupported && { + type: "entities", + entities: [ + { + type: "weblink", + url: "/lovelace/climate", + name: "Climate controls", + icon: "hass:arrow-right", + }, + ], + }, + ].filter(Boolean) as LovelaceCardConfig[], + }, + { + path: "climate", + cards: [ + { + type: "thermostat", + entity: "climate.downstairs", + }, + { + type: "entities", + entities: [ + { + type: "weblink", + url: "/lovelace/overview", + name: "Back", + icon: "hass:arrow-left", + }, + ], + }, + { + type: "thermostat", + entity: "climate.upstairs", + }, + ], + }, + ], + }; +}; diff --git a/cast/src/receiver/entrypoint.ts b/cast/src/receiver/entrypoint.ts new file mode 100644 index 0000000000..0430dbbf5a --- /dev/null +++ b/cast/src/receiver/entrypoint.ts @@ -0,0 +1,42 @@ +import "../../../src/resources/custom-card-support"; +import { castContext } from "./cast_context"; +import { ReceivedMessage } from "./types"; +import { HassMessage } from "../../../src/cast/receiver_messages"; +import { HcMain } from "./layout/hc-main"; +import { CAST_NS } from "../../../src/cast/const"; + +const controller = new HcMain(); +document.body.append(controller); + +const options = new cast.framework.CastReceiverOptions(); +options.disableIdleTimeout = true; +options.customNamespaces = { + // @ts-ignore + [CAST_NS]: cast.framework.system.MessageType.JSON, +}; + +// The docs say we need to set options.touchScreenOptimizeApp = true +// https://developers.google.com/cast/docs/caf_receiver/customize_ui#accessing_ui_controls +// This doesn't work. +// @ts-ignore +options.touchScreenOptimizedApp = true; + +// The class reference say we can set a uiConfig in options to set it +// https://developers.google.com/cast/docs/reference/caf_receiver/cast.framework.CastReceiverOptions#uiConfig +// This doesn't work either. +// @ts-ignore +options.uiConfig = new cast.framework.ui.UiConfig(); +// @ts-ignore +options.uiConfig.touchScreenOptimizedApp = true; + +castContext.addCustomMessageListener( + CAST_NS, + // @ts-ignore + (ev: ReceivedMessage) => { + const msg = ev.data; + msg.senderId = ev.senderId; + controller.processIncomingMessage(msg); + } +); + +castContext.start(options); diff --git a/cast/src/receiver/layout/hc-demo.ts b/cast/src/receiver/layout/hc-demo.ts new file mode 100644 index 0000000000..878026e555 --- /dev/null +++ b/cast/src/receiver/layout/hc-demo.ts @@ -0,0 +1,56 @@ +import { HassElement } from "../../../../src/state/hass-element"; +import "./hc-lovelace"; +import { customElement, TemplateResult, html, property } from "lit-element"; +import { + MockHomeAssistant, + provideHass, +} from "../../../../src/fake_data/provide_hass"; +import { HomeAssistant } from "../../../../src/types"; +import { LovelaceConfig } from "../../../../src/data/lovelace"; +import { castDemoEntities } from "../demo/cast-demo-entities"; +import { castDemoLovelace } from "../demo/cast-demo-lovelace"; +import { mockHistory } from "../../../../demo/src/stubs/history"; + +@customElement("hc-demo") +class HcDemo extends HassElement { + @property() public lovelacePath!: string; + @property() private _lovelaceConfig?: LovelaceConfig; + + protected render(): TemplateResult | void { + if (!this._lovelaceConfig) { + return html``; + } + return html` + + `; + } + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + this._initialize(); + } + + private async _initialize() { + const initial: Partial = { + // Override updateHass so that the correct hass lifecycle methods are called + updateHass: (hassUpdate: Partial) => + this._updateHass(hassUpdate), + }; + + const hass = (this.hass = provideHass(this, initial)); + + mockHistory(hass); + + hass.addEntities(castDemoEntities()); + this._lovelaceConfig = castDemoLovelace(); + } +} + +declare global { + interface HTMLElementTagNameMap { + "hc-demo": HcDemo; + } +} diff --git a/cast/src/receiver/layout/hc-launch-screen.ts b/cast/src/receiver/layout/hc-launch-screen.ts new file mode 100644 index 0000000000..a838ae3e7a --- /dev/null +++ b/cast/src/receiver/layout/hc-launch-screen.ts @@ -0,0 +1,66 @@ +import { + LitElement, + TemplateResult, + html, + customElement, + CSSResult, + css, + property, +} from "lit-element"; +import { HomeAssistant } from "../../../../src/types"; + +@customElement("hc-launch-screen") +class HcLaunchScreen extends LitElement { + @property() public hass?: HomeAssistant; + @property() public error?: string; + + protected render(): TemplateResult | void { + return html` +
+ +
+ ${this.hass ? "Connected" : "Not Connected"} + ${this.error + ? html` +

Error: ${this.error}

+ ` + : ""} +
+
+ `; + } + + static get styles(): CSSResult { + return css` + :host { + display: block; + height: 100vh; + padding-top: 64px; + background-color: white; + font-size: 24px; + } + .container { + display: flex; + flex-direction: column; + text-align: center; + } + img { + width: 717px; + height: 376px; + display: block; + margin: 0 auto; + } + .status { + padding-right: 54px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hc-launch-screen": HcLaunchScreen; + } +} diff --git a/cast/src/receiver/layout/hc-lovelace.ts b/cast/src/receiver/layout/hc-lovelace.ts new file mode 100644 index 0000000000..4a41175dc9 --- /dev/null +++ b/cast/src/receiver/layout/hc-lovelace.ts @@ -0,0 +1,100 @@ +import { + LitElement, + TemplateResult, + html, + customElement, + CSSResult, + css, + property, +} from "lit-element"; +import { LovelaceConfig } from "../../../../src/data/lovelace"; +import "../../../../src/panels/lovelace/hui-view"; +import { HomeAssistant } from "../../../../src/types"; +import { Lovelace } from "../../../../src/panels/lovelace/types"; +import "./hc-launch-screen"; + +@customElement("hc-lovelace") +class HcLovelace extends LitElement { + @property() public hass!: HomeAssistant; + @property() public lovelaceConfig!: LovelaceConfig; + @property() public viewPath?: string; + + protected render(): TemplateResult | void { + const index = this._viewIndex; + if (index === undefined) { + return html` + + `; + } + const lovelace: Lovelace = { + config: this.lovelaceConfig, + editMode: false, + enableFullEditMode: () => undefined, + mode: "storage", + language: "en", + saveConfig: async () => undefined, + setEditMode: () => undefined, + }; + return html` + + `; + } + + protected updated(changedProps) { + super.updated(changedProps); + if (changedProps.has("viewPath") || changedProps.has("lovelaceConfig")) { + const index = this._viewIndex; + + if (index) { + this.shadowRoot!.querySelector("hui-view")!.style.background = + this.lovelaceConfig.views[index].background || + this.lovelaceConfig.background || + ""; + } + } + } + + private get _viewIndex() { + const selectedView = this.viewPath; + const selectedViewInt = parseInt(selectedView!, 10); + for (let i = 0; i < this.lovelaceConfig.views.length; i++) { + if ( + this.lovelaceConfig.views[i].path === selectedView || + i === selectedViewInt + ) { + return i; + } + } + return undefined; + } + + static get styles(): CSSResult { + // We're applying a 10% transform so it all shows a little bigger. + return css` + :host { + min-height: 100vh; + display: flex; + flex-direction: column; + box-sizing: border-box; + background: var(--primary-background-color); + } + hui-view { + flex: 1; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hc-lovelace": HcLovelace; + } +} diff --git a/cast/src/receiver/layout/hc-main.ts b/cast/src/receiver/layout/hc-main.ts new file mode 100644 index 0000000000..b67bf96077 --- /dev/null +++ b/cast/src/receiver/layout/hc-main.ts @@ -0,0 +1,217 @@ +import { HassElement } from "../../../../src/state/hass-element"; +import { + getAuth, + createConnection, + UnsubscribeFunc, +} from "home-assistant-js-websocket"; +import { customElement, TemplateResult, html, property } from "lit-element"; +import { + HassMessage, + ConnectMessage, + ShowLovelaceViewMessage, + GetStatusMessage, + ShowDemoMessage, +} from "../../../../src/cast/receiver_messages"; +import { + LovelaceConfig, + getLovelaceCollection, +} from "../../../../src/data/lovelace"; +import "./hc-launch-screen"; +import { castContext } from "../cast_context"; +import { CAST_NS } from "../../../../src/cast/const"; +import { ReceiverStatusMessage } from "../../../../src/cast/sender_messages"; +import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources"; +import { isNavigationClick } from "../../../../src/common/dom/is-navigation-click"; + +@customElement("hc-main") +export class HcMain extends HassElement { + @property() private _showDemo = false; + @property() private _lovelaceConfig?: LovelaceConfig; + @property() private _lovelacePath: string | null = null; + @property() private _error?: string; + private _unsubLovelace?: UnsubscribeFunc; + + public processIncomingMessage(msg: HassMessage) { + if (msg.type === "connect") { + this._handleConnectMessage(msg); + } else if (msg.type === "show_lovelace_view") { + this._handleShowLovelaceMessage(msg); + } else if (msg.type === "get_status") { + this._handleGetStatusMessage(msg); + } else if (msg.type === "show_demo") { + this._handleShowDemo(msg); + } else { + // tslint:disable-next-line: no-console + console.warn("unknown msg type", msg); + } + } + + protected render(): TemplateResult | void { + if (this._showDemo) { + return html` + + `; + } + + if (!this._lovelaceConfig || !this._lovelacePath) { + return html` + + `; + } + return html` + + `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + import("../second-load"); + window.addEventListener("location-changed", () => { + if (location.pathname.startsWith("/lovelace/")) { + this._lovelacePath = location.pathname.substr(10); + this._sendStatus(); + } + }); + document.body.addEventListener("click", (ev) => { + const href = isNavigationClick(ev); + if (href && href.startsWith("/lovelace/")) { + this._lovelacePath = href.substr(10); + this._sendStatus(); + } + }); + } + + private _sendStatus(senderId?: string) { + const status: ReceiverStatusMessage = { + type: "receiver_status", + connected: !!this.hass, + showDemo: this._showDemo, + }; + + if (this.hass) { + status.hassUrl = this.hass.auth.data.hassUrl; + status.lovelacePath = this._lovelacePath!; + } + + if (senderId) { + this.sendMessage(senderId, status); + } else { + for (const sender of castContext.getSenders()) { + this.sendMessage(sender.id, status); + } + } + } + + private async _handleGetStatusMessage(msg: GetStatusMessage) { + this._sendStatus(msg.senderId!); + } + + private async _handleConnectMessage(msg: ConnectMessage) { + let auth; + try { + auth = await getAuth({ + loadTokens: async () => ({ + hassUrl: msg.hassUrl, + clientId: msg.clientId, + refresh_token: msg.refreshToken, + access_token: "", + expires: 0, + expires_in: 0, + }), + }); + } catch (err) { + this._error = err; + return; + } + const connection = await createConnection({ auth }); + if (this.hass) { + this.hass.connection.close(); + } + this.initializeHass(auth, connection); + this._error = undefined; + this._sendStatus(); + } + + private async _handleShowLovelaceMessage(msg: ShowLovelaceViewMessage) { + // We should not get this command before we are connected. + // Means a client got out of sync. Let's send status to them. + if (!this.hass) { + this._sendStatus(msg.senderId!); + this._error = "Cannot show Lovelace because we're not connected."; + return; + } + if (!this._unsubLovelace) { + const llColl = getLovelaceCollection(this.hass!.connection); + // We first do a single refresh because we need to check if there is LL + // configuration. + try { + await llColl.refresh(); + this._unsubLovelace = llColl.subscribe((lovelaceConfig) => + this._handleNewLovelaceConfig(lovelaceConfig) + ); + } catch (err) { + // Generate a Lovelace config. + this._unsubLovelace = () => undefined; + const { + generateLovelaceConfigFromHass, + } = await import("../../../../src/panels/lovelace/common/generate-lovelace-config"); + this._handleNewLovelaceConfig( + await generateLovelaceConfigFromHass(this.hass!) + ); + } + } + this._showDemo = false; + this._lovelacePath = msg.viewPath; + if (castContext.getDeviceCapabilities().touch_input_supported) { + this._breakFree(); + } + this._sendStatus(); + } + + private _handleNewLovelaceConfig(lovelaceConfig: LovelaceConfig) { + castContext.setApplicationState(lovelaceConfig.title!); + this._lovelaceConfig = lovelaceConfig; + if (lovelaceConfig.resources) { + loadLovelaceResources( + lovelaceConfig.resources, + this.hass!.auth.data.hassUrl + ); + } + } + + private _handleShowDemo(_msg: ShowDemoMessage) { + import("./hc-demo").then(() => { + this._showDemo = true; + this._lovelacePath = "overview"; + this._sendStatus(); + if (castContext.getDeviceCapabilities().touch_input_supported) { + this._breakFree(); + } + }); + } + + private _breakFree() { + const controls = document.body.querySelector("touch-controls"); + if (controls) { + controls.remove(); + } + document.body.setAttribute("style", "overflow-y: auto !important"); + } + + private sendMessage(senderId: string, response: any) { + castContext.sendCustomMessage(CAST_NS, senderId, response); + } +} + +declare global { + interface HTMLElementTagNameMap { + "hc-main": HcMain; + } +} diff --git a/cast/src/receiver/second-load.ts b/cast/src/receiver/second-load.ts new file mode 100644 index 0000000000..97d76cdfd1 --- /dev/null +++ b/cast/src/receiver/second-load.ts @@ -0,0 +1,5 @@ +import "web-animations-js/web-animations-next-lite.min"; +import "../../../src/resources/hass-icons"; +import "../../../src/resources/roboto"; +import "../../../src/components/ha-iconset-svg"; +import "./layout/hc-lovelace"; diff --git a/cast/src/receiver/types.ts b/cast/src/receiver/types.ts new file mode 100644 index 0000000000..ef785a2e51 --- /dev/null +++ b/cast/src/receiver/types.ts @@ -0,0 +1,6 @@ +export interface ReceivedMessage { + gj: boolean; + data: T; + senderId: string; + type: "message"; +} diff --git a/demo/src/configs/arsaboo/lovelace.ts b/demo/src/configs/arsaboo/lovelace.ts index 752b665111..ddb95ad98b 100644 --- a/demo/src/configs/arsaboo/lovelace.ts +++ b/demo/src/configs/arsaboo/lovelace.ts @@ -23,6 +23,9 @@ export const demoLovelaceArsaboo: DemoConfig["lovelace"] = (localize) => ({ entity: "switch.wemoporch", }, "light.lifx5", + { + type: "custom:cast-demo-row", + }, ], }, { diff --git a/demo/src/custom-cards/cast-demo-row.ts b/demo/src/custom-cards/cast-demo-row.ts new file mode 100644 index 0000000000..34aebc310f --- /dev/null +++ b/demo/src/custom-cards/cast-demo-row.ts @@ -0,0 +1,108 @@ +import { + html, + LitElement, + TemplateResult, + customElement, + property, + css, + CSSResult, +} from "lit-element"; + +import "../../../src/components/ha-icon"; +import { + EntityRow, + CastConfig, +} from "../../../src/panels/lovelace/entity-rows/types"; +import { HomeAssistant } from "../../../src/types"; +import { CastManager } from "../../../src/cast/cast_manager"; +import { castSendShowDemo } from "../../../src/cast/receiver_messages"; + +@customElement("cast-demo-row") +class CastDemoRow extends LitElement implements EntityRow { + public hass!: HomeAssistant; + + @property() private _castManager?: CastManager | null; + + public setConfig(_config: CastConfig): void { + // No config possible. + } + + protected render(): TemplateResult | void { + if ( + !this._castManager || + this._castManager.castState === "NO_DEVICES_AVAILABLE" + ) { + return html``; + } + return html` + +
+
Show Chromecast interface
+ +
+ `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + import("../../../src/cast/cast_manager").then(({ getCastManager }) => + getCastManager().then((mgr) => { + this._castManager = mgr; + mgr.addEventListener("state-changed", () => { + this.requestUpdate(); + }); + mgr.castContext.addEventListener( + cast.framework.CastContextEventType.SESSION_STATE_CHANGED, + (ev) => { + if (ev.sessionState === "SESSION_STARTED") { + castSendShowDemo(mgr); + } + } + ); + }) + ); + } + + protected updated(changedProps) { + super.updated(changedProps); + this.style.display = this._castManager ? "" : "none"; + } + + static get styles(): CSSResult { + return css` + :host { + display: flex; + align-items: center; + } + ha-icon { + padding: 8px; + color: var(--paper-item-icon-color); + } + .flex { + flex: 1; + overflow: hidden; + margin-left: 16px; + display: flex; + justify-content: space-between; + align-items: center; + } + .name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + google-cast-launcher { + cursor: pointer; + display: inline-block; + height: 24px; + width: 24px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "cast-demo-row": CastDemoRow; + } +} diff --git a/demo/src/stubs/lovelace.ts b/demo/src/stubs/lovelace.ts index 777d9bce86..0170e272f2 100644 --- a/demo/src/stubs/lovelace.ts +++ b/demo/src/stubs/lovelace.ts @@ -1,4 +1,5 @@ import "../custom-cards/ha-demo-card"; +import "../custom-cards/cast-demo-row"; // Not duplicate, one is for typing. // tslint:disable-next-line import { HADemoCard } from "../custom-cards/ha-demo-card"; diff --git a/package.json b/package.json index 97757ab7f4..4873cf5be2 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "version": "1.0.0", "scripts": { "build": "script/build_frontend", - "lint": "eslint src hassio/src gallery/src && tslint 'src/**/*.ts' 'hassio/src/**/*.ts' 'gallery/src/**/*.ts' 'test-mocha/**/*.ts' && polymer lint && tsc", + "lint": "eslint src hassio/src gallery/src && tslint 'src/**/*.ts' 'hassio/src/**/*.ts' 'gallery/src/**/*.ts' 'cast/src/**/*.ts' 'test-mocha/**/*.ts' && polymer lint && tsc", "mocha": "node_modules/.bin/ts-mocha -p test-mocha/tsconfig.test.json --opts test-mocha/mocha.opts", "test": "npm run lint && npm run mocha", "docker_build": "sh ./script/docker_run.sh build $npm_package_version", diff --git a/setup.py b/setup.py index 82544a867c..fc3eb30bb7 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="home-assistant-frontend", - version="20190801.0", + version="20190804.0", description="The Home Assistant frontend", url="https://github.com/home-assistant/home-assistant-polymer", author="The Home Assistant Authors", diff --git a/src/cards/ha-media_player-card.js b/src/cards/ha-media_player-card.js index 131fed1bbf..5bbeef5c6e 100644 --- a/src/cards/ha-media_player-card.js +++ b/src/cards/ha-media_player-card.js @@ -317,6 +317,10 @@ class HaMediaPlayerCard extends LocalizeMixin(EventsMixin(PolymerElement)) { computeBannerClasses(playerObj, coverShowing, coverLoadError) { var cls = "banner"; + if (!playerObj) { + return cls; + } + if (playerObj.isOff || playerObj.isIdle) { cls += " is-off no-cover"; } else if ( diff --git a/src/cast/cast_framework.ts b/src/cast/cast_framework.ts new file mode 100644 index 0000000000..938589ff88 --- /dev/null +++ b/src/cast/cast_framework.ts @@ -0,0 +1,24 @@ +import { loadJS } from "../common/dom/load_resource"; + +let loadedPromise: Promise | undefined; + +export const castApiAvailable = () => { + if (loadedPromise) { + return loadedPromise; + } + + loadedPromise = new Promise((resolve) => { + (window as any).__onGCastApiAvailable = resolve; + }); + // Any element with a specific ID will get set as a JS variable on window + // This will override the cast SDK if the iconset is loaded afterwards. + // Conflicting IDs will no longer mess with window, so we'll just append one. + const el = document.createElement("div"); + el.id = "cast"; + document.body.append(el); + + loadJS( + "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1" + ); + return loadedPromise; +}; diff --git a/src/cast/cast_manager.ts b/src/cast/cast_manager.ts new file mode 100644 index 0000000000..1b9a1d7e66 --- /dev/null +++ b/src/cast/cast_manager.ts @@ -0,0 +1,167 @@ +import { castApiAvailable } from "./cast_framework"; +import { CAST_APP_ID, CAST_NS, CAST_DEV_HASS_URL, CAST_DEV } from "./const"; +import { + castSendAuth, + HassMessage as ReceiverMessage, +} from "./receiver_messages"; +import { + SessionStateEventData, + CastStateEventData, + // tslint:disable-next-line: no-implicit-dependencies +} from "chromecast-caf-receiver/cast.framework"; +import { SenderMessage, ReceiverStatusMessage } from "./sender_messages"; +import { Auth } from "home-assistant-js-websocket"; + +let managerProm: Promise | undefined; + +type CastEventListener = () => void; + +/* +General flow of Chromecast: + +Chromecast sessions are started via the Chromecast button. When clicked, session +state changes to started. We then send authentication, which will cause the +receiver app to send a status update. + +If a session is already active, we query the status to see what it is up to. If +a user presses the cast button we send auth if not connected yet, then send +command as usual. +*/ + +/* tslint:disable:no-console */ + +type CastEvent = "connection-changed" | "state-changed"; + +export class CastManager { + public auth?: Auth; + // If the cast connection is connected to our Hass. + public status?: ReceiverStatusMessage; + private _eventListeners: { [event: string]: CastEventListener[] } = {}; + + constructor(auth?: Auth) { + this.auth = auth; + const context = this.castContext; + context.setOptions({ + receiverApplicationId: CAST_APP_ID, + // @ts-ignore + autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED, + }); + context.addEventListener( + cast.framework.CastContextEventType.SESSION_STATE_CHANGED, + (ev) => this._sessionStateChanged(ev) + ); + context.addEventListener( + cast.framework.CastContextEventType.CAST_STATE_CHANGED, + (ev) => this._castStateChanged(ev) + ); + } + + public addEventListener(event: CastEvent, listener: CastEventListener) { + if (!(event in this._eventListeners)) { + this._eventListeners[event] = []; + } + this._eventListeners[event].push(listener); + + return () => { + this._eventListeners[event].splice( + this._eventListeners[event].indexOf(listener) + ); + }; + } + + public get castConnectedToOurHass(): boolean { + return ( + this.status !== undefined && + this.auth !== undefined && + this.status.connected && + (this.status.hassUrl === this.auth.data.hassUrl || + (CAST_DEV && this.status.hassUrl === CAST_DEV_HASS_URL)) + ); + } + + public sendMessage(msg: ReceiverMessage) { + if (__DEV__) { + console.log("Sending cast message", msg); + } + this.castSession.sendMessage(CAST_NS, msg); + } + + public get castState() { + return this.castContext.getCastState(); + } + + public get castContext() { + return cast.framework.CastContext.getInstance(); + } + + public get castSession() { + return this.castContext.getCurrentSession()!; + } + + public requestSession() { + return this.castContext.requestSession(); + } + + private _fireEvent(event: CastEvent) { + for (const listener of this._eventListeners[event] || []) { + listener(); + } + } + + private _receiveMessage(msg: SenderMessage) { + if (__DEV__) { + console.log("Received cast message", msg); + } + if (msg.type === "receiver_status") { + this.status = msg; + this._fireEvent("connection-changed"); + } + } + + private _sessionStateChanged(ev: SessionStateEventData) { + if (__DEV__) { + console.log("Cast session state changed", ev.sessionState); + } + if (ev.sessionState === "SESSION_RESUMED") { + this.sendMessage({ type: "get_status" }); + this._attachMessageListener(); + } else if (ev.sessionState === "SESSION_STARTED") { + if (this.auth) { + castSendAuth(this, this.auth); + } else { + // Only do if no auth, as this is done as part of sendAuth. + this.sendMessage({ type: "get_status" }); + } + this._attachMessageListener(); + } else if (ev.sessionState === "SESSION_ENDED") { + this.status = undefined; + this._fireEvent("connection-changed"); + } + } + + private _castStateChanged(ev: CastStateEventData) { + if (__DEV__) { + console.log("Cast state changed", ev.castState); + } + this._fireEvent("state-changed"); + } + + private _attachMessageListener() { + const session = this.castSession; + session.addMessageListener(CAST_NS, (_ns, msg) => + this._receiveMessage(JSON.parse(msg)) + ); + } +} + +export const getCastManager = (auth?: Auth) => { + if (!managerProm) { + managerProm = castApiAvailable().then((isAvailable) => { + if (!isAvailable) { + throw new Error("No Cast API available"); + } + return new CastManager(auth); + }); + } + return managerProm; +}; diff --git a/src/cast/const.ts b/src/cast/const.ts new file mode 100644 index 0000000000..784c159a72 --- /dev/null +++ b/src/cast/const.ts @@ -0,0 +1,11 @@ +// Guard dev mode with `__dev__` so it can only ever be enabled in dev mode. +export const CAST_DEV = __DEV__ && true; +// Replace this with your own unpublished cast app that points at your local dev +const CAST_DEV_APP_ID = "5FE44367"; +export const CAST_APP_ID = CAST_DEV ? CAST_DEV_APP_ID : "B12CE3CA"; +export const CAST_NS = "urn:x-cast:com.nabucasa.hast"; + +// Chromecast SDK will only load on localhost and HTTPS +// So during local development we have to send our dev IP address, +// but then run the UI on localhost. +export const CAST_DEV_HASS_URL = "http://192.168.1.234:8123"; diff --git a/src/cast/receiver_messages.ts b/src/cast/receiver_messages.ts new file mode 100644 index 0000000000..d491c593e2 --- /dev/null +++ b/src/cast/receiver_messages.ts @@ -0,0 +1,73 @@ +// Nessages to be processed inside the Cast Receiver app + +import { CastManager } from "./cast_manager"; + +import { BaseCastMessage } from "./types"; +import { CAST_DEV_HASS_URL, CAST_DEV } from "./const"; +import { Auth } from "home-assistant-js-websocket"; + +export interface GetStatusMessage extends BaseCastMessage { + type: "get_status"; +} + +export interface ConnectMessage extends BaseCastMessage { + type: "connect"; + refreshToken: string; + clientId: string; + hassUrl: string; +} + +export interface ShowLovelaceViewMessage extends BaseCastMessage { + type: "show_lovelace_view"; + viewPath: string | null; +} + +export interface ShowDemoMessage extends BaseCastMessage { + type: "show_demo"; +} + +export type HassMessage = + | ShowDemoMessage + | GetStatusMessage + | ConnectMessage + | ShowLovelaceViewMessage; + +export const castSendAuth = (cast: CastManager, auth: Auth) => + cast.sendMessage({ + type: "connect", + refreshToken: auth.data.refresh_token, + clientId: auth.data.clientId, + hassUrl: CAST_DEV ? CAST_DEV_HASS_URL : auth.data.hassUrl, + }); + +export const castSendShowLovelaceView = ( + cast: CastManager, + viewPath: ShowLovelaceViewMessage["viewPath"] +) => + cast.sendMessage({ + type: "show_lovelace_view", + viewPath, + }); + +export const castSendShowDemo = (cast: CastManager) => + cast.sendMessage({ + type: "show_demo", + }); + +export const ensureConnectedCastSession = (cast: CastManager, auth: Auth) => { + if (cast.castConnectedToOurHass) { + return; + } + + return new Promise((resolve) => { + const unsub = cast.addEventListener("connection-changed", () => { + if (cast.castConnectedToOurHass) { + unsub(); + resolve(); + return; + } + }); + + castSendAuth(cast, auth); + }); +}; diff --git a/src/cast/sender_messages.ts b/src/cast/sender_messages.ts new file mode 100644 index 0000000000..83e85b80bc --- /dev/null +++ b/src/cast/sender_messages.ts @@ -0,0 +1,13 @@ +import { BaseCastMessage } from "./types"; + +// Messages to be processed inside the Home Assistant UI + +export interface ReceiverStatusMessage extends BaseCastMessage { + type: "receiver_status"; + connected: boolean; + showDemo: boolean; + hassUrl?: string; + lovelacePath?: string | null; +} + +export type SenderMessage = ReceiverStatusMessage; diff --git a/src/cast/types.ts b/src/cast/types.ts new file mode 100644 index 0000000000..95695fcad6 --- /dev/null +++ b/src/cast/types.ts @@ -0,0 +1,4 @@ +export interface BaseCastMessage { + type: string; + senderId?: string; +} diff --git a/src/data/zwave.ts b/src/data/zwave.ts index f41567edbc..77740c8051 100644 --- a/src/data/zwave.ts +++ b/src/data/zwave.ts @@ -5,10 +5,13 @@ export interface ZWaveNetworkStatus { } export interface ZWaveValue { - index: number; - instance: number; - label: string; - poll_intensity: number; + key: number; + value: { + index: number; + instance: number; + label: string; + poll_intensity: number; + }; } export interface ZWaveConfigItem { diff --git a/src/panels/config/zwave/zwave-values.ts b/src/panels/config/zwave/zwave-values.ts index 2beda32122..8428b28c0b 100644 --- a/src/panels/config/zwave/zwave-values.ts +++ b/src/panels/config/zwave/zwave-values.ts @@ -23,7 +23,7 @@ import { ZWaveValue } from "../../../data/zwave"; @customElement("zwave-values") export class ZwaveValues extends LitElement { @property() public hass!: HomeAssistant; - @property() private _values: ZWaveValue[] = []; + @property() public values: ZWaveValue[] = []; @property() private _selectedValue: number = -1; protected render(): TemplateResult | void { @@ -34,7 +34,7 @@ export class ZwaveValues extends LitElement { >
@@ -42,19 +42,11 @@ export class ZwaveValues extends LitElement { slot="dropdown-content" .selected=${this._selectedValue} > - ${this._values.map( + ${this.values.map( (item) => html` - ${item.label} - (${this.hass.localize( - "ui.panel.config.zwave.common.instance" - )}: - ${item.instance}, - ${this.hass.localize( - "ui.panel.config.zwave.common.index" - )}: - ${item.index}) + + ${this._computeCaption(item)} + ` )} @@ -110,6 +102,15 @@ export class ZwaveValues extends LitElement { `, ]; } + + private _computeCaption(item) { + let out = `${item.value.label}`; + out += ` (${this.hass.localize("ui.panel.config.zwave.common.instance")}:`; + out += ` ${item.value.instance},`; + out += ` ${this.hass.localize("ui.panel.config.zwave.common.index")}:`; + out += ` ${item.value.index})`; + return out; + } } declare global { diff --git a/src/panels/lovelace/common/create-row-element.ts b/src/panels/lovelace/common/create-row-element.ts index f4ee67a922..f41b4c9c8b 100644 --- a/src/panels/lovelace/common/create-row-element.ts +++ b/src/panels/lovelace/common/create-row-element.ts @@ -26,6 +26,7 @@ import "../special-rows/hui-call-service-row"; import "../special-rows/hui-divider-row"; import "../special-rows/hui-section-row"; import "../special-rows/hui-weblink-row"; +import "../special-rows/hui-cast-row"; import { EntityConfig, EntityRow } from "../entity-rows/types"; const CUSTOM_TYPE_PREFIX = "custom:"; @@ -34,6 +35,7 @@ const SPECIAL_TYPES = new Set([ "divider", "section", "weblink", + "cast", ]); const DOMAIN_TO_ELEMENT_TYPE = { alert: "toggle", diff --git a/src/panels/lovelace/entity-rows/types.ts b/src/panels/lovelace/entity-rows/types.ts index 35b733e330..eada7193b5 100644 --- a/src/panels/lovelace/entity-rows/types.ts +++ b/src/panels/lovelace/entity-rows/types.ts @@ -26,12 +26,21 @@ export interface CallServiceConfig extends EntityConfig { service: string; service_data?: { [key: string]: any }; } +export interface CastConfig { + type: "cast"; + icon: string; + name: string; + view: string; + // Hide the row if either unsupported browser or no API available. + hide_if_unavailable: boolean; +} export type EntityRowConfig = | EntityConfig | DividerConfig | SectionConfig | WeblinkConfig - | CallServiceConfig; + | CallServiceConfig + | CastConfig; export interface EntityRow extends HTMLElement { hass?: HomeAssistant; diff --git a/src/panels/lovelace/special-rows/hui-cast-row.ts b/src/panels/lovelace/special-rows/hui-cast-row.ts new file mode 100644 index 0000000000..02034f8bc3 --- /dev/null +++ b/src/panels/lovelace/special-rows/hui-cast-row.ts @@ -0,0 +1,160 @@ +import { + html, + LitElement, + TemplateResult, + customElement, + property, + css, + CSSResult, +} from "lit-element"; + +import { EntityRow, CastConfig } from "../entity-rows/types"; +import { HomeAssistant } from "../../../types"; + +import "../../../components/ha-icon"; +import { CastManager } from "../../../cast/cast_manager"; +import { + ensureConnectedCastSession, + castSendShowLovelaceView, +} from "../../../cast/receiver_messages"; + +@customElement("hui-cast-row") +class HuiCastRow extends LitElement implements EntityRow { + public hass!: HomeAssistant; + + @property() private _config?: CastConfig; + @property() private _castManager?: CastManager | null; + @property() private _noHTTPS = false; + + public setConfig(config: CastConfig): void { + if (!config || !config.view) { + throw new Error("Invalid Configuration: 'view' required"); + } + + this._config = { + icon: "hass:television", + name: "Home Assistant Cast", + ...config, + }; + } + + protected render(): TemplateResult | void { + if (!this._config) { + return html``; + } + + return html` + +
+
${this._config.name}
+ ${this._noHTTPS + ? html` + Cast requires HTTPS + ` + : this._castManager === undefined + ? html`` + : this._castManager === null + ? html` + Cast API unavailable + ` + : this._castManager.castState === "NO_DEVICES_AVAILABLE" + ? html` + No devices found + ` + : html` +
+ + + SHOW + +
+ `} +
+ `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + if (location.protocol === "http:" && location.hostname !== "localhost") { + this._noHTTPS = true; + } + import("../../../cast/cast_manager").then(({ getCastManager }) => + getCastManager(this.hass.auth).then( + (mgr) => { + this._castManager = mgr; + mgr.addEventListener("connection-changed", () => { + this.requestUpdate(); + }); + mgr.addEventListener("state-changed", () => { + this.requestUpdate(); + }); + }, + () => { + this._castManager = null; + } + ) + ); + } + + protected updated(changedProps) { + super.updated(changedProps); + if (this._config && this._config.hide_if_unavailable) { + this.style.display = + !this._castManager || + this._castManager.castState === "NO_DEVICES_AVAILABLE" + ? "none" + : ""; + } + } + + private async _sendLovelace() { + await ensureConnectedCastSession(this._castManager!, this.hass.auth); + castSendShowLovelaceView(this._castManager!, this._config!.view); + } + + static get styles(): CSSResult { + return css` + :host { + display: flex; + align-items: center; + } + ha-icon { + padding: 8px; + color: var(--paper-item-icon-color); + } + .flex { + flex: 1; + overflow: hidden; + margin-left: 16px; + display: flex; + justify-content: space-between; + align-items: center; + } + .name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .controls { + margin-right: -0.57em; + display: flex; + align-items: center; + } + google-cast-launcher { + cursor: pointer; + display: inline-block; + height: 24px; + width: 24px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-cast-row": HuiCastRow; + } +} diff --git a/translations/ca.json b/translations/ca.json index 012f98acc7..28586eaf4f 100644 --- a/translations/ca.json +++ b/translations/ca.json @@ -620,10 +620,22 @@ "common": { "value": "Valor", "instance": "Instància", - "index": "Índex" + "index": "Índex", + "unknown": "desconegut", + "wakeup_interval": "Interval en despertar" }, "values": { "header": "Valors dels node" + }, + "node_config": { + "header": "Opcions de configuració del node", + "seconds": "segons", + "set_wakeup": "Estableix l’interval en despertar", + "config_parameter": "Paràmetre de configuració", + "config_value": "Valor de configuració", + "true": "Cert", + "false": "Fals", + "set_config_parameter": "Defineix el paràmetre de configuració" } }, "users": { diff --git a/translations/cy.json b/translations/cy.json index 2390801659..a0dc27319f 100644 --- a/translations/cy.json +++ b/translations/cy.json @@ -687,6 +687,30 @@ "password": "Cyfrinair", "create": "Creu" } + }, + "server_control": { + "section": { + "validation": { + "introduction": "Dilyswch eich ffurfweddiad os gwnaethoch rai newidiadau ffurfweddu yn ddiweddar ac rydych eisiau gwneud yn siŵr fod o'n ddilys", + "check_config": "Gwirio config", + "valid": "Ffurfweddiad dilys!", + "invalid": "Ffurfweddiad annilys" + }, + "reloading": { + "heading": "Ffurfweddiad yn ail-lwytho", + "introduction": "Gall rhai rhannau o Home Assistant ail-lwytho heb orfod ailgychwyn. Bydd taro ail-lwytho yn dadlwytho eu cyfluniad cyfredol a llwytho'r un newydd.", + "core": "Ail-lwytho craidd", + "group": "Ail-lwytho grwpiau", + "automation": "Ail-lwytho awtomeiddiadau", + "script": "Ail-lwytho sgriptiau" + }, + "server_management": { + "heading": "Rheoli gweinydd", + "introduction": "Rheoli eich gweinydd Home Assistant.. o Home Assistant.", + "restart": "Ailgychwyn", + "stop": "Stopio" + } + } } }, "lovelace": { diff --git a/translations/en.json b/translations/en.json index 1bed1c80d0..45992fca20 100644 --- a/translations/en.json +++ b/translations/en.json @@ -620,10 +620,22 @@ "common": { "value": "Value", "instance": "Instance", - "index": "Index" + "index": "Index", + "unknown": "unknown", + "wakeup_interval": "Wakeup Interval" }, "values": { "header": "Node Values" + }, + "node_config": { + "header": "Node Config Options", + "seconds": "seconds", + "set_wakeup": "Set Wakeup Interval", + "config_parameter": "Config Parameter", + "config_value": "Config Value", + "true": "True", + "false": "False", + "set_config_parameter": "Set Config Parameter" } }, "users": { diff --git a/translations/fi.json b/translations/fi.json index 87377a685b..600a72583a 100644 --- a/translations/fi.json +++ b/translations/fi.json @@ -320,7 +320,7 @@ "title": "Tapahtumat" }, "templates": { - "title": "Mallit" + "title": "Malli" }, "mqtt": { "title": "MQTT" @@ -346,10 +346,10 @@ "introduction": "Täällä voit säätää Home Assistanttia ja sen komponentteja. Huomioithan, ettei kaikkea voi vielä säätää käyttöliittymän kautta, mutta teemme jatkuvasti töitä sen mahdollistamiseksi.", "core": { "caption": "Yleinen", - "description": "Tarkista asetustiedostosi ja hallitse palvelinta", + "description": "Muuta Home Assistantin yleisiä asetuksiasi", "section": { "core": { - "header": "Asetusten ja palvelimen hallinta", + "header": "Yleiset asetukset", "introduction": "Tiedämme, että asetusten muuttaminen saattaa olla työlästä. Täältä löydät työkaluja, jotka toivottavasti helpottavat elämääsi.", "core_config": { "edit_requires_storage": "Editori on poistettu käytöstä, koska asetuksia on annettu configuration.yaml:ssa.", diff --git a/translations/hu.json b/translations/hu.json index a573efd697..31b8ad79d8 100644 --- a/translations/hu.json +++ b/translations/hu.json @@ -617,10 +617,21 @@ "common": { "value": "Érték", "instance": "Példány", - "index": "Index" + "index": "Index", + "unknown": "Ismeretlen", + "wakeup_interval": "Ébresztési időköz" }, "values": { "header": "Csomópont értékek" + }, + "node_config": { + "seconds": "másodperc", + "set_wakeup": "Ébresztési időköz beállítása", + "config_parameter": "Konfigurációs paraméter", + "config_value": "Konfigurációs érték", + "true": "Igaz", + "false": "Hamis", + "set_config_parameter": "Konfigurációs paraméter beállítása" } }, "users": { @@ -744,6 +755,33 @@ "device_tracker_picked": "Eszköz követése", "device_tracker_pick": "Válassz egy követni kívánt eszközt" } + }, + "server_control": { + "caption": "Szerver vezérlés", + "description": "A Home Assistant szerver újraindítása és leállítása", + "section": { + "validation": { + "heading": "Konfiguráció érvényesítés", + "introduction": "Érvényesítsd a konfigurációt, ha nemrégiben módosítottad azt, és meg szeretnél bizonyosodni róla, hogy minden érvényes", + "check_config": "Konfiguráció ellenőrzése", + "valid": "Érvényes konfiguráció!", + "invalid": "Érvénytelen konfiguráció" + }, + "reloading": { + "heading": "Konfiguráció újratöltés", + "introduction": "A Home Assistant bizonyos részei újraindítás nélkül újratölthetőek. Az újratöltés az aktuális konfiguráció helyére betölti az újat.", + "core": "Mag újratöltése", + "group": "Csoportok újratöltése", + "automation": "Automatizálások újratöltése", + "script": "Szkriptek újratöltése" + }, + "server_management": { + "heading": "Szerver menedzsment", + "introduction": "Home Assistant szerver vezérlése... a Home Assistant-ból.", + "restart": "Újraindítás", + "stop": "Leállítás" + } + } } }, "profile": { diff --git a/translations/is.json b/translations/is.json index 3bc6c8e374..d083116b93 100644 --- a/translations/is.json +++ b/translations/is.json @@ -560,6 +560,9 @@ "soft_reset": "Mjúk endurstilling", "save_config": "Vista stillingar", "cancel_command": "Hætta við skipun" + }, + "node_config": { + "seconds": "sekúndur" } }, "users": { @@ -681,6 +684,27 @@ "device_tracker_picked": "Rekja tæki", "device_tracker_pick": "Velja tæki til að rekja" } + }, + "server_control": { + "description": "Endurræsa og stöðva Home Assistant þjóni", + "section": { + "validation": { + "heading": "Staðfesta stillingar", + "check_config": "Athuga stillingar", + "valid": "Stillingar í lagi!", + "invalid": "Stillingar ógildar" + }, + "reloading": { + "heading": "Endurhleðsla stillinga", + "core": "Endurhlaða kjarna", + "group": "Endurhlaða hópa", + "automation": "Endurhlaða sjálfvirkni" + }, + "server_management": { + "restart": "Endurræsa", + "stop": "Stöðva" + } + } } }, "profile": { diff --git a/translations/ko.json b/translations/ko.json index 7dbb033404..e2b729e13a 100644 --- a/translations/ko.json +++ b/translations/ko.json @@ -349,10 +349,10 @@ "introduction": "여기에서 구성요소와 Home Assistant 를 설정 할 수 있습니다. 아직 여기서 모두 설정 할 수는 없지만, 모든 내용을 설정 할 수 있도록 작업 중입니다.", "core": { "caption": "일반", - "description": "구성 내용 파일의 유효성을 검사하고 서버를 제어합니다", + "description": "Home Assistant 일반 구성 내용을 편집합니다", "section": { "core": { - "header": "구성 내용 설정 및 서버 제어", + "header": "일반 구성", "introduction": "구성 내용의 설정을 변경하는 것은 때때로 난해하고 귀찮은 작업입니다. 여기서 설정 변경을 좀 더 쉽게 하실 수 있습니다.", "core_config": { "edit_requires_storage": "구성내용이 configuration.yaml 에 저장되어 있기 때문에 편집기가 비활성화 되었습니다.", @@ -373,14 +373,14 @@ "server_control": { "validation": { "heading": "구성 내용 유효성 검사", - "introduction": "최근에 구성 내용을 추가 혹은 변경하셨다면, 설정 확인 버튼을 눌러 구성 내용이 올바른지 검사하고 Home Assistant 가 정상 작동 되는지 확인하실 수 있습니다.", - "check_config": "설정 확인", + "introduction": "최근에 구성 내용을 추가 혹은 변경하셨다면, 구성 내용 확인 버튼을 눌러 구성 내용이 올바른지 검사하고 Home Assistant 가 정상 작동 되는지 확인하실 수 있습니다.", + "check_config": "구성 내용 확인", "valid": "구성 내용이 모두 올바릅니다!", "invalid": "구성 내용이 잘못되었습니다" }, "reloading": { "heading": "구성 내용 새로고침", - "introduction": "Home Assistant 의 일부 구성 내용은 재시작 없이 다시 읽어들일 수 있습니다. 새로고침을 누르면 현재 구성 내용을 내리고 새로운 구성 내용을 읽어들입니다", + "introduction": "Home Assistant 의 일부 구성 내용은 재시작 없이 다시 읽어들일 수 있습니다. 새로고침을 누르면 현재 구성 내용을 내리고 새로운 구성 내용을 읽어들입니다.", "core": "코어 새로고침", "group": "그룹 새로고침", "automation": "자동화 새로고침", @@ -388,7 +388,7 @@ }, "server_management": { "heading": "서버 관리", - "introduction": "Home Assistant 서버를 제어합니다", + "introduction": "Home Assistant 서버를 제어합니다.", "restart": "재시작", "stop": "중지" } @@ -620,10 +620,22 @@ "common": { "value": "값", "instance": "인스턴스", - "index": "색인" + "index": "색인", + "unknown": "알 수 없슴", + "wakeup_interval": "절전 모드 해제 간격" }, "values": { "header": "노드 값" + }, + "node_config": { + "header": "노드 구성 옵션", + "seconds": "초", + "set_wakeup": "절전 모드 해제 간격 설정", + "config_parameter": "구성 파라메터", + "config_value": "구성 값", + "true": "참", + "false": "거짓", + "set_config_parameter": "구성 파라메터 설정" } }, "users": { @@ -747,6 +759,33 @@ "device_tracker_picked": "장치 추적 대상", "device_tracker_pick": "추적 할 장치 선택" } + }, + "server_control": { + "caption": "서버 제어", + "description": "Home Assistant 서버를 재시작 또는 중지합니다", + "section": { + "validation": { + "heading": "구성 내용 유효성 검사", + "introduction": "최근에 구성 내용을 추가 혹은 변경하셨다면, 구성 내용 확인 버튼을 눌러 구성 내용이 올바른지 검사하고 Home Assistant 가 정상 작동 되는지 확인하실 수 있습니다.", + "check_config": "구성 내용 확인", + "valid": "구성 내용이 모두 올바릅니다!", + "invalid": "구성 내용이 잘못되었습니다" + }, + "reloading": { + "heading": "구성 내용 새로고침", + "introduction": "Home Assistant 의 일부 구성 내용은 재시작 없이 다시 읽어들일 수 있습니다. 새로고침을 누르면 현재 구성 내용을 내리고 새로운 구성 내용을 읽어들입니다.", + "core": "코어 새로고침", + "group": "그룹 새로고침", + "automation": "자동화 새로고침", + "script": "스크립트 새로 고침" + }, + "server_management": { + "heading": "서버 관리", + "introduction": "Home Assistant 서버를 제어합니다.", + "restart": "재시작", + "stop": "중지" + } + } } }, "profile": { @@ -1230,8 +1269,8 @@ } }, "notification_toast": { - "entity_turned_on": " {entity}이(가) 켜졌습니다.", - "entity_turned_off": " {entity}이(가) 꺼졌습니다.", + "entity_turned_on": "{entity} 이(가) 켜졌습니다.", + "entity_turned_off": "{entity} 이(가) 꺼졌습니다.", "service_called": "{service} 서비스가 호출되었습니다.", "service_call_failed": "{service} 서비스를 호출하지 못했습니다.", "connection_lost": "서버와 연결이 끊어졌습니다. 다시 연결 중..." @@ -1262,7 +1301,7 @@ "confirm": "로그인 저장하기" }, "notification_drawer": { - "click_to_configure": "버튼을 클릭하여 구성하세요 {entity}", + "click_to_configure": "버튼을 클릭하여 {entity} 을(를) 구성", "empty": "알림 없음", "title": "알림" } diff --git a/translations/nb.json b/translations/nb.json index 3d980a3429..f340e2e136 100644 --- a/translations/nb.json +++ b/translations/nb.json @@ -620,10 +620,22 @@ "common": { "value": "Verdi", "instance": "Forekomst", - "index": "Indeks" + "index": "Indeks", + "unknown": "ukjent", + "wakeup_interval": "Våkningsintervall" }, "values": { "header": "Nodeverdier" + }, + "node_config": { + "header": "Alternativer for nodekonfigurasjon", + "seconds": "sekunder", + "set_wakeup": "Angi vekkeintervall", + "config_parameter": "Konfigurasjon parameter", + "config_value": "Konfigurasjon verdi", + "true": "Ekte", + "false": "Falsk", + "set_config_parameter": "Angi konfigurasjons parameter" } }, "users": { diff --git a/translations/pl.json b/translations/pl.json index 27584037d9..e42ccbdc99 100644 --- a/translations/pl.json +++ b/translations/pl.json @@ -620,10 +620,22 @@ "common": { "value": "Wartość", "instance": "Instancja", - "index": "Indeks" + "index": "Indeks", + "unknown": "nieznany", + "wakeup_interval": "Interwał wybudzenia" }, "values": { "header": "Wartości węzła" + }, + "node_config": { + "header": "Opcje konfiguracji węzła", + "seconds": "sekund", + "set_wakeup": "Ustaw interwał wybudzenia", + "config_parameter": "Parametr", + "config_value": "Wartość", + "true": "Prawda", + "false": "Fałsz", + "set_config_parameter": "Ustaw parametr" } }, "users": { diff --git a/translations/ru.json b/translations/ru.json index 74afefcbd1..cb75cd3af3 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -620,10 +620,22 @@ "common": { "value": "Значение", "instance": "Экземпляр", - "index": "Индекс" + "index": "Индекс", + "unknown": "неизвестно", + "wakeup_interval": "Интервал пробуждения" }, "values": { "header": "Значения узлов" + }, + "node_config": { + "header": "Параметры конфигурации узла", + "seconds": "секунд", + "set_wakeup": "Установить интервал пробуждения", + "config_parameter": "Параметр конфигурации", + "config_value": "Значение конфигурации", + "true": "Истина", + "false": "Ложь", + "set_config_parameter": "Установить параметр конфигурации" } }, "users": { @@ -727,7 +739,7 @@ "picker": { "header": "Управление объектами", "unavailable": "(недоступен)", - "introduction": "Home Assistant ведет реестр каждого объекта, который когда-либо был настроен в системе. Каждому из этих объектов присвоен ID, который зарезервирован только для этого объекта.", + "introduction": "Home Assistant ведет реестр каждого объекта, который когда-либо был создан в системе. Каждому из этих объектов присвоен ID, который зарезервирован только для этого объекта.", "introduction2": "Используйте данный реестр, чтобы изменить ID или название объекта либо удалить запись из Home Assistant. Обратите внимание, что удаление записи из реестра объектов не удалит сам объект. Для этого перейдите по указанной ниже ссылке и удалите его со страницы интеграций.", "integrations_page": "Страница интеграций" }, diff --git a/translations/sv.json b/translations/sv.json index 3912929292..fee76ff512 100644 --- a/translations/sv.json +++ b/translations/sv.json @@ -608,6 +608,11 @@ "value": "Värde", "instance": "Instans", "index": "Index" + }, + "node_config": { + "seconds": "Sekunder", + "true": "Sant", + "false": "Falskt" } }, "users": { @@ -731,6 +736,18 @@ "device_tracker_picked": "Spåra enheten", "device_tracker_pick": "Välj enhet att spåra" } + }, + "server_control": { + "section": { + "reloading": { + "introduction": "Vissa delar av Home Assistant kan laddas om utan att en omstart krävs. Att trycka på \"ladda om\" innebär att den nuvarande konfiguration inaktiveras och den nya laddas." + }, + "server_management": { + "introduction": "Kontrollera din Home Assistant-server ... från Home Assistant.", + "restart": "Starta om", + "stop": "Stoppa" + } + } } }, "profile": { diff --git a/translations/tr.json b/translations/tr.json index 3f94136b88..5ad6e2477f 100644 --- a/translations/tr.json +++ b/translations/tr.json @@ -461,7 +461,16 @@ }, "zwave": { "caption": "Z-Wave", - "description": "Z-Wave ağınızı yönetin" + "description": "Z-Wave ağınızı yönetin", + "node_config": { + "seconds": "Saniye", + "set_wakeup": "Uyandırma Aralığı Ayarla", + "config_parameter": "Yapılandırma Parametresi", + "config_value": "Yapılandırma Değeri", + "true": "Doğru", + "false": "Yanlış", + "set_config_parameter": "Yapılandırma parametresini belirle" + } }, "users": { "caption": "Kullanıcılar", @@ -504,6 +513,13 @@ "zha": { "caption": "ZHA", "description": "Zigbee Ev Otomasyonu ağ yönetimi" + }, + "server_control": { + "section": { + "server_management": { + "restart": "Yeniden başlat" + } + } } }, "profile": { diff --git a/translations/vi.json b/translations/vi.json index dd1ec8630a..c96c925a28 100644 --- a/translations/vi.json +++ b/translations/vi.json @@ -593,7 +593,9 @@ "caption": "Z-Wave", "description": "Quản lý mạng Z-Wave", "services": { - "save_config": "Lưu cấu hình" + "soft_reset": "Khởi động lại", + "save_config": "Lưu cấu hình", + "cancel_command": "Hủy lệnh" }, "common": { "value": "Giá trị", @@ -708,6 +710,24 @@ "device_tracker_picked": "Thiết bị theo dõi", "device_tracker_pick": "Chọn thiết bị để theo dõi" } + }, + "server_control": { + "section": { + "validation": { + "heading": "Xác nhận cấu hình", + "introduction": "Xác thực cấu hình của bạn nếu gần đây bạn đã thực hiện một số thay đổi đối với cấu hình của bạn và muốn đảm bảo rằng nó là hợp lệ", + "check_config": "Kiểm tra cấu hình", + "valid": "Cấu hình hợp lệ!", + "invalid": "Cấu hình không hợp lệ" + }, + "reloading": { + "heading": "Nạp lại Cấu hình ", + "introduction": "Một số phần của Home Assistant có thể tải lại mà không yêu cầu khởi động lại. Nhấn nút tải lại sẽ gỡ bỏ cấu hình hiện tại của nó và tải một cấu hình mới.", + "core": "Tải lại lõi", + "group": "Tải lại nhóm", + "automation": "Tải lại Tự động hóa" + } + } } }, "profile": { diff --git a/translations/zh-Hant.json b/translations/zh-Hant.json index a31df9528d..24760eac6b 100644 --- a/translations/zh-Hant.json +++ b/translations/zh-Hant.json @@ -620,10 +620,22 @@ "common": { "value": "數值", "instance": "實例", - "index": "指數" + "index": "指數", + "unknown": "未知", + "wakeup_interval": "喚醒間隔" }, "values": { "header": "節點數值" + }, + "node_config": { + "header": "節點設定選項", + "seconds": "秒", + "set_wakeup": "設定喚醒間隔", + "config_parameter": "設定參數", + "config_value": "設定值", + "true": "True", + "false": "False", + "set_config_parameter": "設定參數" } }, "users": {