mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-13 00:16:09 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692338459d | ||
|
|
a8ee519fcf | ||
|
|
fdbab9046c | ||
|
|
9f19a66a51 | ||
|
|
7b5d02bdba | ||
|
|
3a3228bf79 | ||
|
|
a299f5f9a7 | ||
|
|
27902ef586 | ||
|
|
2d0ab5349b | ||
|
|
77170b447f | ||
|
|
e2bb744202 | ||
|
|
0eec83448a |
No files matched your search
@@ -0,0 +1,19 @@
|
||||
import type { ReactiveElement } from "lit";
|
||||
import { ContextSubscriptionController } from "../../../src/common/decorators/consume";
|
||||
import { uiContext } from "../../../src/data/context";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// A demo that provides its own hass hides the gallery theme from its children,
|
||||
// so pass the gallery light or dark mode on to it.
|
||||
export const followGalleryTheme = (
|
||||
host: ReactiveElement,
|
||||
hass: MockHomeAssistant
|
||||
) =>
|
||||
new ContextSubscriptionController(host, uiContext, (ui) => {
|
||||
if (ui) {
|
||||
hass.updateHass({
|
||||
themes: ui.themes,
|
||||
selectedTheme: ui.selectedTheme,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from "../../../../src/data/history";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { followGalleryTheme } from "../../data/follow-gallery-theme";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
@@ -166,6 +167,7 @@ export class DemoStateHistoryCharts extends LitElement {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
followGalleryTheme(this, hass);
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { ChartSelectMode } from "../../../../src/components/chart/ha-chart-base";
|
||||
import "../../../../src/components/chart/statistics-chart";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-card";
|
||||
import type {
|
||||
Statistics,
|
||||
@@ -11,7 +13,9 @@ import type {
|
||||
} from "../../../../src/data/recorder";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { followGalleryTheme } from "../../data/follow-gallery-theme";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
@@ -108,6 +112,64 @@ interface Example {
|
||||
height?: string;
|
||||
}
|
||||
|
||||
interface TimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface RangeExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
statistics: Statistics;
|
||||
statTypes: StatisticType[];
|
||||
chartType: "line" | "bar";
|
||||
unit: string;
|
||||
selectMode: ChartSelectMode;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const RANGE_EXAMPLES: RangeExample[] = [
|
||||
{
|
||||
id: "point",
|
||||
title: "Point selection",
|
||||
description:
|
||||
"With select-mode set to point, a click selects the hour that contains it, and everything outside fades. Dragging still zooms.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
selectMode: "point",
|
||||
hint: "Click on a bar to select its hour.",
|
||||
},
|
||||
{
|
||||
id: "range-bar",
|
||||
title: "Range selection on bars",
|
||||
description:
|
||||
"With select-mode set to range, dragging selects a time range instead of zooming, so several bars can be selected at once. A click still selects one bar.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
selectMode: "range",
|
||||
hint: "Click or drag on the chart to select one or more bars.",
|
||||
},
|
||||
{
|
||||
id: "range",
|
||||
title: "Range selection on lines",
|
||||
description: "The same on a line chart.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
|
||||
statTypes: ["mean", "min", "max"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
selectMode: "range",
|
||||
hint: "Click or drag on the chart to select a period.",
|
||||
},
|
||||
];
|
||||
|
||||
const formatRange = (range: TimeRange) =>
|
||||
`${new Date(range.start).toLocaleString()} to ${new Date(range.end).toLocaleString()}`;
|
||||
|
||||
const EXAMPLES: Example[] = [
|
||||
{
|
||||
title: "Sum as bars",
|
||||
@@ -159,17 +221,20 @@ const EXAMPLES: Example[] = [
|
||||
export class DemoStatisticsChart extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() private _selections: Record<string, TimeRange | undefined> = {};
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
followGalleryTheme(this, hass);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return EXAMPLES.map(
|
||||
return html`${EXAMPLES.map(
|
||||
(example) => html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
@@ -191,7 +256,66 @@ export class DemoStatisticsChart extends LitElement {
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
);
|
||||
)}
|
||||
${RANGE_EXAMPLES.map((example) => this._renderRangeExample(example))}`;
|
||||
}
|
||||
|
||||
private _renderRangeExample(example: RangeExample) {
|
||||
const selection = this._selections[example.id];
|
||||
return html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
<p>${example.description}</p>
|
||||
<statistics-chart
|
||||
.statisticsData=${example.statistics}
|
||||
.metadata=${METADATA}
|
||||
.names=${NAMES}
|
||||
.statTypes=${example.statTypes}
|
||||
.chartType=${example.chartType}
|
||||
period="hour"
|
||||
.unit=${example.unit}
|
||||
.startTime=${START}
|
||||
.endTime=${END}
|
||||
.highlight=${selection}
|
||||
.clickForMoreInfo=${false}
|
||||
data-example=${example.id}
|
||||
.selectMode=${example.selectMode}
|
||||
hide-legend
|
||||
@chart-select=${this._rangeSelected}
|
||||
></statistics-chart>
|
||||
<div class="selection">
|
||||
${
|
||||
selection
|
||||
? html`
|
||||
<span>${formatRange(selection)}</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
data-example=${example.id}
|
||||
@click=${this._clearSelection}
|
||||
>
|
||||
Clear
|
||||
</ha-button>
|
||||
`
|
||||
: html`<span class="hint">${example.hint}</span>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _rangeSelected(ev: HASSDomEvent<HASSDomEvents["chart-select"]>) {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.example!;
|
||||
this._selections = {
|
||||
...this._selections,
|
||||
[id]: ev.detail,
|
||||
};
|
||||
}
|
||||
|
||||
private _clearSelection(ev: Event) {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.example!;
|
||||
this._selections = { ...this._selections, [id]: undefined };
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -202,6 +326,20 @@ export class DemoStatisticsChart extends LitElement {
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-2);
|
||||
min-height: 48px;
|
||||
margin-top: var(--ha-space-2);
|
||||
padding: 0 var(--ha-space-2) 0 var(--ha-space-4);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--secondary-background-color);
|
||||
}
|
||||
.hint {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { IcePath } from "./limit-ice-servers";
|
||||
|
||||
// The fields of the transport, candidate pair and local candidate stats
|
||||
interface IceStats {
|
||||
id: string;
|
||||
type: string;
|
||||
selectedCandidatePairId?: string;
|
||||
selected?: boolean;
|
||||
localCandidateId?: string;
|
||||
candidateType?: string;
|
||||
relayProtocol?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the network path a connected peer connection uses. Returns
|
||||
* `undefined` if the browser doesn't report it.
|
||||
*/
|
||||
export const getIcePath = async (
|
||||
peerConnection: RTCPeerConnection
|
||||
): Promise<IcePath | undefined> => {
|
||||
const stats: Record<string, IceStats> = {};
|
||||
try {
|
||||
(await peerConnection.getStats()).forEach((report: IceStats) => {
|
||||
stats[report.id] = report;
|
||||
});
|
||||
} catch (_err: unknown) {
|
||||
return undefined;
|
||||
}
|
||||
const reports = Object.values(stats);
|
||||
const pairId = reports.find(
|
||||
(report) => report.type === "transport" && report.selectedCandidatePairId
|
||||
)?.selectedCandidatePairId;
|
||||
const pair = pairId
|
||||
? stats[pairId]
|
||||
: // Firefox has no transport stats
|
||||
reports.find(
|
||||
(report) => report.type === "candidate-pair" && report.selected
|
||||
);
|
||||
const candidate = pair?.localCandidateId
|
||||
? stats[pair.localCandidateId]
|
||||
: undefined;
|
||||
if (!candidate?.candidateType) {
|
||||
return undefined;
|
||||
}
|
||||
if (candidate.url?.startsWith("turn")) {
|
||||
return { relayUrl: candidate.url };
|
||||
}
|
||||
if (candidate.candidateType === "relay" || candidate.relayProtocol) {
|
||||
// Not all browsers report which TURN URL a relayed candidate came from
|
||||
return undefined;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
|
||||
const isTurn = (url: string) => url.startsWith("turn");
|
||||
|
||||
const isTcpTurn = (url: string) =>
|
||||
url.startsWith("turns:") || url.includes("transport=tcp");
|
||||
|
||||
const isTls443 = (url: string) =>
|
||||
url.startsWith("turns:") && /:443(\?|$)/.test(url);
|
||||
|
||||
// "turn:host:3478?transport=udp" -> "host"
|
||||
const getHost = (url: string) =>
|
||||
url
|
||||
.replace(/^[a-z]+:/i, "")
|
||||
.replace(/\?.*$/, "")
|
||||
.replace(/:\d+$/, "")
|
||||
.toLowerCase();
|
||||
|
||||
/**
|
||||
* Reduces the URLs of every ICE server to one STUN URL, one UDP TURN URL and
|
||||
* one TCP TURN URL (preferring TLS on port 443, which passes most firewalls)
|
||||
* per host.
|
||||
*
|
||||
* The browser gathers a candidate for every URL on every network interface and
|
||||
* checks each of them against every remote candidate. Redundant URLs of the
|
||||
* same host do not add a new network path, but multiply the number of checks
|
||||
* and delay the connection by many seconds.
|
||||
*/
|
||||
export const limitIceServers = (
|
||||
iceServers: RTCIceServer[] | undefined
|
||||
): RTCIceServer[] | undefined =>
|
||||
iceServers?.map((server) => {
|
||||
const urls = ensureArray(server.urls);
|
||||
if (urls.length < 2) {
|
||||
return server;
|
||||
}
|
||||
const hosts = [...new Set(urls.map(getHost))];
|
||||
return {
|
||||
...server,
|
||||
urls: hosts.flatMap((host) => {
|
||||
const hostUrls = urls.filter((url) => getHost(url) === host);
|
||||
const turnUrls = hostUrls.filter(isTurn);
|
||||
const tcpTurnUrls = turnUrls.filter(isTcpTurn);
|
||||
return [
|
||||
hostUrls.find((url) => !isTurn(url)),
|
||||
turnUrls.find((url) => !isTcpTurn(url)),
|
||||
tcpTurnUrls.find(isTls443) ??
|
||||
tcpTurnUrls.find((url) => url.startsWith("turns:")) ??
|
||||
tcpTurnUrls[0],
|
||||
].filter((url) => url !== undefined);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// "turns:host" -> "turns:host:5349?transport=tcp", browsers report the URL of
|
||||
// a candidate with the port and transport that were left out
|
||||
const normalizeUrl = (url: string) => {
|
||||
const scheme = url.slice(0, url.indexOf(":")).toLowerCase();
|
||||
const port =
|
||||
/:(\d+)(\?|$)/.exec(url)?.[1] ?? (scheme.endsWith("s") ? "5349" : "3478");
|
||||
const transport =
|
||||
/[?&]transport=(\w+)/i.exec(url)?.[1].toLowerCase() ??
|
||||
(scheme === "turns" ? "tcp" : "udp");
|
||||
return `${scheme}:${getHost(url)}:${port}?transport=${transport}`;
|
||||
};
|
||||
|
||||
/** The network path a connection uses, without `relayUrl` it is direct. */
|
||||
export interface IcePath {
|
||||
relayUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces the ICE servers to what a new connection needs to take the same
|
||||
* path as a connection that is already running: only the TURN URL it relays
|
||||
* over, or no TCP TURN URLs when it is direct. The UDP TURN URLs stay, as
|
||||
* browsers also use them to find their public address.
|
||||
*
|
||||
* Returns `undefined` if the relay is not part of the ICE servers.
|
||||
*/
|
||||
export const limitIceServersToPath = (
|
||||
iceServers: RTCIceServer[] | undefined,
|
||||
path: IcePath
|
||||
): RTCIceServer[] | undefined => {
|
||||
const { relayUrl } = path;
|
||||
if (relayUrl) {
|
||||
const normalizedUrl = normalizeUrl(relayUrl);
|
||||
for (const server of iceServers ?? []) {
|
||||
const url = ensureArray(server.urls).find(
|
||||
(serverUrl) => normalizeUrl(serverUrl) === normalizedUrl
|
||||
);
|
||||
if (url) {
|
||||
return [{ ...server, urls: [url] }];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return limitIceServers(iceServers)
|
||||
?.map((server) => ({
|
||||
...server,
|
||||
urls: ensureArray(server.urls).filter((url) => !isTcpTurn(url)),
|
||||
}))
|
||||
.filter((server) => server.urls.length);
|
||||
};
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
mdiRestart,
|
||||
} from "@mdi/js";
|
||||
import { differenceInMinutes } from "date-fns";
|
||||
import type { DataZoomComponentOption } from "echarts/components";
|
||||
import type {
|
||||
DataZoomComponentOption,
|
||||
MarkAreaComponentOption,
|
||||
} from "echarts/components";
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type {
|
||||
ECElementEvent,
|
||||
@@ -90,14 +93,29 @@ export const DEFAULT_CHART_WIDTH = 500;
|
||||
// is short enough for a whole screenful; on a desktop that would cover the page.
|
||||
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
|
||||
const VISIBILITY_ROOT_MARGIN = "300px";
|
||||
const CLICK_DRAG_TOLERANCE = 4;
|
||||
// The selection is shown by the mask, not by the brush
|
||||
const BRUSH_STYLE = {
|
||||
color: "rgba(0, 0, 0, 0)",
|
||||
borderColor: "rgba(0, 0, 0, 0)",
|
||||
};
|
||||
|
||||
export type ZoomSliderMode = "auto" | "always" | "never";
|
||||
|
||||
export type ChartSelectMode = "none" | "point" | "range";
|
||||
const DEFERRED_PROPS = [
|
||||
"options",
|
||||
"data",
|
||||
"_hiddenDatasets",
|
||||
"_isZoomed",
|
||||
"highlight",
|
||||
] as const;
|
||||
const MASK_OPACITY = 0.7;
|
||||
|
||||
export interface ChartTimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
@@ -176,6 +194,18 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
@property({ attribute: "grow-for-zoom-slider", type: Boolean })
|
||||
public growForZoomSlider = false;
|
||||
|
||||
// "point" selects on click, "range" also selects on drag instead of zooming
|
||||
@property({ attribute: "select-mode" })
|
||||
public selectMode: ChartSelectMode = "none";
|
||||
|
||||
// Everything outside this time range fades
|
||||
@property({ attribute: false }) public highlight?: ChartTimeRange;
|
||||
|
||||
// Rounds a selection to the periods of the data, or drops it
|
||||
@property({ attribute: false }) public snapSelection?: (
|
||||
range: ChartTimeRange
|
||||
) => ChartTimeRange | undefined;
|
||||
|
||||
// extraComponents is not reactive and should not trigger updates
|
||||
public extraComponents?: any[];
|
||||
|
||||
@@ -201,6 +231,14 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
@state() private _isZoomed = false;
|
||||
|
||||
private _pointerDownX?: number;
|
||||
|
||||
private _selectingRange?: ChartTimeRange;
|
||||
|
||||
private _maskColor = "";
|
||||
|
||||
private _maskShown = false;
|
||||
|
||||
// Separate from _isZoomed, whose touch handling reruns graph layouts
|
||||
@state() private _isGraphRoamed = false;
|
||||
|
||||
@@ -454,6 +492,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
if (changedProps.has("selectMode")) {
|
||||
this._updateSelectMode();
|
||||
}
|
||||
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
|
||||
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
|
||||
return;
|
||||
@@ -499,6 +540,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
) {
|
||||
this._disposeSonification();
|
||||
}
|
||||
} else if (changedProps.has("highlight")) {
|
||||
this._updateMask();
|
||||
}
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
@@ -888,6 +931,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
const style = getComputedStyle(this);
|
||||
echarts.registerTheme("custom", this._createTheme(style));
|
||||
this._maskColor =
|
||||
style.getPropertyValue("--card-background-color").trim() ||
|
||||
style.getPropertyValue("--primary-background-color").trim();
|
||||
|
||||
this.chart = echarts.init(this._chartContainer!, "custom");
|
||||
this._dragToZoom = false;
|
||||
@@ -970,6 +1016,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
this._legendItemCount = this._getLegendItems()?.length;
|
||||
this._updateSankeyRoam();
|
||||
this._updateDragToZoom();
|
||||
if (this.selectMode !== "none") {
|
||||
this._updateSelectMode();
|
||||
}
|
||||
if (zoomStart !== 0 || zoomEnd !== 100) {
|
||||
this.zoom(zoomStart, zoomEnd, true);
|
||||
}
|
||||
@@ -1021,7 +1070,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
// A chart can switch type (e.g. pie and bar) without being rebuilt
|
||||
private _updateDragToZoom() {
|
||||
const active = !this._touchInput && this._hasZoomableXAxis();
|
||||
const active =
|
||||
!this._touchInput &&
|
||||
this._hasZoomableXAxis() &&
|
||||
this.selectMode !== "range";
|
||||
if (!this.chart || active === this._dragToZoom) {
|
||||
return;
|
||||
}
|
||||
@@ -1033,6 +1085,127 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _snapRange(range: ChartTimeRange) {
|
||||
return this.snapSelection ? this.snapSelection(range) : range;
|
||||
}
|
||||
|
||||
private _getMaskArea(): MarkAreaComponentOption | undefined {
|
||||
const range = this._selectingRange ?? this.highlight;
|
||||
if (!range) {
|
||||
return undefined;
|
||||
}
|
||||
this._maskShown = true;
|
||||
return {
|
||||
silent: true,
|
||||
z: 10,
|
||||
itemStyle: {
|
||||
color: this._maskColor,
|
||||
opacity: MASK_OPACITY,
|
||||
},
|
||||
data: [
|
||||
[{ xAxis: -Infinity }, { xAxis: range.start }],
|
||||
[{ xAxis: range.end }, { xAxis: Infinity }],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Only the first series carries the mask, merged in without resending data
|
||||
private _updateMask() {
|
||||
this.chart?.setOption({
|
||||
series: [{ markArea: this._getMaskArea() ?? { data: [] } }],
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSelectPointerDown = (e: ECElementEvent) => {
|
||||
this._pointerDownX = e.offsetX;
|
||||
};
|
||||
|
||||
private _handleSelectClick = (e: ECElementEvent) => {
|
||||
const point = [e.offsetX, e.offsetY];
|
||||
// A drag that ends on the plot is not a click
|
||||
const dragged =
|
||||
this._pointerDownX !== undefined &&
|
||||
Math.abs(e.offsetX - this._pointerDownX) > CLICK_DRAG_TOLERANCE;
|
||||
if (dragged || !this.chart?.containPixel("grid", point)) {
|
||||
return;
|
||||
}
|
||||
const [x] = this.chart.convertFromPixel("grid", point) as number[];
|
||||
const selection = this._snapRange({ start: x, end: x });
|
||||
if (selection) {
|
||||
fireEvent(this, "chart-select", selection);
|
||||
}
|
||||
};
|
||||
|
||||
private _handleBrush = (e: any) => {
|
||||
const range = e.areas?.[0]?.coordRange as number[] | undefined;
|
||||
if (range) {
|
||||
this._selectingRange = this._snapRange({
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
});
|
||||
this._updateMask();
|
||||
}
|
||||
};
|
||||
|
||||
private _handleBrushEnd = (e: any) => {
|
||||
const range = e.areas?.[0]?.coordRange as number[] | undefined;
|
||||
this.chart?.dispatchAction({ type: "brush", areas: [] });
|
||||
this._selectingRange = undefined;
|
||||
this._updateMask();
|
||||
const selection =
|
||||
range && this._snapRange({ start: range[0], end: range[1] });
|
||||
if (selection) {
|
||||
fireEvent(this, "chart-select", selection);
|
||||
}
|
||||
};
|
||||
|
||||
private _updateSelectMode() {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
const zr = this.chart.getZr();
|
||||
zr.off("mousedown", this._handleSelectPointerDown);
|
||||
zr.off("click", this._handleSelectClick);
|
||||
this.chart.off("brush", this._handleBrush);
|
||||
this.chart.off("brushEnd", this._handleBrushEnd);
|
||||
if (this.selectMode !== "none") {
|
||||
zr.on("mousedown", this._handleSelectPointerDown);
|
||||
zr.on("click", this._handleSelectClick);
|
||||
}
|
||||
const selectRange = this.selectMode === "range";
|
||||
if (selectRange) {
|
||||
this.chart.on("brush", this._handleBrush);
|
||||
this.chart.on("brushEnd", this._handleBrushEnd);
|
||||
this.chart.setOption({
|
||||
brush: {
|
||||
xAxisIndex: 0,
|
||||
// Only measures the range, the mask fades what is outside
|
||||
seriesIndex: [],
|
||||
transformable: false,
|
||||
brushStyle: BRUSH_STYLE,
|
||||
},
|
||||
});
|
||||
}
|
||||
const brushCursor = {
|
||||
type: "takeGlobalCursor",
|
||||
key: "brush",
|
||||
brushOption: {
|
||||
brushType: selectRange ? "lineX" : false,
|
||||
brushMode: "single",
|
||||
brushStyle: BRUSH_STYLE,
|
||||
},
|
||||
};
|
||||
// Brush and drag to zoom share the global cursor, the last one taken wins
|
||||
if (selectRange) {
|
||||
this._updateDragToZoom();
|
||||
this.chart.dispatchAction(brushCursor);
|
||||
} else {
|
||||
this.chart.dispatchAction(brushCursor);
|
||||
this._dragToZoom = false;
|
||||
this._updateDragToZoom();
|
||||
}
|
||||
}
|
||||
|
||||
private _hasZoomableXAxis() {
|
||||
const xAxis = ensureArray(this.options?.xAxis)?.[0] as
|
||||
XAXisOption | undefined;
|
||||
@@ -1475,7 +1648,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
private _getSeries() {
|
||||
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
|
||||
XAXisOption | undefined;
|
||||
const series = ensureArray(this.data).map((s) => {
|
||||
const maskArea = this._getMaskArea();
|
||||
const series = ensureArray(this.data).map((s, index) => {
|
||||
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
|
||||
? undefined
|
||||
: s.data;
|
||||
@@ -1513,6 +1687,16 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
} as HaECSeriesItem;
|
||||
}
|
||||
}
|
||||
// ECharts merges series by id and would keep a mask left out here
|
||||
if (this._maskShown && (s.type === "line" || s.type === "bar")) {
|
||||
result = {
|
||||
...result,
|
||||
markArea:
|
||||
index === 0 && maskArea
|
||||
? maskArea
|
||||
: ((s as LineSeriesOption).markArea ?? { data: [] }),
|
||||
} as HaECSeriesItem;
|
||||
}
|
||||
return processSeriesTooltipFormatter(result);
|
||||
});
|
||||
return series as ECOption["series"];
|
||||
@@ -1762,7 +1946,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
// it, so a quick drag out would lose the release and its selection
|
||||
if (
|
||||
ev.type === "pointerdown" &&
|
||||
this._dragToZoom &&
|
||||
(this._dragToZoom || this.selectMode === "range") &&
|
||||
this._isZoomPress(ev)
|
||||
) {
|
||||
this.chart
|
||||
@@ -2337,6 +2521,7 @@ declare global {
|
||||
"dataset-hidden": { id: string };
|
||||
"dataset-unhidden": { id: string };
|
||||
"chart-click": ECElementEvent;
|
||||
"chart-select": ChartTimeRange;
|
||||
"legend-label-click": { id: string };
|
||||
"chart-zoom": {
|
||||
start: number;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
@@ -49,7 +49,13 @@ import type {
|
||||
HomeAssistantUI,
|
||||
} from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import type {
|
||||
ChartSelectMode,
|
||||
ChartTimeRange,
|
||||
CustomLegendOption,
|
||||
HaChartBase,
|
||||
ZoomSliderMode,
|
||||
} from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
@@ -128,6 +134,16 @@ export class StatisticsChart extends LitElement {
|
||||
@property({ attribute: "expand-legend", type: Boolean })
|
||||
public expandLegend?: boolean;
|
||||
|
||||
@property({ attribute: "select-mode" })
|
||||
public selectMode: ChartSelectMode = "none";
|
||||
|
||||
@property({ attribute: "zoom-slider" })
|
||||
public zoomSlider: ZoomSliderMode = "auto";
|
||||
|
||||
@property({ attribute: false }) public highlight?: ChartTimeRange;
|
||||
|
||||
@query("ha-chart-base") private _chartBase?: HaChartBase;
|
||||
|
||||
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
|
||||
@state() private _legendData: CustomLegendOption["data"];
|
||||
@@ -244,8 +260,12 @@ export class StatisticsChart extends LitElement {
|
||||
return html`
|
||||
<ha-chart-base
|
||||
.data=${this._chartData}
|
||||
.highlight=${this.highlight}
|
||||
.snapSelection=${this._snapSelection}
|
||||
.options=${this._chartOptions}
|
||||
.height=${this.height}
|
||||
.selectMode=${this.selectMode}
|
||||
.zoomSlider=${this.zoomSlider}
|
||||
style=${styleMap({ height: this.height })}
|
||||
@dataset-hidden=${this._datasetHidden}
|
||||
@dataset-unhidden=${this._datasetUnhidden}
|
||||
@@ -259,6 +279,34 @@ export class StatisticsChart extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
public resetZoom() {
|
||||
this._chartBase?.zoom(0, 100);
|
||||
}
|
||||
|
||||
// A click selects the period that contains it, a drag every period it
|
||||
// touches
|
||||
private _snapSelection = (
|
||||
range: ChartTimeRange
|
||||
): ChartTimeRange | undefined => {
|
||||
let start: number | undefined;
|
||||
let end: number | undefined;
|
||||
Object.values(this.statisticsData ?? {}).forEach((values) =>
|
||||
values.forEach((value) => {
|
||||
const touched =
|
||||
range.start === range.end
|
||||
? value.start <= range.start && range.start < value.end
|
||||
: value.start < range.end && value.end > range.start;
|
||||
if (touched) {
|
||||
start = Math.min(start ?? value.start, value.start);
|
||||
end = Math.max(end ?? value.end, value.end);
|
||||
}
|
||||
})
|
||||
);
|
||||
return start === undefined || end === undefined
|
||||
? undefined
|
||||
: { start, end };
|
||||
};
|
||||
|
||||
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
|
||||
this._hiddenStats.add(ev.detail.id);
|
||||
this.requestUpdate("_hiddenStats");
|
||||
|
||||
@@ -19,10 +19,8 @@ import {
|
||||
type CameraEntity,
|
||||
} from "../data/camera";
|
||||
import {
|
||||
hasWebRtcWorked,
|
||||
MJPEG_STREAM,
|
||||
selectCameraStreams,
|
||||
setWebRtcWorked,
|
||||
type CameraStream,
|
||||
type CameraStreamTracks,
|
||||
type CameraStreamType,
|
||||
@@ -39,9 +37,6 @@ import type { HaWebRtcPlayer } from "./ha-web-rtc-player";
|
||||
|
||||
export type { CameraStreamType } from "../data/camera-streams";
|
||||
|
||||
// How long WebRTC gets on its own before HLS is started as well
|
||||
const WEB_RTC_FIRST_TIMEOUT = 12000;
|
||||
|
||||
@customElement("ha-camera-stream")
|
||||
export class HaCameraStream extends LitElement {
|
||||
@state()
|
||||
@@ -86,22 +81,12 @@ export class HaCameraStream extends LitElement {
|
||||
|
||||
@state() private _webRtcStreams?: CameraStreamTracks;
|
||||
|
||||
// WebRTC worked before for this camera, so HLS is not started with it
|
||||
@state() private _webRtcFirst = false;
|
||||
|
||||
@query("ha-web-rtc-player") private _webRtcPlayer?: HaWebRtcPlayer;
|
||||
|
||||
private _visibleStreamType?: CameraStreamType;
|
||||
|
||||
private _webRtcFirstTimeout?: number;
|
||||
|
||||
private _capabilitiesRequest = 0;
|
||||
|
||||
private _streamsStartedAt = 0;
|
||||
|
||||
// If WebRTC loaded fast enough to be worth waiting for without HLS
|
||||
private _webRtcLoadedInTime?: boolean;
|
||||
|
||||
private _thumbnailApi = memoizeOne(
|
||||
(
|
||||
api: ContextType<typeof apiContext>,
|
||||
@@ -148,16 +133,15 @@ export class HaCameraStream extends LitElement {
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._connected = true;
|
||||
if (this.hasUpdated && this._capabilities && this.stateObj) {
|
||||
if (this.hasUpdated) {
|
||||
// The players start again when reattached, so their results do as well
|
||||
this._startStreams(this.stateObj.entity_id, this._capabilities);
|
||||
this._resetStreams();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._connected = false;
|
||||
this._stopWebRtcFirst();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
@@ -173,28 +157,6 @@ export class HaCameraStream extends LitElement {
|
||||
this._visibleStreamType = visibleStreamType;
|
||||
fireEvent(this, "stream-type-changed", { type: visibleStreamType });
|
||||
}
|
||||
this._rememberWebRtc(streams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers per camera if WebRTC is the stream that ends up playing in this
|
||||
* browser, to start it without HLS the next time.
|
||||
*/
|
||||
private _rememberWebRtc(streams: CameraStream[]) {
|
||||
if (
|
||||
!this.stateObj ||
|
||||
!this._webRtcStreams ||
|
||||
(this._capabilities?.frontend_stream_types.length ?? 0) < 2 ||
|
||||
streams.length !== 1
|
||||
) {
|
||||
// No choice between streams, or the choice is not final yet
|
||||
return;
|
||||
}
|
||||
setWebRtcWorked(
|
||||
this.stateObj.entity_id,
|
||||
streams[0].type === STREAM_TYPE_WEB_RTC &&
|
||||
this._webRtcLoadedInTime === true
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -271,8 +233,7 @@ export class HaCameraStream extends LitElement {
|
||||
return this._streams(
|
||||
this._capabilities?.frontend_stream_types,
|
||||
this._hlsStreams,
|
||||
this._webRtcStreams,
|
||||
this._webRtcFirst
|
||||
this._webRtcStreams
|
||||
);
|
||||
}
|
||||
|
||||
@@ -287,62 +248,19 @@ export class HaCameraStream extends LitElement {
|
||||
};
|
||||
return;
|
||||
}
|
||||
const entityId = this.stateObj!.entity_id;
|
||||
const capabilities = await fetchCameraCapabilities(this._api, entityId);
|
||||
const capabilities = await fetchCameraCapabilities(
|
||||
this._api,
|
||||
this.stateObj!.entity_id
|
||||
);
|
||||
if (request !== this._capabilitiesRequest) {
|
||||
return;
|
||||
}
|
||||
if (this.isConnected) {
|
||||
// Otherwise started when connected
|
||||
this._startStreams(entityId, capabilities);
|
||||
}
|
||||
this._capabilities = capabilities;
|
||||
}
|
||||
|
||||
private _resetStreams() {
|
||||
this._hlsStreams = undefined;
|
||||
this._webRtcStreams = undefined;
|
||||
this._webRtcLoadedInTime = undefined;
|
||||
this._stopWebRtcFirst();
|
||||
}
|
||||
|
||||
private _startStreams(entityId: string, capabilities: CameraCapabilities) {
|
||||
this._resetStreams();
|
||||
this._streamsStartedAt = performance.now();
|
||||
if (
|
||||
capabilities.frontend_stream_types.length > 1 &&
|
||||
hasWebRtcWorked(entityId)
|
||||
) {
|
||||
this._webRtcFirst = true;
|
||||
this._webRtcFirstTimeout = window.setTimeout(
|
||||
this._webRtcFirstTimedOut,
|
||||
WEB_RTC_FIRST_TIMEOUT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _webRtcFirstTimedOut = () => {
|
||||
if (document.hidden) {
|
||||
// A player started in the background is not stopped again, so wait
|
||||
this._webRtcFirstTimeout = window.setTimeout(
|
||||
this._webRtcFirstTimedOut,
|
||||
WEB_RTC_FIRST_TIMEOUT
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._webRtcFirstTimeout = undefined;
|
||||
// Start HLS as well, WebRTC keeps connecting and takes over when it loads
|
||||
this._webRtcFirst = false;
|
||||
if (this.stateObj) {
|
||||
// Don't wait for it the next time, also when it never reports a result
|
||||
setWebRtcWorked(this.stateObj.entity_id, false);
|
||||
}
|
||||
};
|
||||
|
||||
private _stopWebRtcFirst() {
|
||||
clearTimeout(this._webRtcFirstTimeout);
|
||||
this._webRtcFirstTimeout = undefined;
|
||||
this._webRtcFirst = false;
|
||||
}
|
||||
|
||||
private async _getPosterUrl(): Promise<void> {
|
||||
@@ -368,8 +286,6 @@ export class HaCameraStream extends LitElement {
|
||||
}
|
||||
|
||||
private _handleWebRtcStreams(ev: HASSDomEvent<HASSDomEvents["streams"]>) {
|
||||
clearTimeout(this._webRtcFirstTimeout);
|
||||
this._webRtcFirstTimeout = undefined;
|
||||
if (
|
||||
!ev.detail.hasVideo &&
|
||||
!ev.detail.hasAudio &&
|
||||
@@ -380,10 +296,6 @@ export class HaCameraStream extends LitElement {
|
||||
// Only a browser without WebRTC falls back to MJPEG.
|
||||
return;
|
||||
}
|
||||
if (ev.detail.hasVideo && this._webRtcLoadedInTime === undefined) {
|
||||
this._webRtcLoadedInTime =
|
||||
performance.now() - this._streamsStartedAt <= WEB_RTC_FIRST_TIMEOUT;
|
||||
}
|
||||
this._webRtcStreams = ev.detail;
|
||||
}
|
||||
|
||||
@@ -391,8 +303,7 @@ export class HaCameraStream extends LitElement {
|
||||
(
|
||||
supportedTypes?: CameraCapabilities["frontend_stream_types"],
|
||||
hlsStreams?: CameraStreamTracks,
|
||||
webRtcStreams?: CameraStreamTracks,
|
||||
webRtcFirst?: boolean
|
||||
webRtcStreams?: CameraStreamTracks
|
||||
): CameraStream[] => {
|
||||
if (__DEMO__) {
|
||||
return [{ type: MJPEG_STREAM, visible: true }];
|
||||
@@ -401,7 +312,6 @@ export class HaCameraStream extends LitElement {
|
||||
supportedTypes,
|
||||
hlsStreams,
|
||||
webRtcStreams,
|
||||
webRtcFirst,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -180,13 +180,14 @@ class HaHLSPlayer extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.entityid) {
|
||||
const entityId = this.entityid;
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { url } = await fetchStreamUrl(
|
||||
{ callWS: this._api.callWS, hassUrl: this._connection.hassUrl },
|
||||
this.entityid
|
||||
entityId
|
||||
);
|
||||
|
||||
this._url = this._connection.hassUrl(url);
|
||||
@@ -198,6 +199,10 @@ class HaHLSPlayer extends LitElement {
|
||||
// eslint-disable-next-line
|
||||
console.error(err);
|
||||
|
||||
if (!this.isConnected || entityId !== this.entityid) {
|
||||
// Removed or switched camera while fetching, the result is outdated
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "streams", { hasAudio: false, hasVideo: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,6 @@ import { consume } from "../common/decorators/consume";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { getIcePath } from "../common/webrtc/get-ice-path";
|
||||
import type { IcePath } from "../common/webrtc/limit-ice-servers";
|
||||
import {
|
||||
limitIceServers,
|
||||
limitIceServersToPath,
|
||||
} from "../common/webrtc/limit-ice-servers";
|
||||
import {
|
||||
addWebRtcCandidate,
|
||||
fetchWebRtcClientConfiguration,
|
||||
@@ -37,8 +31,10 @@ const ANSWER_TIMEOUT = 20000;
|
||||
// minutes to give up on their own, or never do.
|
||||
const ICE_TIMEOUT = 30000;
|
||||
|
||||
// How often a stream that lost or never got its connection is started again
|
||||
const MAX_RETRIES = 2;
|
||||
// How often a stream that lost its connection or got a backend error is
|
||||
// started again. A new connection that gets no answer or finds no path is not
|
||||
// retried, as a retry would most likely end the same way.
|
||||
const MAX_RETRIES = 1;
|
||||
|
||||
// Gives the backend time to tear down the failed session before the next one
|
||||
const RETRY_DELAY = 1000;
|
||||
@@ -192,16 +188,8 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
// Renegotiating the existing connection is not supported by all providers
|
||||
// (go2rtc answers with a new DTLS fingerprint, which Firefox rejects), so
|
||||
// start a new connection and session with the microphone track instead.
|
||||
// The new connection only looks for the path the running stream uses
|
||||
const icePath = this._peerConnection
|
||||
? await getIcePath(this._peerConnection)
|
||||
: undefined;
|
||||
if (request !== this._microphoneRequest || !this.isConnected) {
|
||||
track.stop();
|
||||
return;
|
||||
}
|
||||
this._captureReconnectFrame();
|
||||
await this._startWebRtc(track, icePath);
|
||||
await this._startWebRtc(track);
|
||||
}
|
||||
|
||||
private _captureReconnectFrame() {
|
||||
@@ -238,8 +226,9 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
private _retries = 0;
|
||||
|
||||
// The stream only loaded with all ICE servers, so keep using them
|
||||
private _allIceServers = false;
|
||||
// The stream showed video since it was started, so a lost connection is
|
||||
// worth a retry
|
||||
private _hasPlayed = false;
|
||||
|
||||
private _handleVisibilityChange = () => {
|
||||
if (document.pictureInPictureElement) {
|
||||
@@ -256,8 +245,7 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
clearTimeout(this._hiddenCleanupTimeout);
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
} else {
|
||||
this._retries = 0;
|
||||
this._startWebRtc();
|
||||
this._restartWebRtc();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -298,8 +286,7 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
public override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated && this.entityid) {
|
||||
this._retries = 0;
|
||||
this._startWebRtc();
|
||||
this._restartWebRtc();
|
||||
}
|
||||
document.addEventListener("visibilitychange", this._handleVisibilityChange);
|
||||
}
|
||||
@@ -320,14 +307,18 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
if (!changedProperties.has("entityid")) {
|
||||
return;
|
||||
}
|
||||
this._restartWebRtc();
|
||||
}
|
||||
|
||||
/** Starts a new stream, not a retry of the current one. */
|
||||
private _restartWebRtc() {
|
||||
this._retries = 0;
|
||||
this._allIceServers = false;
|
||||
this._hasPlayed = false;
|
||||
this._startWebRtc();
|
||||
}
|
||||
|
||||
private async _startWebRtc(
|
||||
localReturnAudioTrack?: MediaStreamTrack,
|
||||
icePath?: IcePath
|
||||
localReturnAudioTrack?: MediaStreamTrack
|
||||
): Promise<void> {
|
||||
this._cleanUp();
|
||||
|
||||
@@ -382,21 +373,9 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
this._logEvent("end clientConfig", this._clientConfig);
|
||||
|
||||
const { iceServers } = this._clientConfig.configuration;
|
||||
const allIceServers = this._allIceServers || this._retries >= MAX_RETRIES;
|
||||
|
||||
this._peerConnection = new RTCPeerConnection({
|
||||
...this._clientConfig.configuration,
|
||||
// The last attempt uses all ICE servers, in case a removed URL is the
|
||||
// only one that works in this network
|
||||
// A known relay is taken from all of them. A direct path is not, as
|
||||
// the URL it needs can be one that is removed.
|
||||
iceServers:
|
||||
(icePath &&
|
||||
(icePath.relayUrl || !allIceServers) &&
|
||||
limitIceServersToPath(iceServers, icePath)) ||
|
||||
(allIceServers ? iceServers : limitIceServers(iceServers)),
|
||||
});
|
||||
this._peerConnection = new RTCPeerConnection(
|
||||
this._clientConfig.configuration
|
||||
);
|
||||
|
||||
if (this._clientConfig.dataChannel) {
|
||||
// Some cameras (such as nest) require a data channel to establish a stream
|
||||
@@ -529,6 +508,9 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
private _retryOrFail(error: WebRtcPlayerError) {
|
||||
if (
|
||||
this._retries >= MAX_RETRIES ||
|
||||
// No answer or no path for a stream that never played, a retry would
|
||||
// most likely end the same way
|
||||
(error.type === "no_connection" && !this._hasPlayed) ||
|
||||
// A microphone connect the user started is handled by starting again
|
||||
// without the microphone. A retry keeps the microphone of the stream.
|
||||
(this._localReturnAudioTrack &&
|
||||
@@ -799,10 +781,8 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
this._logEvent("loadedData", data);
|
||||
this._stopTimer();
|
||||
if (this._retries >= MAX_RETRIES) {
|
||||
this._allIceServers = true;
|
||||
}
|
||||
this._retries = 0;
|
||||
this._hasPlayed = true;
|
||||
|
||||
// Without a track the microphone is still waiting for permission
|
||||
if (this._microphoneConnecting && this._localReturnAudioTrack) {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { addRecent } from "../common/array/add-recent";
|
||||
import { getStorage } from "../common/decorators/storage";
|
||||
|
||||
export const STREAM_TYPE_HLS = "hls";
|
||||
export const STREAM_TYPE_WEB_RTC = "web_rtc";
|
||||
|
||||
@@ -25,8 +22,6 @@ export interface CameraStreamSelectionOptions {
|
||||
supportedTypes?: StreamType[];
|
||||
hlsStreams?: CameraStreamTracks;
|
||||
webRtcStreams?: CameraStreamTracks;
|
||||
/** Only start WebRTC and keep HLS off until WebRTC fails or takes too long. */
|
||||
webRtcFirst?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,7 +32,6 @@ export const selectCameraStreams = ({
|
||||
supportedTypes,
|
||||
hlsStreams,
|
||||
webRtcStreams,
|
||||
webRtcFirst,
|
||||
}: CameraStreamSelectionOptions): CameraStream[] => {
|
||||
if (!supportedTypes) {
|
||||
return [];
|
||||
@@ -76,7 +70,7 @@ export const selectCameraStreams = ({
|
||||
}
|
||||
|
||||
// WebRTC is still loading
|
||||
if (hlsFailed || (webRtcFirst && !hlsStreams)) {
|
||||
if (hlsFailed) {
|
||||
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
|
||||
}
|
||||
return [
|
||||
@@ -84,32 +78,3 @@ export const selectCameraStreams = ({
|
||||
{ type: STREAM_TYPE_WEB_RTC, visible: false },
|
||||
];
|
||||
};
|
||||
|
||||
const WEB_RTC_ENTITIES_KEY = "cameraWebRtcEntities";
|
||||
|
||||
const MAX_WEB_RTC_ENTITIES = 100;
|
||||
|
||||
const getWebRtcEntities = (): string[] => {
|
||||
const entities = getStorage().getValue<string[]>(WEB_RTC_ENTITIES_KEY);
|
||||
return Array.isArray(entities) ? entities : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* If WebRTC was the stream that ended up playing for this camera in this
|
||||
* browser. Stored per browser, as it depends on the network of the client.
|
||||
*/
|
||||
export const hasWebRtcWorked = (entityId: string): boolean =>
|
||||
getWebRtcEntities().includes(entityId);
|
||||
|
||||
export const setWebRtcWorked = (entityId: string, worked: boolean): void => {
|
||||
const entities = getWebRtcEntities();
|
||||
if (entities.includes(entityId) === worked) {
|
||||
return;
|
||||
}
|
||||
getStorage().setValue(
|
||||
WEB_RTC_ENTITIES_KEY,
|
||||
worked
|
||||
? addRecent(entities, entityId, MAX_WEB_RTC_ENTITIES)
|
||||
: entities.filter((entity) => entity !== entityId)
|
||||
);
|
||||
};
|
||||
@@ -167,16 +167,12 @@ export class HaMoreInfoFavorites extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: calc(var(--ha-space-2) * -1);
|
||||
gap: var(--ha-space-4);
|
||||
flex-wrap: wrap;
|
||||
max-width: var(--favorite-items-max-width, 250px);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.container > * {
|
||||
margin: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.favorite {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
DataZoomComponent,
|
||||
VisualMapComponent,
|
||||
ToolboxComponent,
|
||||
MarkAreaComponent,
|
||||
BrushComponent,
|
||||
} from "echarts/components";
|
||||
|
||||
// Features like Universal Transition and Label Layout
|
||||
@@ -48,6 +50,8 @@ import type {
|
||||
GridComponentOption,
|
||||
DataZoomComponentOption,
|
||||
VisualMapComponentOption,
|
||||
MarkAreaComponentOption,
|
||||
BrushComponentOption,
|
||||
} from "echarts/components";
|
||||
import type { ComposeOption } from "echarts/core";
|
||||
|
||||
@@ -62,6 +66,8 @@ export type ECOption = ComposeOption<
|
||||
| GridComponentOption
|
||||
| DataZoomComponentOption
|
||||
| VisualMapComponentOption
|
||||
| MarkAreaComponentOption
|
||||
| BrushComponentOption
|
||||
| SankeySeriesOption
|
||||
| GraphSeriesOption
|
||||
| SunburstSeriesOption
|
||||
@@ -91,6 +97,8 @@ echarts.use([
|
||||
UniversalTransition,
|
||||
CanvasRenderer,
|
||||
ToolboxComponent,
|
||||
MarkAreaComponent,
|
||||
BrushComponent,
|
||||
]);
|
||||
|
||||
export { LinearGradient };
|
||||
|
||||
@@ -3285,13 +3285,13 @@
|
||||
},
|
||||
"mcp": {
|
||||
"header": "Model Context Protocol",
|
||||
"description": "Add one of the URLs below to your AI agent. See the {documentation_link} for agent-specific instructions.",
|
||||
"description_disabled": "See the {documentation_link} to learn how to connect your AI agent to Home Assistant.",
|
||||
"description": "Add one of the URLs below to your AI app. See the {documentation_link} for app-specific instructions.",
|
||||
"description_disabled": "See the {documentation_link} to learn how to connect your AI app to Home Assistant.",
|
||||
"documentation": "documentation",
|
||||
"enable": "[%key:ui::common::enable%]",
|
||||
"disable": "[%key:ui::common::disable%]",
|
||||
"disable_confirm_title": "Disable the Model Context Protocol server?",
|
||||
"disable_confirm_text": "AI agents connected to the server will lose access to Home Assistant.",
|
||||
"disable_confirm_text": "AI apps connected to the server will lose access to Home Assistant.",
|
||||
"your_api_header": "Main URL",
|
||||
"apis_header": "Individual URLs",
|
||||
"copy_url": "Copy URL",
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
limitIceServers,
|
||||
limitIceServersToPath,
|
||||
} from "../../../src/common/webrtc/limit-ice-servers";
|
||||
|
||||
// Redundant URLs cost seconds of connection time, while dropping the wrong
|
||||
// one (the UDP or the firewall friendly TLS relay) breaks remote access.
|
||||
describe("limitIceServers", () => {
|
||||
it("works", () => {
|
||||
expect(limitIceServers(undefined)).toBeUndefined();
|
||||
expect(
|
||||
limitIceServers([
|
||||
{ urls: "stun:stun.example.com:3478" },
|
||||
{ urls: ["stun:a.example.com:3478", "stun:a.example.com:80"] },
|
||||
{
|
||||
urls: [
|
||||
"turn:t.example.com:3478?transport=udp",
|
||||
"turn:t.example.com:3478?transport=tcp",
|
||||
"turns:t.example.com:5349?transport=tcp",
|
||||
"turn:t.example.com:443?transport=udp",
|
||||
"turn:t.example.com:80?transport=tcp",
|
||||
"turns:t.example.com:443?transport=tcp",
|
||||
],
|
||||
username: "user",
|
||||
credential: "secret",
|
||||
},
|
||||
{
|
||||
urls: [
|
||||
"turn:u.example.com:80?transport=tcp",
|
||||
"turn:u.example.com:443?transport=tcp",
|
||||
],
|
||||
},
|
||||
// Different hosts are different network paths, all are kept
|
||||
{
|
||||
urls: [
|
||||
"stun:v.example.com",
|
||||
"turn:v.example.com:3478",
|
||||
"turn:v.example.com:3479",
|
||||
"turn:w.example.com:3478",
|
||||
],
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
{ urls: "stun:stun.example.com:3478" },
|
||||
{ urls: ["stun:a.example.com:3478"] },
|
||||
{
|
||||
urls: [
|
||||
"turn:t.example.com:3478?transport=udp",
|
||||
"turns:t.example.com:443?transport=tcp",
|
||||
],
|
||||
username: "user",
|
||||
credential: "secret",
|
||||
},
|
||||
{ urls: ["turn:u.example.com:80?transport=tcp"] },
|
||||
{
|
||||
urls: [
|
||||
"stun:v.example.com",
|
||||
"turn:v.example.com:3478",
|
||||
"turn:w.example.com:3478",
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const iceServers = [
|
||||
{ urls: ["stun:a.example.com:3478", "stun:a.example.com:80"] },
|
||||
{
|
||||
urls: ["turn:t.example.com", "turns:t.example.com:443"],
|
||||
username: "user",
|
||||
},
|
||||
];
|
||||
// A direct path keeps the UDP relay, which is also used to find the
|
||||
// public address. A relayed one only needs its own relay.
|
||||
expect(limitIceServersToPath(iceServers, {})).toEqual([
|
||||
{ urls: ["stun:a.example.com:3478"] },
|
||||
{ urls: ["turn:t.example.com"], username: "user" },
|
||||
]);
|
||||
// Browsers report the URL with the port and transport that were left out
|
||||
expect(
|
||||
limitIceServersToPath(iceServers, {
|
||||
relayUrl: "turns:t.example.com:443?transport=tcp",
|
||||
})
|
||||
).toEqual([{ urls: ["turns:t.example.com:443"], username: "user" }]);
|
||||
expect(
|
||||
limitIceServersToPath(iceServers, {
|
||||
relayUrl: "turn:t.example.com:3478?transport=udp",
|
||||
})
|
||||
).toEqual([{ urls: ["turn:t.example.com"], username: "user" }]);
|
||||
expect(
|
||||
limitIceServersToPath(iceServers, { relayUrl: "turn:other.example.com" })
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -27,11 +27,9 @@ describe("selectCameraStreams", () => {
|
||||
selectCameraStreams({ supportedTypes: ["hls"], hlsStreams: failed })
|
||||
).toEqual([{ type: "mjpeg", visible: true }]);
|
||||
|
||||
// Loading: HLS is shown until WebRTC is ready, unless WebRTC goes first
|
||||
// Loading: HLS is shown until WebRTC is ready
|
||||
expect(select({})).toBe("hls (web_rtc)");
|
||||
expect(select({ hlsStreams: ok })).toBe("hls (web_rtc)");
|
||||
expect(select({ webRtcFirst: true })).toBe("web_rtc");
|
||||
expect(select({ webRtcFirst: true, hlsStreams: ok })).toBe("hls (web_rtc)");
|
||||
expect(select({ hlsStreams: failed })).toBe("web_rtc");
|
||||
|
||||
// WebRTC loaded: always preferred, also when only HLS has audio
|
||||
|
||||
Reference in new issue
Block a user