mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-12 16:04:15 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77170b447f | ||
|
|
e2bb744202 | ||
|
|
0eec83448a |
No files matched your search
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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