Compare commits

..
Author SHA1 Message Date
Petar Petrov ad5b4a269c Remove unused cost_adjustment_day from grid sources 2026-11-12 15:53:12 +02:00
15 changed files with 441 additions and 52 deletions

No files matched your search

-2
View File
@@ -21,7 +21,6 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid",
cost_adjustment_day: 0,
},
{
type: "grid",
@@ -34,7 +33,6 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
entity_energy_price_export: null,
number_energy_price_export: null,
stat_rate: "sensor.power_grid_return",
cost_adjustment_day: 0,
},
{
type: "solar",
+54
View File
@@ -0,0 +1,54 @@
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 {};
};
+103
View File
@@ -0,0 +1,103 @@
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);
};
+98 -8
View File
@@ -19,8 +19,10 @@ import {
type CameraEntity,
} from "../data/camera";
import {
hasWebRtcWorked,
MJPEG_STREAM,
selectCameraStreams,
setWebRtcWorked,
type CameraStream,
type CameraStreamTracks,
type CameraStreamType,
@@ -37,6 +39,9 @@ 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()
@@ -81,12 +86,22 @@ 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>,
@@ -133,15 +148,16 @@ export class HaCameraStream extends LitElement {
public connectedCallback() {
super.connectedCallback();
this._connected = true;
if (this.hasUpdated) {
if (this.hasUpdated && this._capabilities && this.stateObj) {
// The players start again when reattached, so their results do as well
this._resetStreams();
this._startStreams(this.stateObj.entity_id, this._capabilities);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._connected = false;
this._stopWebRtcFirst();
}
protected updated(changedProps: PropertyValues<this>): void {
@@ -157,6 +173,28 @@ 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() {
@@ -233,7 +271,8 @@ export class HaCameraStream extends LitElement {
return this._streams(
this._capabilities?.frontend_stream_types,
this._hlsStreams,
this._webRtcStreams
this._webRtcStreams,
this._webRtcFirst
);
}
@@ -248,19 +287,62 @@ export class HaCameraStream extends LitElement {
};
return;
}
const capabilities = await fetchCameraCapabilities(
this._api,
this.stateObj!.entity_id
);
const entityId = this.stateObj!.entity_id;
const capabilities = await fetchCameraCapabilities(this._api, entityId);
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> {
@@ -286,6 +368,8 @@ export class HaCameraStream extends LitElement {
}
private _handleWebRtcStreams(ev: HASSDomEvent<HASSDomEvents["streams"]>) {
clearTimeout(this._webRtcFirstTimeout);
this._webRtcFirstTimeout = undefined;
if (
!ev.detail.hasVideo &&
!ev.detail.hasAudio &&
@@ -296,6 +380,10 @@ 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;
}
@@ -303,7 +391,8 @@ export class HaCameraStream extends LitElement {
(
supportedTypes?: CameraCapabilities["frontend_stream_types"],
hlsStreams?: CameraStreamTracks,
webRtcStreams?: CameraStreamTracks
webRtcStreams?: CameraStreamTracks,
webRtcFirst?: boolean
): CameraStream[] => {
if (__DEMO__) {
return [{ type: MJPEG_STREAM, visible: true }];
@@ -312,6 +401,7 @@ export class HaCameraStream extends LitElement {
supportedTypes,
hlsStreams,
webRtcStreams,
webRtcFirst,
});
}
);
+2 -7
View File
@@ -180,14 +180,13 @@ class HaHLSPlayer extends LitElement {
return;
}
const entityId = this.entityid;
if (!entityId) {
if (!this.entityid) {
return;
}
try {
const { url } = await fetchStreamUrl(
{ callWS: this._api.callWS, hassUrl: this._connection.hassUrl },
entityId
this.entityid
);
this._url = this._connection.hassUrl(url);
@@ -199,10 +198,6 @@ 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 });
}
}
+44 -24
View File
@@ -9,6 +9,12 @@ 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,
@@ -31,10 +37,8 @@ const ANSWER_TIMEOUT = 20000;
// minutes to give up on their own, or never do.
const ICE_TIMEOUT = 30000;
// 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;
// How often a stream that lost or never got its connection is started again
const MAX_RETRIES = 2;
// Gives the backend time to tear down the failed session before the next one
const RETRY_DELAY = 1000;
@@ -188,8 +192,16 @@ 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);
await this._startWebRtc(track, icePath);
}
private _captureReconnectFrame() {
@@ -226,9 +238,8 @@ export class HaWebRtcPlayer extends LitElement {
private _retries = 0;
// The stream showed video since it was started, so a lost connection is
// worth a retry
private _hasPlayed = false;
// The stream only loaded with all ICE servers, so keep using them
private _allIceServers = false;
private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
@@ -245,7 +256,8 @@ export class HaWebRtcPlayer extends LitElement {
clearTimeout(this._hiddenCleanupTimeout);
this._hiddenCleanupTimeout = undefined;
} else {
this._restartWebRtc();
this._retries = 0;
this._startWebRtc();
}
};
@@ -286,7 +298,8 @@ export class HaWebRtcPlayer extends LitElement {
public override connectedCallback() {
super.connectedCallback();
if (this.hasUpdated && this.entityid) {
this._restartWebRtc();
this._retries = 0;
this._startWebRtc();
}
document.addEventListener("visibilitychange", this._handleVisibilityChange);
}
@@ -307,18 +320,14 @@ 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._hasPlayed = false;
this._allIceServers = false;
this._startWebRtc();
}
private async _startWebRtc(
localReturnAudioTrack?: MediaStreamTrack
localReturnAudioTrack?: MediaStreamTrack,
icePath?: IcePath
): Promise<void> {
this._cleanUp();
@@ -373,9 +382,21 @@ export class HaWebRtcPlayer extends LitElement {
this._logEvent("end clientConfig", this._clientConfig);
this._peerConnection = new RTCPeerConnection(
this._clientConfig.configuration
);
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)),
});
if (this._clientConfig.dataChannel) {
// Some cameras (such as nest) require a data channel to establish a stream
@@ -508,9 +529,6 @@ 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 &&
@@ -781,8 +799,10 @@ 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) {
+36 -1
View File
@@ -1,3 +1,6 @@
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";
@@ -22,6 +25,8 @@ 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;
}
/**
@@ -32,6 +37,7 @@ export const selectCameraStreams = ({
supportedTypes,
hlsStreams,
webRtcStreams,
webRtcFirst,
}: CameraStreamSelectionOptions): CameraStream[] => {
if (!supportedTypes) {
return [];
@@ -70,7 +76,7 @@ export const selectCameraStreams = ({
}
// WebRTC is still loading
if (hlsFailed) {
if (hlsFailed || (webRtcFirst && !hlsStreams)) {
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
}
return [
@@ -78,3 +84,32 @@ 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)
);
};
-2
View File
@@ -81,7 +81,6 @@ export const emptyGridSourceEnergyPreference =
number_energy_price: null,
entity_energy_price_export: null,
number_energy_price_export: null,
cost_adjustment_day: 0,
});
export const emptySolarEnergyPreference =
@@ -169,7 +168,6 @@ export interface GridSourceTypeEnergyPreference {
stat_rate?: string; // always available if power_config is set
power_config?: PowerConfig;
cost_adjustment_day: number;
name?: string;
}
@@ -167,12 +167,16 @@ export class HaMoreInfoFavorites extends LitElement {
display: flex;
align-items: center;
justify-content: center;
gap: var(--ha-space-4);
margin: calc(var(--ha-space-2) * -1);
flex-wrap: wrap;
max-width: var(--favorite-items-max-width, 250px);
user-select: none;
}
.container > * {
margin: var(--ha-space-2);
}
.favorite {
display: block;
}
@@ -768,7 +768,6 @@ export class HaEnergyGridSourceEditor extends EnergyEditorMixin(
number_energy_price: draft.number_energy_price,
entity_energy_price_export: draft.entity_energy_price_export,
number_energy_price_export: draft.number_energy_price_export,
cost_adjustment_day: draft.cost_adjustment_day,
};
if (draft.name) {
source.name = draft.name;
+3 -3
View File
@@ -3285,13 +3285,13 @@
},
"mcp": {
"header": "Model Context Protocol",
"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.",
"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.",
"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 apps connected to the server will lose access to Home Assistant.",
"disable_confirm_text": "AI agents connected to the server will lose access to Home Assistant.",
"your_api_header": "Main URL",
"apis_header": "Individual URLs",
"copy_url": "Copy URL",
@@ -0,0 +1,93 @@
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();
});
});
+3 -1
View File
@@ -27,9 +27,11 @@ describe("selectCameraStreams", () => {
selectCameraStreams({ supportedTypes: ["hls"], hlsStreams: failed })
).toEqual([{ type: "mjpeg", visible: true }]);
// Loading: HLS is shown until WebRTC is ready
// Loading: HLS is shown until WebRTC is ready, unless WebRTC goes first
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
-1
View File
@@ -2104,7 +2104,6 @@ describe("getEnergyDataCollection gas energy unit", () => {
stat_compensation: null,
entity_energy_price_export: null,
number_energy_price_export: null,
cost_adjustment_day: 0,
},
solar: {
type: "solar",
-1
View File
@@ -42,7 +42,6 @@ export const generateEnergyPreferences = (
stat_compensation: null,
entity_energy_price_export: null,
number_energy_price_export: null,
cost_adjustment_day: 0,
});
}
if (options.solar) {