Merge pull request #3457 from home-assistant/dev

20190804.0
This commit is contained in:
Paulus Schoutsen 2019-08-04 22:37:45 -07:00 committed by GitHub
commit de04f60821
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
66 changed files with 3009 additions and 54 deletions

View File

@ -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"
)
);

View File

@ -15,3 +15,9 @@ gulp.task(
return del([config.demo_root, config.build_dir]);
})
);
gulp.task(
"clean-cast",
gulp.parallel("clean-translations", function cleanOutputAndBuildDir() {
return del([config.cast_root, config.build_dir]);
})
);

View File

@ -14,6 +14,9 @@ const templatePath = (tpl) =>
const demoTemplatePath = (tpl) =>
path.resolve(config.demo_dir, "src/html/", `${tpl}.html.template`);
const castTemplatePath = (tpl) =>
path.resolve(config.cast_dir, "src/html/", `${tpl}.html.template`);
const readFile = (pth) => fs.readFileSync(pth).toString();
const renderTemplate = (pth, data = {}, pathFunc = templatePath) => {
@ -24,6 +27,9 @@ const renderTemplate = (pth, data = {}, pathFunc = templatePath) => {
const renderDemoTemplate = (pth, data = {}) =>
renderTemplate(pth, data, demoTemplatePath);
const renderCastTemplate = (pth, data = {}) =>
renderTemplate(pth, data, castTemplatePath);
const minifyHtml = (content) =>
minify(content, {
collapseWhitespace: true,
@ -113,17 +119,64 @@ gulp.task("gen-index-app-prod", (done) => {
done();
});
gulp.task("gen-index-demo-dev", (done) => {
// In dev mode we don't mangle names, so we hardcode urls. That way we can
// run webpack as last in watch mode, which blocks output.
const content = renderDemoTemplate("index", {
latestDemoJS: "/frontend_latest/main.js",
es5Compatibility: "/frontend_es5/compatibility.js",
es5DemoJS: "/frontend_es5/main.js",
gulp.task("gen-index-cast-dev", (done) => {
const contentReceiver = renderCastTemplate("receiver", {
latestReceiverJS: "/frontend_latest/receiver.js",
});
fs.outputFileSync(
path.resolve(config.cast_root, "receiver.html"),
contentReceiver
);
fs.outputFileSync(path.resolve(config.demo_root, "index.html"), content);
const contentFAQ = renderCastTemplate("launcher-faq", {
latestLauncherJS: "/frontend_latest/launcher.js",
es5LauncherJS: "/frontend_es5/launcher.js",
});
fs.outputFileSync(path.resolve(config.cast_root, "faq.html"), contentFAQ);
const contentLauncher = renderCastTemplate("launcher", {
latestLauncherJS: "/frontend_latest/launcher.js",
es5LauncherJS: "/frontend_es5/launcher.js",
});
fs.outputFileSync(
path.resolve(config.cast_root, "index.html"),
contentLauncher
);
done();
});
gulp.task("gen-index-cast-prod", (done) => {
const latestManifest = require(path.resolve(
config.cast_output,
"manifest.json"
));
const es5Manifest = require(path.resolve(
config.cast_output_es5,
"manifest.json"
));
const contentReceiver = renderCastTemplate("receiver", {
latestReceiverJS: latestManifest["receiver.js"],
});
fs.outputFileSync(
path.resolve(config.cast_root, "receiver.html"),
contentReceiver
);
const contentFAQ = renderCastTemplate("launcher-faq", {
latestLauncherJS: latestManifest["launcher.js"],
es5LauncherJS: es5Manifest["launcher.js"],
});
fs.outputFileSync(path.resolve(config.cast_root, "faq.html"), contentFAQ);
const contentLauncher = renderCastTemplate("launcher", {
latestLauncherJS: latestManifest["launcher.js"],
es5LauncherJS: es5Manifest["launcher.js"],
});
fs.outputFileSync(
path.resolve(config.cast_root, "index.html"),
contentLauncher
);
done();
});

View File

@ -114,3 +114,15 @@ gulp.task("copy-static-demo", (done) => {
copyTranslations(paths.demo_static);
done();
});
gulp.task("copy-static-cast", (done) => {
// Copy app static files
fs.copySync(polyPath("public/static"), paths.cast_static);
// Copy cast static files
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_root);
copyMapPanel(paths.cast_static);
copyFonts(paths.cast_static);
copyTranslations(paths.cast_static);
done();
});

View File

@ -5,7 +5,11 @@ const webpack = require("webpack");
const WebpackDevServer = require("webpack-dev-server");
const log = require("fancy-log");
const paths = require("../paths");
const { createAppConfig, createDemoConfig } = require("../webpack");
const {
createAppConfig,
createDemoConfig,
createCastConfig,
} = require("../webpack");
const handler = (done) => (err, stats) => {
if (err) {
@ -114,3 +118,53 @@ gulp.task(
)
)
);
gulp.task("webpack-dev-server-cast", () => {
const compiler = webpack([
createCastConfig({
isProdBuild: false,
latestBuild: false,
}),
createCastConfig({
isProdBuild: false,
latestBuild: true,
}),
]);
new WebpackDevServer(compiler, {
open: true,
watchContentBase: true,
contentBase: path.resolve(paths.cast_dir, "dist"),
}).listen(
8080,
// Accessible from the network, because that's how Cast hits it.
"0.0.0.0",
function(err) {
if (err) {
throw err;
}
// Server listening
log("[webpack-dev-server]", "http://localhost:8080");
}
);
});
gulp.task(
"webpack-prod-cast",
() =>
new Promise((resolve) =>
webpack(
[
createCastConfig({
isProdBuild: true,
latestBuild: false,
}),
createCastConfig({
isProdBuild: true,
latestBuild: true,
}),
],
handler(resolve)
)
)
);

View File

@ -14,4 +14,10 @@ module.exports = {
demo_static: path.resolve(__dirname, "../demo/dist/static"),
demo_output: path.resolve(__dirname, "../demo/dist/frontend_latest"),
demo_output_es5: path.resolve(__dirname, "../demo/dist/frontend_es5"),
cast_dir: path.resolve(__dirname, "../cast"),
cast_root: path.resolve(__dirname, "../cast/dist"),
cast_static: path.resolve(__dirname, "../cast/dist/static"),
cast_output: path.resolve(__dirname, "../cast/dist/frontend_latest"),
cast_output_es5: path.resolve(__dirname, "../cast/dist/frontend_es5"),
};

View File

@ -214,10 +214,56 @@ const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) => {
};
};
const createCastConfig = ({ isProdBuild, latestBuild }) => {
const isStatsBuild = false;
const entry = {
launcher: "./cast/src/launcher/entrypoint.ts",
};
if (latestBuild) {
entry.receiver = "./cast/src/receiver/entrypoint.ts";
}
return {
mode: genMode(isProdBuild),
devtool: genDevTool(isProdBuild),
entry,
module: {
rules: [babelLoaderConfig({ latestBuild }), cssLoader, htmlLoader],
},
optimization: optimization(latestBuild),
plugins: [
new ManifestPlugin(),
new webpack.DefinePlugin({
__DEV__: !isProdBuild,
__BUILD__: JSON.stringify(latestBuild ? "latest" : "es5"),
__VERSION__: JSON.stringify(version),
__DEMO__: false,
__STATIC_PATH__: "/static/",
"process.env.NODE_ENV": JSON.stringify(
isProdBuild ? "production" : "development"
),
}),
...plugins,
].filter(Boolean),
resolve,
output: {
filename: genFilename(isProdBuild),
chunkFilename: genChunkFilename(isProdBuild, isStatsBuild),
path: path.resolve(
paths.cast_root,
latestBuild ? "frontend_latest" : "frontend_es5"
),
publicPath: latestBuild ? "/frontend_latest/" : "/frontend_es5/",
},
};
};
module.exports = {
resolve,
plugins,
optimization,
createAppConfig,
createDemoConfig,
createCastConfig,
};

56
cast/README.md Normal file
View File

@ -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

18
cast/public/manifest.json Normal file
View File

@ -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"
}

View File

@ -0,0 +1,3 @@
self.addEventListener("fetch", function(event) {
event.respondWith(fetch(event.request));
});

9
cast/script/build_cast Executable file
View File

@ -0,0 +1,9 @@
#!/bin/sh
# Build the cast receiver
# Stop on errors
set -e
cd "$(dirname "$0")/../.."
./node_modules/.bin/gulp build-cast

9
cast/script/develop_cast Executable file
View File

@ -0,0 +1,9 @@
#!/bin/sh
# Develop the cast receiver
# Stop on errors
set -e
cd "$(dirname "$0")/../.."
./node_modules/.bin/gulp develop-cast

3
cast/script/upload Executable file
View File

@ -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

View File

@ -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="/">&laquo; 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>

View File

@ -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>

View File

@ -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>

View File

@ -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";

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1 @@
export const castContext = cast.framework.CastReceiverContext.getInstance();

View File

@ -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"],
},
},
});

View File

@ -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",
},
],
},
],
};
};

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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";

View File

@ -0,0 +1,6 @@
export interface ReceivedMessage<T> {
gj: boolean;
data: T;
senderId: string;
type: "message";
}

View File

@ -23,6 +23,9 @@ export const demoLovelaceArsaboo: DemoConfig["lovelace"] = (localize) => ({
entity: "switch.wemoporch",
},
"light.lifx5",
{
type: "custom:cast-demo-row",
},
],
},
{

View File

@ -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;
}
}

View File

@ -1,4 +1,5 @@
import "../custom-cards/ha-demo-card";
import "../custom-cards/cast-demo-row";
// Not duplicate, one is for typing.
// tslint:disable-next-line
import { HADemoCard } from "../custom-cards/ha-demo-card";

View File

@ -8,7 +8,7 @@
"version": "1.0.0",
"scripts": {
"build": "script/build_frontend",
"lint": "eslint src hassio/src gallery/src && tslint 'src/**/*.ts' 'hassio/src/**/*.ts' 'gallery/src/**/*.ts' 'test-mocha/**/*.ts' && polymer lint && tsc",
"lint": "eslint src hassio/src gallery/src && tslint 'src/**/*.ts' 'hassio/src/**/*.ts' 'gallery/src/**/*.ts' 'cast/src/**/*.ts' 'test-mocha/**/*.ts' && polymer lint && tsc",
"mocha": "node_modules/.bin/ts-mocha -p test-mocha/tsconfig.test.json --opts test-mocha/mocha.opts",
"test": "npm run lint && npm run mocha",
"docker_build": "sh ./script/docker_run.sh build $npm_package_version",

View File

@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup(
name="home-assistant-frontend",
version="20190801.0",
version="20190804.0",
description="The Home Assistant frontend",
url="https://github.com/home-assistant/home-assistant-polymer",
author="The Home Assistant Authors",

View File

@ -317,6 +317,10 @@ class HaMediaPlayerCard extends LocalizeMixin(EventsMixin(PolymerElement)) {
computeBannerClasses(playerObj, coverShowing, coverLoadError) {
var cls = "banner";
if (!playerObj) {
return cls;
}
if (playerObj.isOff || playerObj.isIdle) {
cls += " is-off no-cover";
} else if (

View File

@ -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;
};

167
src/cast/cast_manager.ts Normal file
View File

@ -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;
};

11
src/cast/const.ts Normal file
View File

@ -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";

View File

@ -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);
});
};

View File

@ -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;

4
src/cast/types.ts Normal file
View File

@ -0,0 +1,4 @@
export interface BaseCastMessage {
type: string;
senderId?: string;
}

View File

@ -5,10 +5,13 @@ export interface ZWaveNetworkStatus {
}
export interface ZWaveValue {
index: number;
instance: number;
label: string;
poll_intensity: number;
key: number;
value: {
index: number;
instance: number;
label: string;
poll_intensity: number;
};
}
export interface ZWaveConfigItem {

View File

@ -23,7 +23,7 @@ import { ZWaveValue } from "../../../data/zwave";
@customElement("zwave-values")
export class ZwaveValues extends LitElement {
@property() public hass!: HomeAssistant;
@property() private _values: ZWaveValue[] = [];
@property() public values: ZWaveValue[] = [];
@property() private _selectedValue: number = -1;
protected render(): TemplateResult | void {
@ -34,7 +34,7 @@ export class ZwaveValues extends LitElement {
>
<div class="device-picker">
<paper-dropdown-menu
label=${this.hass.localize("ui.panel.config.zwave.common.value")}
.label=${this.hass.localize("ui.panel.config.zwave.common.value")}
dynamic-align
class="flex"
>
@ -42,19 +42,11 @@ export class ZwaveValues extends LitElement {
slot="dropdown-content"
.selected=${this._selectedValue}
>
${this._values.map(
${this.values.map(
(item) => html`
<paper-item
>${item.label}
(${this.hass.localize(
"ui.panel.config.zwave.common.instance"
)}:
${item.instance},
${this.hass.localize(
"ui.panel.config.zwave.common.index"
)}:
${item.index})</paper-item
>
<paper-item>
${this._computeCaption(item)}
</paper-item>
`
)}
</paper-listbox>
@ -110,6 +102,15 @@ export class ZwaveValues extends LitElement {
`,
];
}
private _computeCaption(item) {
let out = `${item.value.label}`;
out += ` (${this.hass.localize("ui.panel.config.zwave.common.instance")}:`;
out += ` ${item.value.instance},`;
out += ` ${this.hass.localize("ui.panel.config.zwave.common.index")}:`;
out += ` ${item.value.index})`;
return out;
}
}
declare global {

View File

@ -26,6 +26,7 @@ import "../special-rows/hui-call-service-row";
import "../special-rows/hui-divider-row";
import "../special-rows/hui-section-row";
import "../special-rows/hui-weblink-row";
import "../special-rows/hui-cast-row";
import { EntityConfig, EntityRow } from "../entity-rows/types";
const CUSTOM_TYPE_PREFIX = "custom:";
@ -34,6 +35,7 @@ const SPECIAL_TYPES = new Set([
"divider",
"section",
"weblink",
"cast",
]);
const DOMAIN_TO_ELEMENT_TYPE = {
alert: "toggle",

View File

@ -26,12 +26,21 @@ export interface CallServiceConfig extends EntityConfig {
service: string;
service_data?: { [key: string]: any };
}
export interface CastConfig {
type: "cast";
icon: string;
name: string;
view: string;
// Hide the row if either unsupported browser or no API available.
hide_if_unavailable: boolean;
}
export type EntityRowConfig =
| EntityConfig
| DividerConfig
| SectionConfig
| WeblinkConfig
| CallServiceConfig;
| CallServiceConfig
| CastConfig;
export interface EntityRow extends HTMLElement {
hass?: HomeAssistant;

View File

@ -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;
}
}

View File

@ -620,10 +620,22 @@
"common": {
"value": "Valor",
"instance": "Instància",
"index": "Índex"
"index": "Índex",
"unknown": "desconegut",
"wakeup_interval": "Interval en despertar"
},
"values": {
"header": "Valors dels node"
},
"node_config": {
"header": "Opcions de configuració del node",
"seconds": "segons",
"set_wakeup": "Estableix linterval en despertar",
"config_parameter": "Paràmetre de configuració",
"config_value": "Valor de configuració",
"true": "Cert",
"false": "Fals",
"set_config_parameter": "Defineix el paràmetre de configuració"
}
},
"users": {

View File

@ -687,6 +687,30 @@
"password": "Cyfrinair",
"create": "Creu"
}
},
"server_control": {
"section": {
"validation": {
"introduction": "Dilyswch eich ffurfweddiad os gwnaethoch rai newidiadau ffurfweddu yn ddiweddar ac rydych eisiau gwneud yn siŵr fod o'n ddilys",
"check_config": "Gwirio config",
"valid": "Ffurfweddiad dilys!",
"invalid": "Ffurfweddiad annilys"
},
"reloading": {
"heading": "Ffurfweddiad yn ail-lwytho",
"introduction": "Gall rhai rhannau o Home Assistant ail-lwytho heb orfod ailgychwyn. Bydd taro ail-lwytho yn dadlwytho eu cyfluniad cyfredol a llwytho'r un newydd.",
"core": "Ail-lwytho craidd",
"group": "Ail-lwytho grwpiau",
"automation": "Ail-lwytho awtomeiddiadau",
"script": "Ail-lwytho sgriptiau"
},
"server_management": {
"heading": "Rheoli gweinydd",
"introduction": "Rheoli eich gweinydd Home Assistant.. o Home Assistant.",
"restart": "Ailgychwyn",
"stop": "Stopio"
}
}
}
},
"lovelace": {

View File

@ -620,10 +620,22 @@
"common": {
"value": "Value",
"instance": "Instance",
"index": "Index"
"index": "Index",
"unknown": "unknown",
"wakeup_interval": "Wakeup Interval"
},
"values": {
"header": "Node Values"
},
"node_config": {
"header": "Node Config Options",
"seconds": "seconds",
"set_wakeup": "Set Wakeup Interval",
"config_parameter": "Config Parameter",
"config_value": "Config Value",
"true": "True",
"false": "False",
"set_config_parameter": "Set Config Parameter"
}
},
"users": {

View File

@ -320,7 +320,7 @@
"title": "Tapahtumat"
},
"templates": {
"title": "Mallit"
"title": "Malli"
},
"mqtt": {
"title": "MQTT"
@ -346,10 +346,10 @@
"introduction": "Täällä voit säätää Home Assistanttia ja sen komponentteja. Huomioithan, ettei kaikkea voi vielä säätää käyttöliittymän kautta, mutta teemme jatkuvasti töitä sen mahdollistamiseksi.",
"core": {
"caption": "Yleinen",
"description": "Tarkista asetustiedostosi ja hallitse palvelinta",
"description": "Muuta Home Assistantin yleisiä asetuksiasi",
"section": {
"core": {
"header": "Asetusten ja palvelimen hallinta",
"header": "Yleiset asetukset",
"introduction": "Tiedämme, että asetusten muuttaminen saattaa olla työlästä. Täältä löydät työkaluja, jotka toivottavasti helpottavat elämääsi.",
"core_config": {
"edit_requires_storage": "Editori on poistettu käytöstä, koska asetuksia on annettu configuration.yaml:ssa.",

View File

@ -617,10 +617,21 @@
"common": {
"value": "Érték",
"instance": "Példány",
"index": "Index"
"index": "Index",
"unknown": "Ismeretlen",
"wakeup_interval": "Ébresztési időköz"
},
"values": {
"header": "Csomópont értékek"
},
"node_config": {
"seconds": "másodperc",
"set_wakeup": "Ébresztési időköz beállítása",
"config_parameter": "Konfigurációs paraméter",
"config_value": "Konfigurációs érték",
"true": "Igaz",
"false": "Hamis",
"set_config_parameter": "Konfigurációs paraméter beállítása"
}
},
"users": {
@ -744,6 +755,33 @@
"device_tracker_picked": "Eszköz követése",
"device_tracker_pick": "Válassz egy követni kívánt eszközt"
}
},
"server_control": {
"caption": "Szerver vezérlés",
"description": "A Home Assistant szerver újraindítása és leállítása",
"section": {
"validation": {
"heading": "Konfiguráció érvényesítés",
"introduction": "Érvényesítsd a konfigurációt, ha nemrégiben módosítottad azt, és meg szeretnél bizonyosodni róla, hogy minden érvényes",
"check_config": "Konfiguráció ellenőrzése",
"valid": "Érvényes konfiguráció!",
"invalid": "Érvénytelen konfiguráció"
},
"reloading": {
"heading": "Konfiguráció újratöltés",
"introduction": "A Home Assistant bizonyos részei újraindítás nélkül újratölthetőek. Az újratöltés az aktuális konfiguráció helyére betölti az újat.",
"core": "Mag újratöltése",
"group": "Csoportok újratöltése",
"automation": "Automatizálások újratöltése",
"script": "Szkriptek újratöltése"
},
"server_management": {
"heading": "Szerver menedzsment",
"introduction": "Home Assistant szerver vezérlése... a Home Assistant-ból.",
"restart": "Újraindítás",
"stop": "Leállítás"
}
}
}
},
"profile": {

View File

@ -560,6 +560,9 @@
"soft_reset": "Mjúk endurstilling",
"save_config": "Vista stillingar",
"cancel_command": "Hætta við skipun"
},
"node_config": {
"seconds": "sekúndur"
}
},
"users": {
@ -681,6 +684,27 @@
"device_tracker_picked": "Rekja tæki",
"device_tracker_pick": "Velja tæki til að rekja"
}
},
"server_control": {
"description": "Endurræsa og stöðva Home Assistant þjóni",
"section": {
"validation": {
"heading": "Staðfesta stillingar",
"check_config": "Athuga stillingar",
"valid": "Stillingar í lagi!",
"invalid": "Stillingar ógildar"
},
"reloading": {
"heading": "Endurhleðsla stillinga",
"core": "Endurhlaða kjarna",
"group": "Endurhlaða hópa",
"automation": "Endurhlaða sjálfvirkni"
},
"server_management": {
"restart": "Endurræsa",
"stop": "Stöðva"
}
}
}
},
"profile": {

View File

@ -349,10 +349,10 @@
"introduction": "여기에서 구성요소와 Home Assistant 를 설정 할 수 있습니다. 아직 여기서 모두 설정 할 수는 없지만, 모든 내용을 설정 할 수 있도록 작업 중입니다.",
"core": {
"caption": "일반",
"description": "구성 내용 파일의 유효성을 검사하고 서버를 제어합니다",
"description": "Home Assistant 일반 구성 내용을 편집합니다",
"section": {
"core": {
"header": "구성 내용 설정 및 서버 제어",
"header": "일반 구성",
"introduction": "구성 내용의 설정을 변경하는 것은 때때로 난해하고 귀찮은 작업입니다. 여기서 설정 변경을 좀 더 쉽게 하실 수 있습니다.",
"core_config": {
"edit_requires_storage": "구성내용이 configuration.yaml 에 저장되어 있기 때문에 편집기가 비활성화 되었습니다.",
@ -373,14 +373,14 @@
"server_control": {
"validation": {
"heading": "구성 내용 유효성 검사",
"introduction": "최근에 구성 내용을 추가 혹은 변경하셨다면, 설정 확인 버튼을 눌러 구성 내용이 올바른지 검사하고 Home Assistant 가 정상 작동 되는지 확인하실 수 있습니다.",
"check_config": "설정 확인",
"introduction": "최근에 구성 내용을 추가 혹은 변경하셨다면, 구성 내용 확인 버튼을 눌러 구성 내용이 올바른지 검사하고 Home Assistant 가 정상 작동 되는지 확인하실 수 있습니다.",
"check_config": "구성 내용 확인",
"valid": "구성 내용이 모두 올바릅니다!",
"invalid": "구성 내용이 잘못되었습니다"
},
"reloading": {
"heading": "구성 내용 새로고침",
"introduction": "Home Assistant 의 일부 구성 내용은 재시작 없이 다시 읽어들일 수 있습니다. 새로고침을 누르면 현재 구성 내용을 내리고 새로운 구성 내용을 읽어들입니다",
"introduction": "Home Assistant 의 일부 구성 내용은 재시작 없이 다시 읽어들일 수 있습니다. 새로고침을 누르면 현재 구성 내용을 내리고 새로운 구성 내용을 읽어들입니다.",
"core": "코어 새로고침",
"group": "그룹 새로고침",
"automation": "자동화 새로고침",
@ -388,7 +388,7 @@
},
"server_management": {
"heading": "서버 관리",
"introduction": "Home Assistant 서버를 제어합니다",
"introduction": "Home Assistant 서버를 제어합니다.",
"restart": "재시작",
"stop": "중지"
}
@ -620,10 +620,22 @@
"common": {
"value": "값",
"instance": "인스턴스",
"index": "색인"
"index": "색인",
"unknown": "알 수 없슴",
"wakeup_interval": "절전 모드 해제 간격"
},
"values": {
"header": "노드 값"
},
"node_config": {
"header": "노드 구성 옵션",
"seconds": "초",
"set_wakeup": "절전 모드 해제 간격 설정",
"config_parameter": "구성 파라메터",
"config_value": "구성 값",
"true": "참",
"false": "거짓",
"set_config_parameter": "구성 파라메터 설정"
}
},
"users": {
@ -747,6 +759,33 @@
"device_tracker_picked": "장치 추적 대상",
"device_tracker_pick": "추적 할 장치 선택"
}
},
"server_control": {
"caption": "서버 제어",
"description": "Home Assistant 서버를 재시작 또는 중지합니다",
"section": {
"validation": {
"heading": "구성 내용 유효성 검사",
"introduction": "최근에 구성 내용을 추가 혹은 변경하셨다면, 구성 내용 확인 버튼을 눌러 구성 내용이 올바른지 검사하고 Home Assistant 가 정상 작동 되는지 확인하실 수 있습니다.",
"check_config": "구성 내용 확인",
"valid": "구성 내용이 모두 올바릅니다!",
"invalid": "구성 내용이 잘못되었습니다"
},
"reloading": {
"heading": "구성 내용 새로고침",
"introduction": "Home Assistant 의 일부 구성 내용은 재시작 없이 다시 읽어들일 수 있습니다. 새로고침을 누르면 현재 구성 내용을 내리고 새로운 구성 내용을 읽어들입니다.",
"core": "코어 새로고침",
"group": "그룹 새로고침",
"automation": "자동화 새로고침",
"script": "스크립트 새로 고침"
},
"server_management": {
"heading": "서버 관리",
"introduction": "Home Assistant 서버를 제어합니다.",
"restart": "재시작",
"stop": "중지"
}
}
}
},
"profile": {
@ -1230,8 +1269,8 @@
}
},
"notification_toast": {
"entity_turned_on": " {entity}이(가) 켜졌습니다.",
"entity_turned_off": " {entity}이(가) 꺼졌습니다.",
"entity_turned_on": "{entity} 이(가) 켜졌습니다.",
"entity_turned_off": "{entity} 이(가) 꺼졌습니다.",
"service_called": "{service} 서비스가 호출되었습니다.",
"service_call_failed": "{service} 서비스를 호출하지 못했습니다.",
"connection_lost": "서버와 연결이 끊어졌습니다. 다시 연결 중..."
@ -1262,7 +1301,7 @@
"confirm": "로그인 저장하기"
},
"notification_drawer": {
"click_to_configure": "버튼을 클릭하여 구성하세요 {entity}",
"click_to_configure": "버튼을 클릭하여 {entity} 을(를) 구성",
"empty": "알림 없음",
"title": "알림"
}

View File

@ -620,10 +620,22 @@
"common": {
"value": "Verdi",
"instance": "Forekomst",
"index": "Indeks"
"index": "Indeks",
"unknown": "ukjent",
"wakeup_interval": "Våkningsintervall"
},
"values": {
"header": "Nodeverdier"
},
"node_config": {
"header": "Alternativer for nodekonfigurasjon",
"seconds": "sekunder",
"set_wakeup": "Angi vekkeintervall",
"config_parameter": "Konfigurasjon parameter",
"config_value": "Konfigurasjon verdi",
"true": "Ekte",
"false": "Falsk",
"set_config_parameter": "Angi konfigurasjons parameter"
}
},
"users": {

View File

@ -620,10 +620,22 @@
"common": {
"value": "Wartość",
"instance": "Instancja",
"index": "Indeks"
"index": "Indeks",
"unknown": "nieznany",
"wakeup_interval": "Interwał wybudzenia"
},
"values": {
"header": "Wartości węzła"
},
"node_config": {
"header": "Opcje konfiguracji węzła",
"seconds": "sekund",
"set_wakeup": "Ustaw interwał wybudzenia",
"config_parameter": "Parametr",
"config_value": "Wartość",
"true": "Prawda",
"false": "Fałsz",
"set_config_parameter": "Ustaw parametr"
}
},
"users": {

View File

@ -620,10 +620,22 @@
"common": {
"value": "Значение",
"instance": "Экземпляр",
"index": "Индекс"
"index": "Индекс",
"unknown": "неизвестно",
"wakeup_interval": "Интервал пробуждения"
},
"values": {
"header": "Значения узлов"
},
"node_config": {
"header": "Параметры конфигурации узла",
"seconds": "секунд",
"set_wakeup": "Установить интервал пробуждения",
"config_parameter": "Параметр конфигурации",
"config_value": "Значение конфигурации",
"true": "Истина",
"false": "Ложь",
"set_config_parameter": "Установить параметр конфигурации"
}
},
"users": {
@ -727,7 +739,7 @@
"picker": {
"header": "Управление объектами",
"unavailable": "(недоступен)",
"introduction": "Home Assistant ведет реестр каждого объекта, который когда-либо был настроен в системе. Каждому из этих объектов присвоен ID, который зарезервирован только для этого объекта.",
"introduction": "Home Assistant ведет реестр каждого объекта, который когда-либо был создан в системе. Каждому из этих объектов присвоен ID, который зарезервирован только для этого объекта.",
"introduction2": "Используйте данный реестр, чтобы изменить ID или название объекта либо удалить запись из Home Assistant. Обратите внимание, что удаление записи из реестра объектов не удалит сам объект. Для этого перейдите по указанной ниже ссылке и удалите его со страницы интеграций.",
"integrations_page": "Страница интеграций"
},

View File

@ -608,6 +608,11 @@
"value": "Värde",
"instance": "Instans",
"index": "Index"
},
"node_config": {
"seconds": "Sekunder",
"true": "Sant",
"false": "Falskt"
}
},
"users": {
@ -731,6 +736,18 @@
"device_tracker_picked": "Spåra enheten",
"device_tracker_pick": "Välj enhet att spåra"
}
},
"server_control": {
"section": {
"reloading": {
"introduction": "Vissa delar av Home Assistant kan laddas om utan att en omstart krävs. Att trycka på \"ladda om\" innebär att den nuvarande konfiguration inaktiveras och den nya laddas."
},
"server_management": {
"introduction": "Kontrollera din Home Assistant-server ... från Home Assistant.",
"restart": "Starta om",
"stop": "Stoppa"
}
}
}
},
"profile": {

View File

@ -461,7 +461,16 @@
},
"zwave": {
"caption": "Z-Wave",
"description": "Z-Wave ağınızı yönetin"
"description": "Z-Wave ağınızı yönetin",
"node_config": {
"seconds": "Saniye",
"set_wakeup": "Uyandırma Aralığı Ayarla",
"config_parameter": "Yapılandırma Parametresi",
"config_value": "Yapılandırma Değeri",
"true": "Doğru",
"false": "Yanlış",
"set_config_parameter": "Yapılandırma parametresini belirle"
}
},
"users": {
"caption": "Kullanıcılar",
@ -504,6 +513,13 @@
"zha": {
"caption": "ZHA",
"description": "Zigbee Ev Otomasyonu ağ yönetimi"
},
"server_control": {
"section": {
"server_management": {
"restart": "Yeniden başlat"
}
}
}
},
"profile": {

View File

@ -593,7 +593,9 @@
"caption": "Z-Wave",
"description": "Quản lý mạng Z-Wave",
"services": {
"save_config": "Lưu cấu hình"
"soft_reset": "Khởi động lại",
"save_config": "Lưu cấu hình",
"cancel_command": "Hủy lệnh"
},
"common": {
"value": "Giá trị",
@ -708,6 +710,24 @@
"device_tracker_picked": "Thiết bị theo dõi",
"device_tracker_pick": "Chọn thiết bị để theo dõi"
}
},
"server_control": {
"section": {
"validation": {
"heading": "Xác nhận cấu hình",
"introduction": "Xác thực cấu hình của bạn nếu gần đây bạn đã thực hiện một số thay đổi đối với cấu hình của bạn và muốn đảm bảo rằng nó là hợp lệ",
"check_config": "Kiểm tra cấu hình",
"valid": "Cấu hình hợp lệ!",
"invalid": "Cấu hình không hợp lệ"
},
"reloading": {
"heading": "Nạp lại Cấu hình ",
"introduction": "Một số phần của Home Assistant có thể tải lại mà không yêu cầu khởi động lại. Nhấn nút tải lại sẽ gỡ bỏ cấu hình hiện tại của nó và tải một cấu hình mới.",
"core": "Tải lại lõi",
"group": "Tải lại nhóm",
"automation": "Tải lại Tự động hóa"
}
}
}
},
"profile": {

View File

@ -620,10 +620,22 @@
"common": {
"value": "數值",
"instance": "實例",
"index": "指數"
"index": "指數",
"unknown": "未知",
"wakeup_interval": "喚醒間隔"
},
"values": {
"header": "節點數值"
},
"node_config": {
"header": "節點設定選項",
"seconds": "秒",
"set_wakeup": "設定喚醒間隔",
"config_parameter": "設定參數",
"config_value": "設定值",
"true": "True",
"false": "False",
"set_config_parameter": "設定參數"
}
},
"users": {