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') %>
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+ Home Assistant Cast requires a Home Assistant installation that is
+ accessible via HTTPS (the url starts with "https://").
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+ 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 .
+
+
+
+
+
+
+ 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:
+
+
+ Render Lovelace views, including custom cards
+
+ Real-time data stream will ensure the UI always shows the latest
+ state of your house
+
+ Navigate between views using navigate actions or weblinks
+
+ Instant updates of the casted Lovelace UI when you update your
+ Lovelace configuration.
+
+
+
Things that currently do not work:
+
+
+ Live videostreams using the streaming integration
+
+ Specifying a view with a single card with "panel: true".
+
+
+
+
+
+
+ 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".
+
+
+
+
+
+
+ Chromecast is a technology developed by Google, and is available on:
+
+
+
+
+
+
+
+ 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`
+
+
+ ${(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}.
+
+
+
+ `;
+ }
+
+ 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`
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ 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;
+ }
+}