20221206.0 (#14585)

This commit is contained in:
Bram Kragten 2022-12-06 18:35:05 +01:00 committed by GitHub
commit dacef605c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 637 additions and 252 deletions

18
.gitignore vendored
View File

@ -2,20 +2,20 @@
.reify-cache
# build
build
hass_frontend/*
dist
translations
build/
dist/
/hass_frontend/
/translations/
# yarn
.yarn/*
.yarn/**
!.yarn/patches
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
.pnp.*
node_modules/*
/node_modules/
yarn-error.log
npm-debug.log
@ -27,11 +27,11 @@ npm-debug.log
# venv stuff
pyvenv.cfg
pip-selfcheck.json
venv/*
/venv/
.venv
# vscode
.vscode/*
.vscode/**
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/tasks.json
@ -46,4 +46,4 @@ src/cast/dev_const.ts
.tool-versions
# Home Assistant config
/config
/config/

View File

@ -181,7 +181,7 @@ class HcCast extends LitElement {
private async _handlePickView(ev: Event) {
const path = (ev.currentTarget as any).getAttribute("data-path");
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, path);
castSendShowLovelaceView(this.castManager, path, this.auth.data.hassUrl);
}
private async _handleLogout() {

View File

@ -33,7 +33,6 @@ import { castContext } from "../cast_context";
import "./hc-launch-screen";
let resourcesLoaded = false;
@customElement("hc-main")
export class HcMain extends HassElement {
@state() private _showDemo = false;
@ -46,6 +45,8 @@ export class HcMain extends HassElement {
@state() private _urlPath?: string | null;
private _hassUUID?: string;
private _unsubLovelace?: UnsubscribeFunc;
public processIncomingMessage(msg: HassMessage) {
@ -125,6 +126,7 @@ export class HcMain extends HassElement {
if (this.hass) {
status.hassUrl = this.hass.auth.data.hassUrl;
status.hassUUID = this._hassUUID;
status.lovelacePath = this._lovelacePath;
status.urlPath = this._urlPath;
}
@ -163,6 +165,18 @@ export class HcMain extends HassElement {
};
private async _handleGetStatusMessage(msg: GetStatusMessage) {
if (
(this.hass && msg.hassUUID && msg.hassUUID !== this._hassUUID) ||
(this.hass && msg.hassUrl && msg.hassUrl !== this.hass.auth.data.hassUrl)
) {
this._error = "Not connected to the same Home Assistant instance.";
this._sendError(
ReceiverErrorCode.WRONG_INSTANCE,
this._error,
msg.senderId!
);
}
this._sendStatus(msg.senderId!);
}
@ -179,6 +193,7 @@ export class HcMain extends HassElement {
expires_in: 0,
}),
});
this._hassUUID = msg.hassUUID;
} catch (err: any) {
const errorMessage = this._getErrorMessage(err);
this._error = errorMessage;
@ -209,9 +224,29 @@ export class HcMain extends HassElement {
if (!this.hass) {
this._sendStatus(msg.senderId!);
this._error = "Cannot show Lovelace because we're not connected.";
this._sendError(ReceiverErrorCode.NOT_CONNECTED, this._error);
this._sendError(
ReceiverErrorCode.NOT_CONNECTED,
this._error,
msg.senderId!
);
return;
}
if (
(msg.hassUUID && msg.hassUUID !== this._hassUUID) ||
(msg.hassUrl && msg.hassUrl !== this.hass.auth.data.hassUrl)
) {
this._sendStatus(msg.senderId!);
this._error =
"Cannot show Lovelace because we're not connected to the same Home Assistant instance.";
this._sendError(
ReceiverErrorCode.WRONG_INSTANCE,
this._error,
msg.senderId!
);
return;
}
this._error = undefined;
if (msg.urlPath === "lovelace") {
msg.urlPath = null;

View File

@ -4,14 +4,12 @@ import {
} from "home-assistant-js-websocket";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeDomain } from "../../../../src/common/entity/compute_domain";
import { computeStateDisplay } from "../../../../src/common/entity/compute_state_display";
import { stateColorCss } from "../../../../src/common/entity/state_color";
import { stateIconPath } from "../../../../src/common/entity/state_icon_path";
import "../../../../src/components/data-table/ha-data-table";
import type { DataTableColumnContainer } from "../../../../src/components/data-table/ha-data-table";
import "../../../../src/components/entity/state-badge";
import "../../../../src/components/ha-chip";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import { HomeAssistant } from "../../../../src/types";
@ -105,6 +103,12 @@ const ENTITIES: HassEntity[] = [
createEntity("alarm_control_panel.arming", "arming"),
createEntity("alarm_control_panel.disarming", "disarming"),
createEntity("alarm_control_panel.triggered", "triggered"),
// Alert
createEntity("alert.off", "off"),
createEntity("alert.on", "on"),
// Automation
createEntity("automation.off", "off"),
createEntity("automation.on", "on"),
// Binary Sensor
...BINARY_SENSOR_DEVICE_CLASSES.map((dc) =>
createEntity(`binary_sensor.${dc}`, "on", dc)
@ -113,8 +117,11 @@ const ENTITIES: HassEntity[] = [
createEntity("button.restart", "unknown", "restart"),
createEntity("button.update", "unknown", "update"),
// Calendar
createEntity("calendar.on", "on"),
createEntity("calendar.off", "off"),
createEntity("calendar.on", "on"),
// Camera
createEntity("camera.off", "off"),
createEntity("camera.on", "on"),
// Climate
createEntity("climate.off", "off"),
createEntity("climate.heat", "heat"),
@ -124,10 +131,10 @@ const ENTITIES: HassEntity[] = [
createEntity("climate.dry", "dry"),
createEntity("climate.fan_only", "fan_only"),
// Cover
createEntity("cover.opening", "opening"),
createEntity("cover.open", "open"),
createEntity("cover.closing", "closing"),
createEntity("cover.closed", "closed"),
createEntity("cover.opening", "opening"),
createEntity("cover.open", "open"),
createEntity("cover.awning", "open", "awning"),
createEntity("cover.blind", "open", "blind"),
createEntity("cover.curtain", "open", "curtain"),
@ -139,24 +146,27 @@ const ENTITIES: HassEntity[] = [
createEntity("cover.shutter", "open", "shutter"),
createEntity("cover.window", "open", "window"),
// Device tracker/person
createEntity("device_tracker.home", "home"),
createEntity("device_tracker.not_home", "not_home"),
createEntity("device_tracker.home", "home"),
createEntity("device_tracker.work", "work"),
createEntity("person.home", "home"),
createEntity("person.not_home", "not_home"),
createEntity("person.work", "work"),
// Fan
createEntity("fan.on", "on"),
createEntity("fan.off", "off"),
createEntity("fan.on", "on"),
// Humidifier
createEntity("humidifier.on", "on"),
createEntity("humidifier.off", "off"),
createEntity("humidifier.on", "on"),
// Helpers
createEntity("input_boolean.off", "off"),
createEntity("input_boolean.on", "on"),
// Light
createEntity("light.on", "on"),
createEntity("light.off", "off"),
createEntity("light.on", "on"),
// Locks
createEntity("lock.locked", "locked"),
createEntity("lock.unlocked", "unlocked"),
createEntity("lock.locked", "locked"),
createEntity("lock.locking", "locking"),
createEntity("lock.unlocking", "unlocking"),
createEntity("lock.jammed", "jammed"),
@ -180,6 +190,12 @@ const ENTITIES: HassEntity[] = [
createEntity("media_player.speaker_playing", "playing", "speaker"),
createEntity("media_player.speaker_paused", "paused", "speaker"),
createEntity("media_player.speaker_standby", "standby", "speaker"),
// Remote
createEntity("remote.off", "off"),
createEntity("remote.on", "on"),
// Script
createEntity("script.off", "off"),
createEntity("script.on", "on"),
// Sensor
...SENSOR_DEVICE_CLASSES.map((dc) => createEntity(`sensor.${dc}`, "10", dc)),
// Battery sensor
@ -196,9 +212,12 @@ const ENTITIES: HassEntity[] = [
createEntity("switch.outlet_on", "on", "outlet"),
createEntity("switch.switch_off", "off", "switch"),
createEntity("switch.switch_on", "on", "switch"),
// Timer
createEntity("timer.off", "off"),
createEntity("timer.on", "on"),
// Vacuum
createEntity("vacuum.cleaning", "cleaning"),
createEntity("vacuum.docked", "docked"),
createEntity("vacuum.cleaning", "cleaning"),
createEntity("vacuum.paused", "paused"),
createEntity("vacuum.idle", "idle"),
createEntity("vacuum.returning", "returning"),
@ -280,18 +299,12 @@ export class DemoEntityState extends LitElement {
const columns: DataTableColumnContainer<EntityRowData> = {
icon: {
title: "Icon",
template: (_, entry) => {
const cssColor = stateColorCss(entry.stateObj);
return html`
<ha-svg-icon
style=${styleMap({
color: `rgb(${cssColor})`,
})}
.path=${stateIconPath(entry.stateObj)}
>
</ha-svg-icon>
`;
},
template: (_, entry) => html`
<state-badge
.stateObj=${entry.stateObj}
.stateColor=${true}
></state-badge>
`,
},
entity_id: {
title: "Entity id",

0
hassio/.gitignore vendored
View File

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20221205.0"
version = "20221206.0"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"

View File

@ -8,6 +8,8 @@ import { BaseCastMessage } from "./types";
export interface GetStatusMessage extends BaseCastMessage {
type: "get_status";
hassUrl?: string;
hassUUID?: string;
}
export interface ConnectMessage extends BaseCastMessage {
@ -15,12 +17,15 @@ export interface ConnectMessage extends BaseCastMessage {
refreshToken: string;
clientId: string | null;
hassUrl: string;
hassUUID?: string;
}
export interface ShowLovelaceViewMessage extends BaseCastMessage {
type: "show_lovelace_view";
viewPath: string | number | null;
urlPath: string | null;
hassUrl: string;
hassUUID?: string;
}
export interface ShowDemoMessage extends BaseCastMessage {
@ -43,6 +48,7 @@ export const castSendAuth = (cast: CastManager, auth: Auth) =>
export const castSendShowLovelaceView = (
cast: CastManager,
hassUrl: string,
viewPath: ShowLovelaceViewMessage["viewPath"],
urlPath?: string | null
) =>
@ -50,6 +56,7 @@ export const castSendShowLovelaceView = (
type: "show_lovelace_view",
viewPath,
urlPath: urlPath || null,
hassUrl: CAST_DEV ? CAST_DEV_HASS_URL : hassUrl,
});
export const castSendShowDemo = (cast: CastManager) =>

View File

@ -7,6 +7,7 @@ export interface ReceiverStatusMessage extends BaseCastMessage {
connected: boolean;
showDemo: boolean;
hassUrl?: string;
hassUUID?: string;
lovelacePath?: string | number | null;
urlPath?: string | null;
}
@ -23,6 +24,7 @@ export const enum ReceiverErrorCode {
CONNECTION_LOST = 3,
HASS_URL_MISSING = 4,
NO_HTTPS = 5,
WRONG_INSTANCE = 20,
NOT_CONNECTED = 21,
FETCH_CONFIG_FAILED = 22,
}

View File

@ -188,6 +188,14 @@ export const DOMAINS_WITH_CARD = [
"water_heater",
];
export const SENSOR_ENTITIES = [
"sensor",
"binary_sensor",
"camera",
"device_tracker",
"weather",
];
/** Domains that render an input element instead of a text value when displayed in a row.
* Those rows should then not show a cursor pointer when hovered (which would normally
* be the default) unless the element itself enforces it (e.g. a button). Also those elements

View File

@ -0,0 +1,10 @@
import { HassEntity } from "home-assistant-js-websocket";
export const personColor = (stateObj: HassEntity): string | undefined => {
switch (stateObj.state) {
case "home":
return "person-home";
default:
return "person-zone";
}
};

View File

@ -33,6 +33,10 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
return compareState === "problem";
case "group":
return ["on", "home", "open", "locked", "problem"].includes(compareState);
case "timer":
return compareState === "active";
case "camera":
return compareState === "streaming";
default:
return true;
}

View File

@ -5,6 +5,7 @@ import { alarmControlPanelColor } from "./color/alarm_control_panel_color";
import { binarySensorColor } from "./color/binary_sensor_color";
import { climateColor } from "./color/climate_color";
import { lockColor } from "./color/lock_color";
import { personColor } from "./color/person_color";
import { sensorColor } from "./color/sensor_color";
import { computeDomain } from "./compute_domain";
import { stateActive } from "./state_active";
@ -28,6 +29,9 @@ export const stateColor = (stateObj: HassEntity, state?: string) => {
const domain = computeDomain(stateObj.entity_id);
switch (domain) {
case "automation":
return "automation";
case "alarm_control_panel":
return alarmControlPanelColor(compareState);
@ -55,6 +59,10 @@ export const stateColor = (stateObj: HassEntity, state?: string) => {
case "media_player":
return "media-player";
case "person":
case "device_tracker":
return personColor(stateObj);
case "sensor":
return sensorColor(stateObj);
@ -68,8 +76,20 @@ export const stateColor = (stateObj: HassEntity, state?: string) => {
return compareState === "above_horizon" ? "sun-day" : "sun-night";
case "switch":
case "input_boolean":
return "switch";
case "alert":
return "alert";
case "calendar":
case "camera":
case "remote":
case "script":
case "timer":
case "group":
return "active";
case "update":
return updateIsInstalling(stateObj as UpdateEntity)
? "update-installing"

View File

@ -1,29 +1,6 @@
import { css } from "lit";
export const iconColorCSS = css`
ha-state-icon[data-active][data-domain="alert"],
ha-state-icon[data-active][data-domain="automation"],
ha-state-icon[data-active][data-domain="binary_sensor"],
ha-state-icon[data-active][data-domain="calendar"],
ha-state-icon[data-active][data-domain="camera"],
ha-state-icon[data-active][data-domain="cover"],
ha-state-icon[data-active][data-domain="device_tracker"],
ha-state-icon[data-active][data-domain="fan"],
ha-state-icon[data-active][data-domain="humidifier"],
ha-state-icon[data-active][data-domain="light"],
ha-state-icon[data-active][data-domain="input_boolean"],
ha-state-icon[data-active][data-domain="lock"],
ha-state-icon[data-active][data-domain="media_player"],
ha-state-icon[data-active][data-domain="remote"],
ha-state-icon[data-active][data-domain="script"],
ha-state-icon[data-active][data-domain="sun"],
ha-state-icon[data-active][data-domain="switch"],
ha-state-icon[data-active][data-domain="timer"],
ha-state-icon[data-active][data-domain="vacuum"],
ha-state-icon[data-active][data-domain="group"] {
color: var(--paper-item-icon-active-color, #fdd835);
}
ha-state-icon[data-active][data-domain="alarm_control_panel"][data-state="pending"],
ha-state-icon[data-active][data-domain="alarm_control_panel"][data-state="arming"],
ha-state-icon[data-active][data-domain="alarm_control_panel"][data-state="triggered"] {

View File

@ -26,7 +26,7 @@ class StateHistoryChartLine extends LitElement {
@property() public identifier?: string;
@property({ type: Boolean }) public isSingleDevice = false;
@property({ type: Boolean }) public showNames = true;
@property({ attribute: false }) public endTime!: Date;
@ -101,7 +101,7 @@ class StateHistoryChartLine extends LitElement {
propagate: true,
},
legend: {
display: !this.isSingleDevice,
display: this.showNames,
labels: {
usePointStyle: true,
},

View File

@ -1,73 +1,14 @@
import type { ChartData, ChartDataset, ChartOptions } from "chart.js";
import { HassEntity } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { getGraphColorByIndex } from "../../common/color/colors";
import { rgb2hex } from "../../common/color/convert-color";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { stateActive } from "../../common/entity/state_active";
import { stateColor } from "../../common/entity/state_color";
import { numberFormatToLocale } from "../../common/number/format_number";
import { computeRTL } from "../../common/util/compute_rtl";
import { TimelineEntity } from "../../data/history";
import { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { TimeLineData } from "./timeline-chart/const";
const stateColorTokenMap: Map<string, string> = new Map();
const stateColorMap: Map<string, string> = new Map();
let colorIndex = 0;
export const getStateColorToken = (
stateString: string,
entityState?: HassEntity
) => {
if (!entityState || !stateActive(entityState, stateString)) {
return `disabled`;
}
const color = stateColor(entityState, stateString);
if (color) {
return `state-${color}`;
}
return undefined;
};
const getColor = (
stateString: string,
computedStyles: CSSStyleDeclaration,
entityState?: HassEntity
) => {
const stateColorToken = getStateColorToken(stateString, entityState);
if (stateColorToken) {
if (stateColorTokenMap.has(stateColorToken)) {
return stateColorTokenMap.get(stateColorToken);
}
const value = computedStyles.getPropertyValue(
`--rgb-${stateColorToken}-color`
);
if (value) {
const parsedValue = value.split(",").map((v) => Number(v)) as [
number,
number,
number
];
const hexValue = rgb2hex(parsedValue);
stateColorTokenMap.set(stateColorToken, hexValue);
return hexValue;
}
}
if (stateColorMap.has(stateString)) {
return stateColorMap.get(stateString);
}
const color = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
stateColorMap.set(stateString, color);
return color;
};
import { computeTimelineColor } from "./timeline-chart/timeline-color";
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@ -83,7 +24,7 @@ export class StateHistoryChartTimeline extends LitElement {
@property() public identifier?: string;
@property({ type: Boolean }) public isSingleDevice = false;
@property({ type: Boolean }) public showNames = true;
@property({ type: Boolean }) public chunked = false;
@ -175,8 +116,7 @@ export class StateHistoryChartTimeline extends LitElement {
drawTicks: false,
},
ticks: {
display:
this.chunked || !this.isSingleDevice || this.data.length !== 1,
display: this.chunked || this.showNames,
},
afterSetDimensions: (y) => {
y.maxWidth = y.chart.width * 0.18;
@ -271,7 +211,7 @@ export class StateHistoryChartTimeline extends LitElement {
start: prevLastChanged,
end: newLastChanged,
label: locState,
color: getColor(
color: computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
@ -289,7 +229,7 @@ export class StateHistoryChartTimeline extends LitElement {
start: prevLastChanged,
end: endTime,
label: locState,
color: getColor(
color: computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]

View File

@ -48,7 +48,7 @@ class StateHistoryCharts extends LitElement {
@property({ type: Boolean, attribute: "up-to-now" }) public upToNow = false;
@property({ type: Boolean, attribute: "no-single" }) public noSingle = false;
@property({ type: Boolean }) public showNames = true;
@property({ type: Boolean }) public isLoadingData = false;
@ -128,8 +128,7 @@ class StateHistoryCharts extends LitElement {
.unit=${item.unit}
.data=${item.data}
.identifier=${item.identifier}
.isSingleDevice=${!this.noSingle &&
this.historyData.line?.length === 1}
.showNames=${this.showNames}
.endTime=${this._computedEndTime}
.names=${this.names}
></state-history-chart-line>
@ -141,8 +140,7 @@ class StateHistoryCharts extends LitElement {
.data=${item}
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
.isSingleDevice=${!this.noSingle &&
this.historyData.timeline?.length === 1}
.showNames=${this.showNames}
.names=${this.names}
.narrow=${this.narrow}
.chunked=${this.virtualize}

View File

@ -0,0 +1,99 @@
import { HassEntity } from "home-assistant-js-websocket";
import { getGraphColorByIndex } from "../../../common/color/colors";
import { lab2hex, rgb2hex, rgb2lab } from "../../../common/color/convert-color";
import { labBrighten } from "../../../common/color/lab";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateActive } from "../../../common/entity/state_active";
import { stateColor } from "../../../common/entity/state_color";
const DOMAIN_STATE_SHADES: Record<string, Record<string, number>> = {
media_player: {
paused: 0.5,
idle: 1,
},
vacuum: {
returning: 0.5,
},
};
const cssColorMap: Map<string, [number, number, number]> = new Map();
function cssToRgb(
cssVariable: string,
computedStyles: CSSStyleDeclaration
): [number, number, number] | undefined {
if (!cssVariable.startsWith("--rgb")) {
return undefined;
}
if (cssColorMap.has(cssVariable)) {
return cssColorMap.get(cssVariable)!;
}
const value = computedStyles.getPropertyValue(cssVariable);
if (!value) return undefined;
const rgb = value.split(",").map((v) => Number(v)) as [
number,
number,
number
];
cssColorMap.set(cssVariable, rgb);
return rgb;
}
function computeTimelineStateColor(
state: string,
computedStyles: CSSStyleDeclaration,
stateObj?: HassEntity
): string | undefined {
if (!stateObj || !stateActive(stateObj, state)) {
const rgb = cssToRgb("--rgb-disabled-color", computedStyles);
if (!rgb) return undefined;
return rgb2hex(rgb);
}
const color = stateColor(stateObj, state);
if (!color) return undefined;
const domain = computeDomain(stateObj.entity_id);
const rgb = cssToRgb(`--rgb-state-${color}-color`, computedStyles);
if (!rgb) return undefined;
const shade = DOMAIN_STATE_SHADES[domain]?.[state] as number | number;
if (!shade) {
return rgb2hex(rgb);
}
return lab2hex(labBrighten(rgb2lab(rgb), shade));
}
let colorIndex = 0;
const stateColorMap: Map<string, string> = new Map();
function computeTimeLineGenericColor(
state: string,
computedStyles: CSSStyleDeclaration
): string {
if (stateColorMap.has(state)) {
return stateColorMap.get(state)!;
}
const color = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
stateColorMap.set(state, color);
return color;
}
export function computeTimelineColor(
state: string,
computedStyles: CSSStyleDeclaration,
stateObj?: HassEntity
): string {
return (
computeTimelineStateColor(state, computedStyles, stateObj) ||
computeTimeLineGenericColor(state, computedStyles)
);
}

View File

@ -2,6 +2,7 @@ import { getColorByIndex } from "../common/color/colors";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE_STATES } from "./entity";
export interface Calendar {
entity_id: string;
@ -56,13 +57,14 @@ export const fetchCalendarEvents = async (
start: Date,
end: Date,
calendars: Calendar[]
): Promise<CalendarEvent[]> => {
): Promise<{ events: CalendarEvent[]; errors: string[] }> => {
const params = encodeURI(
`?start=${start.toISOString()}&end=${end.toISOString()}`
);
const calEvents: CalendarEvent[] = [];
const promises: Promise<any>[] = [];
const errors: string[] = [];
const promises: Promise<CalendarEvent[]>[] = [];
calendars.forEach((cal) => {
promises.push(
@ -73,9 +75,15 @@ export const fetchCalendarEvents = async (
);
});
const results = await Promise.all(promises);
results.forEach((result, idx) => {
for (const [idx, promise] of promises.entries()) {
let result: CalendarEvent[];
try {
// eslint-disable-next-line no-await-in-loop
result = await promise;
} catch (err) {
errors.push(calendars[idx].entity_id);
continue;
}
const cal = calendars[idx];
result.forEach((ev) => {
const eventStart = getCalendarDate(ev.start);
@ -104,9 +112,9 @@ export const fetchCalendarEvents = async (
calEvents.push(event);
});
});
}
return calEvents;
return { events: calEvents, errors };
};
const getCalendarDate = (dateObj: any): string | undefined => {
@ -127,7 +135,11 @@ const getCalendarDate = (dateObj: any): string | undefined => {
export const getCalendars = (hass: HomeAssistant): Calendar[] =>
Object.keys(hass.states)
.filter((eid) => computeDomain(eid) === "calendar")
.filter(
(eid) =>
computeDomain(eid) === "calendar" &&
!UNAVAILABLE_STATES.includes(hass.states[eid].state)
)
.sort()
.map((eid, idx) => ({
entity_id: eid,

View File

@ -67,12 +67,14 @@ export class MoreInfoHistory extends LitElement {
.statTypes=${statTypes}
.names=${this._statNames}
hideLegend
.showNames=${false}
></statistics-chart>`
: html`<state-history-charts
up-to-now
.hass=${this.hass}
.historyData=${this._stateHistory}
.isLoadingData=${!this._stateHistory}
.showNames=${false}
></state-history-charts>`}`
: ""}`;
}

View File

@ -1,7 +1,13 @@
import "@material/mwc-button";
import { mdiClose } from "@mdi/js";
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { addDays, addHours, startOfHour } from "date-fns/esm";
import {
addDays,
addHours,
addMilliseconds,
differenceInMilliseconds,
startOfHour,
} from "date-fns/esm";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@ -35,6 +41,8 @@ class DialogCalendarEventEditor extends LitElement {
@state() private _error?: string;
@state() private _info?: string;
@state() private _params?: CalendarEventDetailDialogParams;
@state() private _calendars: Calendar[] = [];
@ -57,6 +65,7 @@ class DialogCalendarEventEditor extends LitElement {
public showDialog(params: CalendarEventEditDialogParams): void {
this._error = undefined;
this._info = undefined;
this._params = params;
this._calendars = params.calendars;
this._calendarId = params.calendarId || this._calendars[0].entity_id;
@ -120,6 +129,14 @@ class DialogCalendarEventEditor extends LitElement {
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
${this._info
? html`<ha-alert
alert-type="info"
dismissable
@alert-dismissed-clicked=${this._clearInfo}
>${this._info}</ha-alert
>`
: ""}
<ha-textfield
class="summary"
@ -260,6 +277,10 @@ class DialogCalendarEventEditor extends LitElement {
}
);
private _clearInfo() {
this._info = undefined;
}
private _handleSummaryChanged(ev) {
this._summary = ev.target.value;
}
@ -277,9 +298,30 @@ class DialogCalendarEventEditor extends LitElement {
}
private _startDateChanged(ev: CustomEvent) {
// Store previous event duration
const duration = differenceInMilliseconds(this._dtend!, this._dtstart!);
this._dtstart = new Date(
ev.detail.value + "T" + this._dtstart!.toISOString().split("T")[1]
);
// Prevent that the end time can be before the start time. Try to keep the
// duration the same.
if (this._dtend! <= this._dtstart!) {
const newEnd = addMilliseconds(this._dtstart, duration);
// en-CA locale used for date format YYYY-MM-DD
// en-GB locale used for 24h time format HH:MM:SS
this._dtend = new Date(
`${newEnd.toLocaleDateString("en-CA", {
timeZone: this.hass.config.time_zone,
})}T${newEnd.toLocaleTimeString("en-GB", {
timeZone: this.hass.config.time_zone,
})}`
);
this._info = this.hass.localize(
"ui.components.calendar.event.end_auto_adjusted"
);
}
}
private _endDateChanged(ev: CustomEvent) {
@ -289,9 +331,28 @@ class DialogCalendarEventEditor extends LitElement {
}
private _startTimeChanged(ev: CustomEvent) {
// Store previous event duration
const duration = differenceInMilliseconds(this._dtend!, this._dtstart!);
this._dtstart = new Date(
this._dtstart!.toISOString().split("T")[0] + "T" + ev.detail.value
);
// Prevent that the end time can be before the start time. Try to keep the
// duration the same.
if (this._dtend! <= this._dtstart!) {
const newEnd = addMilliseconds(new Date(this._dtstart), duration);
this._dtend = new Date(
`${newEnd.toLocaleDateString("en-CA", {
timeZone: this.hass.config.time_zone,
})}T${newEnd.toLocaleTimeString("en-GB", {
timeZone: this.hass.config.time_zone,
})}`
);
this._info = this.hass.localize(
"ui.components.calendar.event.end_auto_adjusted"
);
}
}
private _endTimeChanged(ev: CustomEvent) {
@ -337,6 +398,13 @@ class DialogCalendarEventEditor extends LitElement {
return;
}
if (this._dtend! <= this._dtstart!) {
this._error = this.hass.localize(
"ui.components.calendar.event.invalid_duration"
);
return;
}
this._submitting = true;
try {
await createCalendarEvent(

View File

@ -97,6 +97,8 @@ export class HAFullCalendar extends LitElement {
@property() public initialView: FullCalendarView = "dayGridMonth";
@property({ attribute: false }) public error?: string = undefined;
private calendar?: Calendar;
private _viewButtons?: ToggleButton[];
@ -116,6 +118,14 @@ export class HAFullCalendar extends LitElement {
return html`
${this.calendar
? html`
${this.error
? html`<ha-alert
alert-type="error"
dismissable
@alert-dismissed-clicked=${this._clearError}
>${this.error}</ha-alert
>`
: ""}
<div class="header">
${!this.narrow
? html`
@ -380,6 +390,10 @@ export class HAFullCalendar extends LitElement {
);
});
private _clearError() {
this.error = undefined;
}
static get styles(): CSSResultGroup {
return [
haStyle,
@ -449,6 +463,11 @@ export class HAFullCalendar extends LitElement {
z-index: 1;
}
ha-alert {
display: block;
margin: 4px 0;
}
#calendar {
flex-grow: 1;
background-color: var(

View File

@ -15,6 +15,7 @@ import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { LocalStorage } from "../../common/decorators/local-storage";
import { HASSDomEvent } from "../../common/dom/fire_event";
import { computeStateName } from "../../common/entity/compute_state_name";
import "../../components/ha-card";
import "../../components/ha-icon-button";
import "../../components/ha-menu-button";
@ -40,6 +41,8 @@ class PanelCalendar extends LitElement {
@state() private _events: CalendarEvent[] = [];
@state() private _error?: string = undefined;
@LocalStorage("deSelectedCalendars", true)
private _deSelectedCalendars: string[] = [];
@ -101,6 +104,7 @@ class PanelCalendar extends LitElement {
.calendars=${this._calendars}
.narrow=${this.narrow}
.hass=${this.hass}
.error=${this._error}
@view-changed=${this._handleViewChanged}
></ha-full-calendar>
</div>
@ -118,9 +122,9 @@ class PanelCalendar extends LitElement {
start: Date,
end: Date,
calendars: Calendar[]
): Promise<CalendarEvent[]> {
): Promise<{ events: CalendarEvent[]; errors: string[] }> {
if (!calendars.length) {
return [];
return { events: [], errors: [] };
}
return fetchCalendarEvents(this.hass, start, end, calendars);
@ -135,8 +139,9 @@ class PanelCalendar extends LitElement {
const checked = ev.target.checked;
if (checked) {
const events = await this._fetchEvents(this._start!, this._end!, [cal]);
this._events = [...this._events, ...events];
const result = await this._fetchEvents(this._start!, this._end!, [cal]);
this._events = [...this._events, ...result.events];
this._handleErrors(result.errors);
this._deSelectedCalendars = this._deSelectedCalendars.filter(
(deCal) => deCal !== cal.entity_id
);
@ -161,19 +166,40 @@ class PanelCalendar extends LitElement {
): Promise<void> {
this._start = ev.detail.start;
this._end = ev.detail.end;
this._events = await this._fetchEvents(
const result = await this._fetchEvents(
this._start,
this._end,
this._selectedCalendars
);
this._events = result.events;
this._handleErrors(result.errors);
}
private async _handleRefresh(): Promise<void> {
this._events = await this._fetchEvents(
const result = await this._fetchEvents(
this._start!,
this._end!,
this._selectedCalendars
);
this._events = result.events;
this._handleErrors(result.errors);
}
private _handleErrors(error_entity_ids: string[]) {
this._error = undefined;
if (error_entity_ids.length > 0) {
const nameList = error_entity_ids
.map((error_entity_id) =>
this.hass!.states[error_entity_id]
? computeStateName(this.hass!.states[error_entity_id])
: error_entity_id
)
.join(", ");
this._error = `${this.hass!.localize(
"ui.components.calendar.event_retrieval_error"
)} ${nameList}`;
}
}
static get styles(): CSSResultGroup {

View File

@ -75,6 +75,7 @@ import {
showDeviceRegistryDetailDialog,
} from "./device-registry-detail/show-dialog-device-registry-detail";
import "../../../layouts/hass-subpage";
import { SENSOR_ENTITIES } from "../../../common/const";
export interface EntityRegistryStateEntry extends EntityRegistryEntry {
stateName?: string | null;
@ -172,13 +173,7 @@ export class HaConfigDevicePage extends LitElement {
const result = groupBy(entities, (entry) =>
entry.entity_category
? entry.entity_category
: [
"sensor",
"binary_sensor",
"camera",
"device_tracker",
"weather",
].includes(computeDomain(entry.entity_id))
: SENSOR_ENTITIES.includes(computeDomain(entry.entity_id))
? "sensor"
: "control"
) as Record<

View File

@ -117,7 +117,7 @@ const OVERRIDE_NUMBER_UNITS = {
const OVERRIDE_SENSOR_UNITS = {
distance: ["cm", "ft", "in", "km", "m", "mi", "mm", "yd"],
gas: ["ft³", "m³"],
precipitation: ["in", "mm"],
precipitation: ["cm", "in", "mm"],
precipitation_intensity: ["in/d", "in/h", "mm/d", "mm/h"],
pressure: ["hPa", "Pa", "kPa", "bar", "cbar", "mbar", "mmHg", "inHg", "psi"],
speed: ["ft/s", "in/d", "in/h", "km/h", "kn", "m/s", "mm/d", "mm/h", "mph"],

View File

@ -6,7 +6,7 @@ import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
// @ts-ignore
import timegridStyle from "@fullcalendar/timegrid/main.css";
import { isSameDay } from "date-fns";
import { addDays, isSameDay, isSameWeek, nextDay } from "date-fns";
import {
css,
CSSResultGroup,
@ -17,15 +17,17 @@ import {
unsafeCSS,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../../../common/datetime/first_weekday";
import { formatTime24h } from "../../../../common/datetime/format_time";
import { useAmPm } from "../../../../common/datetime/use_am_pm";
import { firstWeekdayIndex } from "../../../../common/datetime/first_weekday";
import { fireEvent } from "../../../../common/dom/fire_event";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-icon-picker";
import "../../../../components/ha-textfield";
import { Schedule, ScheduleDay, weekdays } from "../../../../data/schedule";
import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import { installResizeObserver } from "../../../lovelace/common/install-resize-observer";
const defaultFullCalendarConfig: CalendarOptions = {
plugins: [timeGridPlugin, interactionPlugin],
@ -71,6 +73,8 @@ class HaScheduleForm extends LitElement {
private _item?: Schedule;
private _resizeObserver?: ResizeObserver;
set item(item: Schedule) {
this._item = item;
if (item) {
@ -104,6 +108,40 @@ class HaScheduleForm extends LitElement {
);
}
public connectedCallback(): void {
super.connectedCallback();
this.updateComplete.then(() => this._attachObserver());
}
public disconnectedCallback(): void {
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
}
private _measureForm() {
const form = this.shadowRoot!.querySelector(".form");
if (!form) {
return;
}
this.calendar?.updateSize();
}
private async _attachObserver(): Promise<void> {
if (!this._resizeObserver) {
await installResizeObserver();
this._resizeObserver = new ResizeObserver(
debounce(() => this._measureForm(), 250, false)
);
}
const form = this.shadowRoot!.querySelector(".form");
if (!form) {
return;
}
this._resizeObserver.observe(form);
}
protected render(): TemplateResult {
if (!this.hass) {
return html``;
@ -207,11 +245,6 @@ class HaScheduleForm extends LitElement {
private get _events() {
const events: any[] = [];
const currentDay = new Date().getDay();
const baseDay =
currentDay === 0 && firstWeekdayIndex(this.hass.locale) === 1
? 7
: currentDay;
for (const [i, day] of weekdays.entries()) {
if (!this[`_${day}`].length) {
@ -219,14 +252,15 @@ class HaScheduleForm extends LitElement {
}
this[`_${day}`].forEach((item: ScheduleDay, index: number) => {
// Add 7 to 0 because we start the calendar on Monday, except when the locale says otherwise (firstWeekdayIndex() != 1)
const distance =
i -
baseDay +
(i === 0 && firstWeekdayIndex(this.hass.locale) === 1 ? 7 : 0);
const start = new Date();
start.setDate(start.getDate() + distance);
let date = nextDay(new Date(), i as Day);
if (
!isSameWeek(date, new Date(), {
weekStartsOn: firstWeekdayIndex(this.hass.locale),
})
) {
date = addDays(date, -7);
}
const start = new Date(date);
const start_tokens = item.from.split(":");
start.setHours(
parseInt(start_tokens[0]),
@ -235,8 +269,7 @@ class HaScheduleForm extends LitElement {
0
);
const end = new Date();
end.setDate(end.getDate() + distance);
const end = new Date(date);
const end_tokens = item.to.split(":");
end.setHours(parseInt(end_tokens[0]), parseInt(end_tokens[1]), 0, 0);
@ -397,12 +430,45 @@ class HaScheduleForm extends LitElement {
--fc-border-color: var(--divider-color);
--fc-event-border-color: var(--divider-color);
}
.fc-scroller {
overflow-x: visible !important;
}
.fc-v-event .fc-event-time {
white-space: inherit;
}
.fc-theme-standard .fc-scrollgrid {
border: 1px solid var(--divider-color);
border-radius: var(--mdc-shape-small, 4px);
}
.fc-scrollgrid-section-header td {
border: none;
}
:host([narrow]) .fc-scrollgrid-sync-table {
overflow: hidden;
}
table.fc-scrollgrid-sync-table
tbody
tr:first-child
.fc-daygrid-day-top {
padding-top: 0;
}
.fc-scroller::-webkit-scrollbar {
width: 0.4rem;
height: 0.4rem;
}
.fc-scroller::-webkit-scrollbar-thumb {
-webkit-border-radius: 4px;
border-radius: 4px;
background: var(--scrollbar-thumb-color);
}
.fc-scroller {
overflow-y: auto;
scrollbar-color: var(--scrollbar-thumb-color) transparent;
scrollbar-width: thin;
}
.fc-timegrid-event-short .fc-event-time:after {
content: ""; /* prevent trailing dash in half hour events since we do not have event titles */
}
a {
color: inherit !important;

View File

@ -163,7 +163,6 @@ class HaPanelHistory extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.historyData=${this._stateHistory}
.endTime=${this._endDate}
no-single
>
</state-history-charts>
`}

View File

@ -1,5 +1,6 @@
import "@lit-labs/virtualizer";
import { VisibilityChangedEvent } from "@lit-labs/virtualizer/Virtualizer";
import type { HassEntity } from "home-assistant-js-websocket";
import {
css,
CSSResultGroup,
@ -8,37 +9,35 @@ import {
PropertyValues,
TemplateResult,
} from "lit";
import type { HassEntity } from "home-assistant-js-websocket";
import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { formatDate } from "../../common/datetime/format_date";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { stateActive } from "../../common/entity/state_active";
import { stateColorCss } from "../../common/entity/state_color";
import { navigate } from "../../common/navigate";
import { computeTimelineColor } from "../../components/chart/timeline-chart/timeline-color";
import "../../components/entity/state-badge";
import "../../components/ha-circular-progress";
import "../../components/ha-icon-next";
import "../../components/ha-relative-time";
import {
createHistoricState,
localizeTriggerSource,
localizeStateMessage,
localizeTriggerSource,
LogbookEntry,
} from "../../data/logbook";
import { TraceContexts } from "../../data/trace";
import {
buttonLinkStyle,
haStyle,
haStyleScrollbar,
buttonLinkStyle,
} from "../../resources/styles";
import { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../../components/ha-icon-next";
import { navigate } from "../../common/navigate";
declare global {
interface HASSDomEvents {
@ -264,14 +263,15 @@ class HaLogbookRenderer extends LitElement {
const stateObj = this.hass.states[item.entity_id!] as
| HassEntity
| undefined;
const computedStyles = getComputedStyle(this);
const color =
stateObj && stateActive(stateObj, item.state)
? stateColorCss(stateObj, item.state)
item.state !== undefined
? computeTimelineColor(item.state, computedStyles, stateObj)
: undefined;
const style = {
"--indicator-color": color,
backgroundColor: color,
};
return html` <div class="indicator" style=${styleMap(style)}></div> `;
@ -577,9 +577,7 @@ class HaLogbookRenderer extends LitElement {
}
.indicator {
background-color: rgb(
var(--indicator-color, var(--rgb-disabled-color))
);
background-color: rgb(var(--rgb-disabled-color));
height: 8px;
width: 8px;
border-radius: 4px;

View File

@ -10,6 +10,7 @@ import { customElement, property, state, query } from "lit/decorators";
import { getColorByIndex } from "../../../common/color/colors";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-card";
import {
@ -69,6 +70,8 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
@state() private _veryNarrow = false;
@state() private _error?: string = undefined;
@query("ha-full-calendar", true) private _calendar?: HAFullCalendar;
private _startDate?: Date;
@ -131,6 +134,7 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
.hass=${this.hass}
.views=${views}
.initialView=${this._config.initial_view!}
.error=${this._error}
@view-changed=${this._handleViewChanged}
></ha-full-calendar>
</ha-card>
@ -169,12 +173,28 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
return;
}
this._events = await fetchCalendarEvents(
this._error = undefined;
const result = await fetchCalendarEvents(
this.hass!,
this._startDate,
this._endDate,
this._calendars
);
this._events = result.events;
if (result.errors.length > 0) {
const nameList = result.errors
.map((error_entity_id) =>
this.hass!.states[error_entity_id]
? computeStateName(this.hass!.states[error_entity_id])
: error_entity_id
)
.join(", ");
this._error = `${this.hass!.localize(
"ui.components.calendar.event_retrieval_error"
)} ${nameList}`;
}
}
private _measureCard() {

View File

@ -144,7 +144,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.historyData=${this._stateHistory}
.names=${this._names}
up-to-now
no-single
.showNames=${this._config.show_names}
></state-history-charts>
</div>
</ha-card>

View File

@ -128,6 +128,18 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
private _computeStateColor = memoize((entity: HassEntity, color?: string) => {
if (UNAVAILABLE_STATES.includes(entity.state)) {
return undefined;
}
// Use default color for person/device_tracker because color is on the badge
if (
computeDomain(entity.entity_id) === "person" ||
computeDomain(entity.entity_id) === "device_tracker"
) {
return "var(--rgb-state-default-color)";
}
if (!stateActive(entity)) {
return undefined;
}

View File

@ -1,6 +1,5 @@
import { mdiHelp, mdiHome, mdiHomeExportOutline } from "@mdi/js";
import { mdiHome, mdiHomeExportOutline } from "@mdi/js";
import { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE_STATES } from "../../../../../data/entity";
import { HomeAssistant } from "../../../../../types";
import { ComputeBadgeFunction } from "./tile-badge";
@ -17,20 +16,18 @@ function getZone(entity: HassEntity, hass: HomeAssistant) {
function personBadgeIcon(entity: HassEntity) {
const state = entity.state;
if (UNAVAILABLE_STATES.includes(state)) {
return mdiHelp;
}
return state === "not_home" ? mdiHomeExportOutline : mdiHome;
}
function personBadgeColor(entity: HassEntity, inZone?: boolean) {
if (inZone) {
return "var(--rgb-state-person-zone-color)";
function personBadgeColor(entity: HassEntity) {
switch (entity.state) {
case "home":
return "var(--rgb-badge-person-home-color)";
case "not_home":
return "var(--rgb-badge-person-not-home-color)";
default:
return "var(--rgb-badge-person-zone-color)";
}
const state = entity.state;
return state === "not_home"
? "var(--rgb-state-person-not-home-color)"
: "var(--rgb-state-person-home-color)";
}
export const computePersonBadge: ComputeBadgeFunction = (stateObj, hass) => {
@ -39,6 +36,6 @@ export const computePersonBadge: ComputeBadgeFunction = (stateObj, hass) => {
return {
iconPath: personBadgeIcon(stateObj),
icon: zone?.attributes.icon,
color: personBadgeColor(stateObj, Boolean(zone)),
color: personBadgeColor(stateObj),
};
};

View File

@ -1,5 +1,7 @@
import { mdiExclamationThick } from "@mdi/js";
import { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { UNAVAILABLE, UNKNOWN } from "../../../../../data/entity";
import { HomeAssistant } from "../../../../../types";
import { computeClimateBadge } from "./tile-badge-climate";
import { computePersonBadge } from "./tile-badge-person";
@ -16,6 +18,15 @@ export type ComputeBadgeFunction = (
) => TileBadge | undefined;
export const computeTileBadge: ComputeBadgeFunction = (stateObj, hass) => {
if (stateObj.state === UNKNOWN) {
return undefined;
}
if (stateObj.state === UNAVAILABLE) {
return {
color: "var(--rgb-orange-color)",
iconPath: mdiExclamationThick,
};
}
const domain = computeDomain(stateObj.entity_id);
switch (domain) {
case "person":

View File

@ -300,6 +300,7 @@ export interface HistoryGraphCardConfig extends LovelaceCardConfig {
hours_to_show?: number;
refresh_interval?: number;
title?: string;
show_names?: boolean;
}
export interface StatisticsGraphCardConfig extends LovelaceCardConfig {

View File

@ -1,4 +1,5 @@
import { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { SENSOR_ENTITIES } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
@ -23,7 +24,7 @@ import {
PictureEntityCardConfig,
ThermostatCardConfig,
} from "../cards/types";
import { LovelaceRowConfig } from "../entity-rows/types";
import { EntityConfig } from "../entity-rows/types";
import { ButtonsHeaderFooterConfig } from "../header-footer/types";
const HIDE_DOMAIN = new Set([
@ -96,14 +97,15 @@ const splitByAreaDevice = (
};
export const computeCards = (
states: Array<[string, HassEntity?]>,
states: HassEntities,
entityIds: string[],
entityCardOptions: Partial<EntitiesCardConfig>,
renderFooterEntities = true
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
// For entity card
const entities: Array<string | LovelaceRowConfig> = [];
const entitiesConf: Array<string | EntityConfig> = [];
const titlePrefix = entityCardOptions.title
? entityCardOptions.title.toLowerCase()
@ -111,7 +113,8 @@ export const computeCards = (
const footerEntities: ButtonsHeaderFooterConfig["entities"] = [];
for (const [entityId, stateObj] of states) {
for (const entityId of entityIds) {
const stateObj = states[entityId];
const domain = computeDomain(entityId);
if (domain === "alarm_control_panel") {
@ -200,20 +203,49 @@ export const computeCards = (
}
: entityId;
entities.push(entityConf);
entitiesConf.push(entityConf);
}
}
entitiesConf.sort((a, b) => {
const entityIdA = typeof a === "string" ? a : a.entity;
const entityIdB = typeof b === "string" ? b : b.entity;
const categoryA = SENSOR_ENTITIES.includes(computeDomain(entityIdA))
? "sensor"
: "control";
const categoryB = SENSOR_ENTITIES.includes(computeDomain(entityIdB))
? "sensor"
: "control";
if (categoryA !== categoryB) {
return categoryA === "sensor" ? 1 : -1;
}
return stringCompare(
typeof a === "string"
? states[a]
? computeStateName(states[a])
: ""
: a.name || "",
typeof b === "string"
? states[b]
? computeStateName(states[b])
: ""
: b.name || ""
);
});
// If we ended up with footer entities but no normal entities,
// render the footer entities as normal entities.
if (entities.length === 0 && footerEntities.length > 0) {
return computeCards(states, entityCardOptions, false);
if (entitiesConf.length === 0 && footerEntities.length > 0) {
return computeCards(states, entityIds, entityCardOptions, false);
}
if (entities.length > 0 || footerEntities.length > 0) {
if (entitiesConf.length > 0 || footerEntities.length > 0) {
const card: EntitiesCardConfig = {
type: "entities",
entities,
entities: entitiesConf,
...entityCardOptions,
};
if (footerEntities.length > 0) {
@ -354,15 +386,10 @@ export const generateViewConfig = (
for (const groupEntity of splitted.groups) {
cards.push(
...computeCards(
groupEntity.attributes.entity_id.map(
(entityId): [string, HassEntity] => [entityId, entities[entityId]]
),
{
title: computeStateName(groupEntity),
show_header_toggle: groupEntity.attributes.control !== "hidden",
}
)
...computeCards(entities, groupEntity.attributes.entity_id, {
title: computeStateName(groupEntity),
show_header_toggle: groupEntity.attributes.control !== "hidden",
})
);
}
@ -398,17 +425,13 @@ export const generateViewConfig = (
.forEach((domain) => {
cards.push(
...computeCards(
ungroupedEntitites[domain]
.sort((a, b) =>
stringCompare(
computeStateName(entities[a]),
computeStateName(entities[b])
)
entities,
ungroupedEntitites[domain].sort((a, b) =>
stringCompare(
computeStateName(entities[a]),
computeStateName(entities[b])
)
.map((entityId): [string, HassEntity] => [
entityId,
entities[entityId],
]),
),
{
title: domainTranslations[domain],
}
@ -466,28 +489,35 @@ export const generateDefaultViewConfig = (
groupOrders
);
const splittedCards: LovelaceCardConfig[] = [];
const areaCards: LovelaceCardConfig[] = [];
for (const [areaId, areaEntities] of Object.entries(
splittedByAreaDevice.areasWithEntities
)) {
const area = areaEntries[areaId];
splittedCards.push(
areaCards.push(
...computeCards(
areaEntities.map((entity) => [entity.entity_id, entity]),
entities,
areaEntities.map((entity) => entity.entity_id),
{
title: area.name,
}
)
);
}
areaCards.sort((a, b) => stringCompare(a.title || "", b.title || ""));
const deviceCards: LovelaceCardConfig[] = [];
for (const [deviceId, deviceEntities] of Object.entries(
splittedByAreaDevice.devicesWithEntities
)) {
const device = deviceEntries[deviceId];
splittedCards.push(
deviceCards.push(
...computeCards(
deviceEntities.map((entity) => [entity.entity_id, entity]),
entities,
deviceEntities.map((entity) => entity.entity_id),
{
title:
device.name_by_user ||
@ -503,6 +533,11 @@ export const generateDefaultViewConfig = (
)
);
}
deviceCards.sort((a, b) => stringCompare(a.title || "", b.title || ""));
let energyCard: LovelaceCardConfig | undefined;
if (energyPrefs) {
// Distribution card requires the grid to be configured
const grid = energyPrefs.energy_sources.find(
@ -510,17 +545,21 @@ export const generateDefaultViewConfig = (
) as GridSourceTypeEnergyPreference | undefined;
if (grid && grid.flow_from.length > 0) {
splittedCards.push({
energyCard = {
title: localize(
"ui.panel.lovelace.cards.energy.energy_distribution.title_today"
),
type: "energy-distribution",
link_dashboard: true,
});
};
}
}
config.cards!.unshift(...splittedCards);
config.cards!.unshift(
...areaCards,
...(energyCard ? [energyCard] : []),
...deviceCards
);
return config;
};

View File

@ -30,15 +30,9 @@ export class HuiDialogSuggestCard extends LitElement {
this._params = params;
this._cardConfig =
params.cardConfig ||
computeCards(
params.entities.map((entityId) => [
entityId,
this.hass.states[entityId],
]),
{
title: params.cardTitle,
}
);
computeCards(this.hass.states, params.entities, {
title: params.cardTitle,
});
if (!Object.isFrozen(this._cardConfig)) {
this._cardConfig = deepFreeze(this._cardConfig);
}

View File

@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
import {
array,
assert,
boolean,
number,
object,
optional,
@ -28,6 +29,7 @@ const cardConfigStruct = assign(
title: optional(string()),
hours_to_show: optional(number()),
refresh_interval: optional(number()),
show_names: optional(boolean()),
})
);

View File

@ -122,6 +122,7 @@ class HuiCastRow extends LitElement implements LovelaceRow {
await ensureConnectedCastSession(this._castManager!, this.hass.auth);
castSendShowLovelaceView(
this._castManager!,
this.hass.auth.data.hassUrl,
this._config!.view,
this._config!.dashboard
);

View File

@ -137,10 +137,13 @@ documentContainer.innerHTML = `<custom-style>
/* rgb state color */
--rgb-state-default-color: 68, 115, 158;
--rgb-state-active-color: var(--rgb-primary-color);
--rgb-state-alarm-armed-color: var(--rgb-red-color);
--rgb-state-alarm-pending-color: var(--rgb-orange-color);
--rgb-state-alarm-arming-color: var(--rgb-orange-color);
--rgb-state-alarm-triggered-color: var(--rgb-red-color);
--rgb-state-alert-color: var(--rgb-red-color);
--rgb-state-automation-color: var(--rgb-amber-color);
--rgb-state-binary-sensor-color: var(--rgb-primary-color);
--rgb-state-binary-sensor-alerting-color: var(--rgb-red-color);
--rgb-state-cover-color: var(--rgb-purple-color);
@ -152,7 +155,6 @@ documentContainer.innerHTML = `<custom-style>
--rgb-state-lock-pending-color: var(--rgb-orange-color);
--rgb-state-media-player-color: var(--rgb-indigo-color);
--rgb-state-person-home-color: var(--rgb-green-color);
--rgb-state-person-not-home-color: var(--rgb-red-color);
--rgb-state-person-zone-color: var(--rgb-blue-color);
--rgb-state-sensor-battery-high-color: var(--rgb-green-color);
--rgb-state-sensor-battery-low-color: var(--rgb-red-color);
@ -173,6 +175,11 @@ documentContainer.innerHTML = `<custom-style>
--rgb-state-climate-heat-cool-color: var(--rgb-state-default-color);
--rgb-state-climate-idle-color: var(--rgb-disabled-color);
/* rgb state badge color */
--rgb-badge-person-home-color: var(--rgb-state-person-home-color);
--rgb-badge-person-zone-color: var(--rgb-state-person-zone-color);
--rgb-badge-person-not-home-color: var(--rgb-red-color);
/* input components */
--input-idle-line-color: rgba(0, 0, 0, 0.42);
--input-hover-line-color: rgba(0, 0, 0, 0.87);

View File

@ -634,6 +634,7 @@
"label": "Calendar",
"my_calendars": "My Calendars",
"today": "Today",
"event_retrieval_error": "Could not retrieve events for calendars: ",
"event": {
"add": "Add Event",
"delete": "Delete Event",
@ -642,7 +643,9 @@
"all_day": "All Day",
"start": "Start",
"end": "End",
"not_all_required_fields": "Not all required fields are filled in.",
"invalid_duration": "The duration of the event is not valid. Please check start and end date.",
"not_all_required_fields": "Not all required fields are filled in",
"end_auto_adjusted": "Event end was adjusted to prevent negative duration",
"confirm_delete": {
"delete": "Delete Event",
"delete_this": "Delete Only This Event",