Home Assistant Cast
parent
0544027c38
commit
2da844a1fb
|
@ -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"
|
||||||
|
)
|
||||||
|
);
|
|
@ -15,3 +15,9 @@ gulp.task(
|
||||||
return del([config.demo_root, config.build_dir]);
|
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]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
|
@ -14,6 +14,9 @@ const templatePath = (tpl) =>
|
||||||
const demoTemplatePath = (tpl) =>
|
const demoTemplatePath = (tpl) =>
|
||||||
path.resolve(config.demo_dir, "src/html/", `${tpl}.html.template`);
|
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 readFile = (pth) => fs.readFileSync(pth).toString();
|
||||||
|
|
||||||
const renderTemplate = (pth, data = {}, pathFunc = templatePath) => {
|
const renderTemplate = (pth, data = {}, pathFunc = templatePath) => {
|
||||||
|
@ -24,6 +27,9 @@ const renderTemplate = (pth, data = {}, pathFunc = templatePath) => {
|
||||||
const renderDemoTemplate = (pth, data = {}) =>
|
const renderDemoTemplate = (pth, data = {}) =>
|
||||||
renderTemplate(pth, data, demoTemplatePath);
|
renderTemplate(pth, data, demoTemplatePath);
|
||||||
|
|
||||||
|
const renderCastTemplate = (pth, data = {}) =>
|
||||||
|
renderTemplate(pth, data, castTemplatePath);
|
||||||
|
|
||||||
const minifyHtml = (content) =>
|
const minifyHtml = (content) =>
|
||||||
minify(content, {
|
minify(content, {
|
||||||
collapseWhitespace: true,
|
collapseWhitespace: true,
|
||||||
|
@ -113,17 +119,64 @@ gulp.task("gen-index-app-prod", (done) => {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
gulp.task("gen-index-demo-dev", (done) => {
|
gulp.task("gen-index-cast-dev", (done) => {
|
||||||
// In dev mode we don't mangle names, so we hardcode urls. That way we can
|
const contentReceiver = renderCastTemplate("receiver", {
|
||||||
// run webpack as last in watch mode, which blocks output.
|
latestReceiverJS: "/frontend_latest/receiver.js",
|
||||||
const content = renderDemoTemplate("index", {
|
});
|
||||||
latestDemoJS: "/frontend_latest/main.js",
|
fs.outputFileSync(
|
||||||
|
path.resolve(config.cast_root, "receiver.html"),
|
||||||
|
contentReceiver
|
||||||
|
);
|
||||||
|
|
||||||
es5Compatibility: "/frontend_es5/compatibility.js",
|
const contentFAQ = renderCastTemplate("launcher-faq", {
|
||||||
es5DemoJS: "/frontend_es5/main.js",
|
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
fs.outputFileSync(path.resolve(config.demo_root, "index.html"), content);
|
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();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -114,3 +114,15 @@ gulp.task("copy-static-demo", (done) => {
|
||||||
copyTranslations(paths.demo_static);
|
copyTranslations(paths.demo_static);
|
||||||
done();
|
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();
|
||||||
|
});
|
||||||
|
|
|
@ -5,7 +5,11 @@ const webpack = require("webpack");
|
||||||
const WebpackDevServer = require("webpack-dev-server");
|
const WebpackDevServer = require("webpack-dev-server");
|
||||||
const log = require("fancy-log");
|
const log = require("fancy-log");
|
||||||
const paths = require("../paths");
|
const paths = require("../paths");
|
||||||
const { createAppConfig, createDemoConfig } = require("../webpack");
|
const {
|
||||||
|
createAppConfig,
|
||||||
|
createDemoConfig,
|
||||||
|
createCastConfig,
|
||||||
|
} = require("../webpack");
|
||||||
|
|
||||||
const handler = (done) => (err, stats) => {
|
const handler = (done) => (err, stats) => {
|
||||||
if (err) {
|
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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
|
@ -14,4 +14,10 @@ module.exports = {
|
||||||
demo_static: path.resolve(__dirname, "../demo/dist/static"),
|
demo_static: path.resolve(__dirname, "../demo/dist/static"),
|
||||||
demo_output: path.resolve(__dirname, "../demo/dist/frontend_latest"),
|
demo_output: path.resolve(__dirname, "../demo/dist/frontend_latest"),
|
||||||
demo_output_es5: path.resolve(__dirname, "../demo/dist/frontend_es5"),
|
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"),
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 = {
|
module.exports = {
|
||||||
resolve,
|
resolve,
|
||||||
plugins,
|
plugins,
|
||||||
optimization,
|
optimization,
|
||||||
createAppConfig,
|
createAppConfig,
|
||||||
createDemoConfig,
|
createDemoConfig,
|
||||||
|
createCastConfig,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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`).
|
Binary file not shown.
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
|
@ -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"
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
self.addEventListener("fetch", function(event) {
|
||||||
|
event.respondWith(fetch(event.request));
|
||||||
|
});
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Build the cast receiver
|
||||||
|
|
||||||
|
# Stop on errors
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
./node_modules/.bin/gulp build-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
|
|
@ -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
|
|
@ -0,0 +1,226 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Home Assistant Cast - FAQ</title>
|
||||||
|
<link rel="icon" href="/images/ha-cast-icon.png" type="image/png" />
|
||||||
|
<%= renderTemplate('_style_base') %>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #e5e5e5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<meta property="fb:app_id" content="338291289691179" />
|
||||||
|
<meta property="og:title" content="FAQ - Home Assistant Cast" />
|
||||||
|
<meta property="og:site_name" content="Home Assistant Cast" />
|
||||||
|
<meta property="og:url" content="https://cast.home-assistant.io/" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Frequently asked questions about Home Assistant Cast."
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:image"
|
||||||
|
content="https://cast.home-assistant.io/images/google-nest-hub.png"
|
||||||
|
/>
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:site" content="@home_assistant" />
|
||||||
|
<meta name="twitter:title" content="FAQ - Home Assistant Cast" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="Frequently asked questions about Home Assistant Cast."
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="twitter:image"
|
||||||
|
content="https://cast.home-assistant.io/images/google-nest-hub.png"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<%= renderTemplate('_js_base') %>
|
||||||
|
|
||||||
|
<script type="module" crossorigin="use-credentials">
|
||||||
|
import "<%= latestLauncherJS %>";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script nomodule>
|
||||||
|
(function() {
|
||||||
|
// // Safari 10.1 supports type=module but ignores nomodule, so we add this check.
|
||||||
|
if (!isS101) {
|
||||||
|
_ls("/static/polyfills/custom-elements-es5-adapter.js");
|
||||||
|
_ls("<%= es5LauncherJS %>");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<hc-layout subtitle="FAQ">
|
||||||
|
<style>
|
||||||
|
a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="card-content">
|
||||||
|
<p><a href="/">« Back to Home Assistant Cast</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">What is Home Assistant Cast?</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">
|
||||||
|
What are the Home Assistant Cast requirements?
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<p>
|
||||||
|
Home Assistant Cast requires a Home Assistant installation that is
|
||||||
|
accessible via HTTPS (the url starts with "https://").
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">What is Home Assistant?</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://www.home-assistant.io" target="_blank"
|
||||||
|
>Visit the Home Assistant website.</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header" id="https">
|
||||||
|
Why does my Home Assistant needs to be served using HTTPS?
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<p>
|
||||||
|
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 (<a
|
||||||
|
href="https://developer.mozilla.org/en-US/docs/Web/Security/Mixed_content#Mixed_active_content"
|
||||||
|
target="_blank"
|
||||||
|
>learn more @ MDN</a
|
||||||
|
>).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The easiest way to get your Home Assistant installation served over
|
||||||
|
HTTPS is by signing up for
|
||||||
|
<a href="https://www.nabucasa.com" target="_blank"
|
||||||
|
>Home Assistant Cloud by Nabu Casa</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">How does Home Assistant Cast work?</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<p>
|
||||||
|
Home Assistant Cast is a receiver application for the Chromecast. When
|
||||||
|
loaded, it will make a direct connection to your Home Assistant
|
||||||
|
instance.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Render Lovelace views, including custom cards</li>
|
||||||
|
<li>
|
||||||
|
Real-time data stream will ensure the UI always shows the latest
|
||||||
|
state of your house
|
||||||
|
</li>
|
||||||
|
<li>Navigate between views using navigate actions or weblinks</li>
|
||||||
|
<li>
|
||||||
|
Instant updates of the casted Lovelace UI when you update your
|
||||||
|
Lovelace configuration.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>Things that currently do not work:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Live videostreams using the streaming integration
|
||||||
|
</li>
|
||||||
|
<li>Specifying a view with a single card with "panel: true".</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header" id="https">
|
||||||
|
How do I change what is shown on my Chromecast?
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
To edit your Lovelace UI, open Home Assistant, click on the three-dot
|
||||||
|
menu in the top right and click on "Configure UI".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header" id="browser">
|
||||||
|
What browsers are supported?
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<p>
|
||||||
|
Chromecast is a technology developed by Google, and is available on:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Google Chrome (all platforms except on iOS)</li>
|
||||||
|
<li>
|
||||||
|
Microsoft Edge (all platforms,
|
||||||
|
<a href="https://www.microsoftedgeinsider.com" target="_blank"
|
||||||
|
>dev and canary builds only</a
|
||||||
|
>)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">Why do some custom cards not work?</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<p>
|
||||||
|
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:
|
||||||
|
</p>
|
||||||
|
<pre>
|
||||||
|
http:
|
||||||
|
cors_allowed_origins:
|
||||||
|
- https://cast.home-assistant.io</pre
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</hc-layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var _gaq = [["_setAccount", "UA-57927901-9"], ["_trackPageview"]];
|
||||||
|
(function(d, t) {
|
||||||
|
var g = d.createElement(t),
|
||||||
|
s = d.getElementsByTagName(t)[0];
|
||||||
|
g.src =
|
||||||
|
("https:" == location.protocol ? "//ssl" : "//www") +
|
||||||
|
".google-analytics.com/ga.js";
|
||||||
|
s.parentNode.insertBefore(g, s);
|
||||||
|
})(document, "script");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,51 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Home Assistant Cast</title>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="icon" href="/images/ha-cast-icon.png" type="image/png" />
|
||||||
|
<%= renderTemplate('_style_base') %>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #e5e5e5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<meta property="fb:app_id" content="338291289691179">
|
||||||
|
<meta property="og:title" content="Home Assistant Cast">
|
||||||
|
<meta property="og:site_name" content="Home Assistant Cast">
|
||||||
|
<meta property="og:url" content="https://cast.home-assistant.io/">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:description" content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen.">
|
||||||
|
<meta property="og:image" content="https://cast.home-assistant.io/images/google-nest-hub.png">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:site" content="@home_assistant">
|
||||||
|
<meta name="twitter:title" content="Home Assistant Cast">
|
||||||
|
<meta name="twitter:description" content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen.">
|
||||||
|
<meta name="twitter:image" content="https://cast.home-assistant.io/images/google-nest-hub.png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<%= renderTemplate('_js_base') %>
|
||||||
|
|
||||||
|
<hc-connect></hc-connect>
|
||||||
|
|
||||||
|
<script type="module" crossorigin="use-credentials">
|
||||||
|
import "<%= latestLauncherJS %>";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script nomodule>
|
||||||
|
(function() {
|
||||||
|
// // Safari 10.1 supports type=module but ignores nomodule, so we add this check.
|
||||||
|
if (!isS101) {
|
||||||
|
_ls("/static/polyfills/custom-elements-es5-adapter.js");
|
||||||
|
_ls("<%= es5LauncherJS %>");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
var _gaq=[['_setAccount','UA-57927901-9'],['_trackPageview']];
|
||||||
|
(function(d,t){var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
||||||
|
g.src=('https:'==location.protocol?'//ssl':'//www')+'.google-analytics.com/ga.js';
|
||||||
|
s.parentNode.insertBefore(g,s)}(document,'script'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<script src="//www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js"></script>
|
||||||
|
<script type="module" src="<%= latestReceiverJS %>"></script>
|
||||||
|
<%= renderTemplate('_style_base') %>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: white;
|
||||||
|
font-size: initial;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</html>
|
|
@ -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";
|
|
@ -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`
|
||||||
|
<loading-screen></loading-screen>>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error =
|
||||||
|
this.castManager.castState === "NO_DEVICES_AVAILABLE"
|
||||||
|
? html`
|
||||||
|
<p>
|
||||||
|
There were no suitable Chromecast devices to cast to found.
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<hc-layout .auth=${this.auth} .connection=${this.connection}>
|
||||||
|
${this.askWrite
|
||||||
|
? html`
|
||||||
|
<p class="question action-item">
|
||||||
|
Stay logged in?
|
||||||
|
<span>
|
||||||
|
<mwc-button @click=${this._handleSaveTokens}>
|
||||||
|
YES
|
||||||
|
</mwc-button>
|
||||||
|
<mwc-button @click=${this._handleSkipSaveTokens}>
|
||||||
|
NO
|
||||||
|
</mwc-button>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
: ""}
|
||||||
|
${error
|
||||||
|
? html`
|
||||||
|
<div class="card-content">${error}</div>
|
||||||
|
`
|
||||||
|
: !this.castManager.status
|
||||||
|
? html`
|
||||||
|
<p class="center-item">
|
||||||
|
<mwc-button raised @click=${this._handleLaunch}>
|
||||||
|
<iron-icon icon="hass:cast"></iron-icon>
|
||||||
|
Start Casting
|
||||||
|
</mwc-button>
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<div class="section-header">PICK A VIEW</div>
|
||||||
|
<paper-listbox
|
||||||
|
attr-for-selected="data-path"
|
||||||
|
.selected=${this.castManager.status.lovelacePath || ""}
|
||||||
|
>
|
||||||
|
${(this.lovelaceConfig
|
||||||
|
? this.lovelaceConfig.views
|
||||||
|
: [generateDefaultViewConfig([], [], [], {}, () => "")]
|
||||||
|
).map(
|
||||||
|
(view, idx) => html`
|
||||||
|
<paper-icon-item
|
||||||
|
@click=${this._handlePickView}
|
||||||
|
data-path=${view.path || idx}
|
||||||
|
>
|
||||||
|
${view.icon
|
||||||
|
? html`
|
||||||
|
<ha-icon
|
||||||
|
.icon=${view.icon}
|
||||||
|
slot="item-icon"
|
||||||
|
></ha-icon>
|
||||||
|
`
|
||||||
|
: ""}
|
||||||
|
${view.title || view.path}
|
||||||
|
</paper-icon-item>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</paper-listbox>
|
||||||
|
`}
|
||||||
|
<div class="card-actions">
|
||||||
|
${this.castManager.status
|
||||||
|
? html`
|
||||||
|
<mwc-button @click=${this._handleLaunch}>
|
||||||
|
<iron-icon icon="hass:cast-connected"></iron-icon>
|
||||||
|
Manage
|
||||||
|
</mwc-button>
|
||||||
|
`
|
||||||
|
: ""}
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<mwc-button @click=${this._handleLogout}>Log out</mwc-button>
|
||||||
|
</div>
|
||||||
|
</hc-layout>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 <a href="./faq.html${qid ? `#${qid}` : ""}">the FAQ</a> 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`
|
||||||
|
<p>
|
||||||
|
Home Assistant Cast allows you to cast your Home Assistant installation to
|
||||||
|
Chromecast video devices and to Google Assistant devices with a screen.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
For more information, see the
|
||||||
|
<a href="./faq.html">frequently asked questions</a>.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
@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`
|
||||||
|
<hc-layout>
|
||||||
|
<div class="card-content">
|
||||||
|
Unable to connect to ${tokens!.hassUrl}.
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<a href="/">
|
||||||
|
<mwc-button>
|
||||||
|
Retry
|
||||||
|
</mwc-button>
|
||||||
|
</a>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<mwc-button @click=${this._handleLogout}>Log out</mwc-button>
|
||||||
|
</div>
|
||||||
|
</hc-layout>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.castManager === undefined || this.loading) {
|
||||||
|
return html`
|
||||||
|
<loading-screen></loading-screen>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.castManager === null) {
|
||||||
|
return html`
|
||||||
|
<hc-layout>
|
||||||
|
<div class="card-content">
|
||||||
|
${INTRO}
|
||||||
|
<p class="error">
|
||||||
|
The Cast API is not available in your browser.
|
||||||
|
${seeFAQ("browser")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</hc-layout>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.auth) {
|
||||||
|
return html`
|
||||||
|
<hc-layout>
|
||||||
|
<div class="card-content">
|
||||||
|
${INTRO}
|
||||||
|
<p>
|
||||||
|
To get started, enter your Home Assistant URL and click authorize.
|
||||||
|
If you want a preview instead, click the show demo button.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<paper-input
|
||||||
|
label="Home Assistant URL"
|
||||||
|
placeholder="https://abcdefghijklmnop.ui.nabu.casa"
|
||||||
|
@keydown=${this._handleInputKeyDown}
|
||||||
|
></paper-input>
|
||||||
|
</p>
|
||||||
|
${this.error
|
||||||
|
? html`
|
||||||
|
<p class="error">${this.error}</p>
|
||||||
|
`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<mwc-button @click=${this._handleDemo}>
|
||||||
|
Show Demo
|
||||||
|
<iron-icon
|
||||||
|
.icon=${this.castManager.castState === "CONNECTED"
|
||||||
|
? "hass:cast-connected"
|
||||||
|
: "hass:cast"}
|
||||||
|
></iron-icon>
|
||||||
|
</mwc-button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<mwc-button @click=${this._handleConnect}>Authorize</mwc-button>
|
||||||
|
</div>
|
||||||
|
</hc-layout>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<hc-cast
|
||||||
|
.connection=${this.connection}
|
||||||
|
.auth=${this.auth}
|
||||||
|
.castManager=${this.castManager}
|
||||||
|
></hc-cast>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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`
|
||||||
|
<ha-card>
|
||||||
|
<div class="layout">
|
||||||
|
<img class="hero" src="/images/google-nest-hub.png" />
|
||||||
|
<div class="card-header">
|
||||||
|
Home Assistant Cast${this.subtitle ? ` – ${this.subtitle}` : ""}
|
||||||
|
${this.auth
|
||||||
|
? html`
|
||||||
|
<div class="subtitle">
|
||||||
|
<a href=${this.auth.data.hassUrl} target="_blank"
|
||||||
|
>${this.auth.data.hassUrl.substr(
|
||||||
|
this.auth.data.hassUrl.indexOf("//") + 2
|
||||||
|
)}</a
|
||||||
|
>
|
||||||
|
${this.user
|
||||||
|
? html`
|
||||||
|
– ${this.user.name}
|
||||||
|
`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</ha-card>
|
||||||
|
<div class="footer">
|
||||||
|
<a href="./faq.html">Frequently Asked Questions</a> – Found a bug? Let
|
||||||
|
@balloob know
|
||||||
|
<!-- <a
|
||||||
|
href="https://github.com/home-assistant/home-assistant-polymer/issues"
|
||||||
|
target="_blank"
|
||||||
|
>Let us know!</a
|
||||||
|
> -->
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export const castContext = cast.framework.CastReceiverContext.getInstance();
|
|
@ -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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
|
@ -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<HassMessage>) => {
|
||||||
|
const msg = ev.data;
|
||||||
|
msg.senderId = ev.senderId;
|
||||||
|
controller.processIncomingMessage(msg);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
castContext.start(options);
|
|
@ -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`
|
||||||
|
<hc-lovelace
|
||||||
|
.hass=${this.hass}
|
||||||
|
.lovelaceConfig=${this._lovelaceConfig}
|
||||||
|
.viewPath=${this.lovelacePath}
|
||||||
|
></hc-lovelace>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
protected firstUpdated(changedProps) {
|
||||||
|
super.firstUpdated(changedProps);
|
||||||
|
this._initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _initialize() {
|
||||||
|
const initial: Partial<MockHomeAssistant> = {
|
||||||
|
// Override updateHass so that the correct hass lifecycle methods are called
|
||||||
|
updateHass: (hassUpdate: Partial<HomeAssistant>) =>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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`
|
||||||
|
<div class="container">
|
||||||
|
<img
|
||||||
|
src="https://www.home-assistant.io/images/blog/2018-09-thinking-big/social.png"
|
||||||
|
/>
|
||||||
|
<div class="status">
|
||||||
|
${this.hass ? "Connected" : "Not Connected"}
|
||||||
|
${this.error
|
||||||
|
? html`
|
||||||
|
<p>Error: ${this.error}</p>
|
||||||
|
`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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`
|
||||||
|
<hc-launch-screen
|
||||||
|
.hass=${this.hass}
|
||||||
|
.error=${`Unable to find a view with path ${this.viewPath}`}
|
||||||
|
></hc-launch-screen>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
const lovelace: Lovelace = {
|
||||||
|
config: this.lovelaceConfig,
|
||||||
|
editMode: false,
|
||||||
|
enableFullEditMode: () => undefined,
|
||||||
|
mode: "storage",
|
||||||
|
language: "en",
|
||||||
|
saveConfig: async () => undefined,
|
||||||
|
setEditMode: () => undefined,
|
||||||
|
};
|
||||||
|
return html`
|
||||||
|
<hui-view
|
||||||
|
.hass=${this.hass}
|
||||||
|
.lovelace=${lovelace}
|
||||||
|
.index=${index}
|
||||||
|
columns="2"
|
||||||
|
></hui-view>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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`
|
||||||
|
<hc-demo .lovelacePath=${this._lovelacePath}></hc-demo>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._lovelaceConfig || !this._lovelacePath) {
|
||||||
|
return html`
|
||||||
|
<hc-launch-screen
|
||||||
|
.hass=${this.hass}
|
||||||
|
.error=${this._error}
|
||||||
|
></hc-launch-screen>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<hc-lovelace
|
||||||
|
.hass=${this.hass}
|
||||||
|
.lovelaceConfig=${this._lovelaceConfig}
|
||||||
|
.viewPath=${this._lovelacePath}
|
||||||
|
></hc-lovelace>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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";
|
|
@ -0,0 +1,6 @@
|
||||||
|
export interface ReceivedMessage<T> {
|
||||||
|
gj: boolean;
|
||||||
|
data: T;
|
||||||
|
senderId: string;
|
||||||
|
type: "message";
|
||||||
|
}
|
|
@ -23,6 +23,9 @@ export const demoLovelaceArsaboo: DemoConfig["lovelace"] = (localize) => ({
|
||||||
entity: "switch.wemoporch",
|
entity: "switch.wemoporch",
|
||||||
},
|
},
|
||||||
"light.lifx5",
|
"light.lifx5",
|
||||||
|
{
|
||||||
|
type: "custom:cast-demo-row",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -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`
|
||||||
|
<ha-icon icon="hademo:television"></ha-icon>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="name">Show Chromecast interface</div>
|
||||||
|
<google-cast-launcher></google-cast-launcher>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import "../custom-cards/ha-demo-card";
|
import "../custom-cards/ha-demo-card";
|
||||||
|
import "../custom-cards/cast-demo-row";
|
||||||
// Not duplicate, one is for typing.
|
// Not duplicate, one is for typing.
|
||||||
// tslint:disable-next-line
|
// tslint:disable-next-line
|
||||||
import { HADemoCard } from "../custom-cards/ha-demo-card";
|
import { HADemoCard } from "../custom-cards/ha-demo-card";
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "script/build_frontend",
|
"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",
|
"mocha": "node_modules/.bin/ts-mocha -p test-mocha/tsconfig.test.json --opts test-mocha/mocha.opts",
|
||||||
"test": "npm run lint && npm run mocha",
|
"test": "npm run lint && npm run mocha",
|
||||||
"docker_build": "sh ./script/docker_run.sh build $npm_package_version",
|
"docker_build": "sh ./script/docker_run.sh build $npm_package_version",
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { loadJS } from "../common/dom/load_resource";
|
||||||
|
|
||||||
|
let loadedPromise: Promise<boolean> | 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;
|
||||||
|
};
|
|
@ -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<CastManager> | 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;
|
||||||
|
};
|
|
@ -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";
|
|
@ -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);
|
||||||
|
});
|
||||||
|
};
|
|
@ -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;
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface BaseCastMessage {
|
||||||
|
type: string;
|
||||||
|
senderId?: string;
|
||||||
|
}
|
|
@ -26,6 +26,7 @@ import "../special-rows/hui-call-service-row";
|
||||||
import "../special-rows/hui-divider-row";
|
import "../special-rows/hui-divider-row";
|
||||||
import "../special-rows/hui-section-row";
|
import "../special-rows/hui-section-row";
|
||||||
import "../special-rows/hui-weblink-row";
|
import "../special-rows/hui-weblink-row";
|
||||||
|
import "../special-rows/hui-cast-row";
|
||||||
import { EntityConfig, EntityRow } from "../entity-rows/types";
|
import { EntityConfig, EntityRow } from "../entity-rows/types";
|
||||||
|
|
||||||
const CUSTOM_TYPE_PREFIX = "custom:";
|
const CUSTOM_TYPE_PREFIX = "custom:";
|
||||||
|
@ -34,6 +35,7 @@ const SPECIAL_TYPES = new Set([
|
||||||
"divider",
|
"divider",
|
||||||
"section",
|
"section",
|
||||||
"weblink",
|
"weblink",
|
||||||
|
"cast",
|
||||||
]);
|
]);
|
||||||
const DOMAIN_TO_ELEMENT_TYPE = {
|
const DOMAIN_TO_ELEMENT_TYPE = {
|
||||||
alert: "toggle",
|
alert: "toggle",
|
||||||
|
|
|
@ -26,12 +26,21 @@ export interface CallServiceConfig extends EntityConfig {
|
||||||
service: string;
|
service: string;
|
||||||
service_data?: { [key: string]: any };
|
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 =
|
export type EntityRowConfig =
|
||||||
| EntityConfig
|
| EntityConfig
|
||||||
| DividerConfig
|
| DividerConfig
|
||||||
| SectionConfig
|
| SectionConfig
|
||||||
| WeblinkConfig
|
| WeblinkConfig
|
||||||
| CallServiceConfig;
|
| CallServiceConfig
|
||||||
|
| CastConfig;
|
||||||
|
|
||||||
export interface EntityRow extends HTMLElement {
|
export interface EntityRow extends HTMLElement {
|
||||||
hass?: HomeAssistant;
|
hass?: HomeAssistant;
|
||||||
|
|
|
@ -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`
|
||||||
|
<ha-icon .icon="${this._config.icon}"></ha-icon>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="name">${this._config.name}</div>
|
||||||
|
${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`
|
||||||
|
<div class="controls">
|
||||||
|
<google-cast-launcher></google-cast-launcher>
|
||||||
|
<mwc-button
|
||||||
|
@click=${this._sendLovelace}
|
||||||
|
.disabled=${!this._castManager.status}
|
||||||
|
>
|
||||||
|
SHOW
|
||||||
|
</mwc-button>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue