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/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/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; + } +}