diff --git a/.gitignore b/.gitignore index 39e1eacc98..728c403c50 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/cast/src/launcher/layout/hc-cast.ts b/cast/src/launcher/layout/hc-cast.ts index 2b58d6347d..28d26a9df1 100644 --- a/cast/src/launcher/layout/hc-cast.ts +++ b/cast/src/launcher/layout/hc-cast.ts @@ -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() { diff --git a/cast/src/receiver/layout/hc-main.ts b/cast/src/receiver/layout/hc-main.ts index 935456a98f..298487bd0f 100644 --- a/cast/src/receiver/layout/hc-main.ts +++ b/cast/src/receiver/layout/hc-main.ts @@ -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; diff --git a/gallery/src/pages/misc/entity-state.ts b/gallery/src/pages/misc/entity-state.ts index 280ba22976..35fe96528a 100644 --- a/gallery/src/pages/misc/entity-state.ts +++ b/gallery/src/pages/misc/entity-state.ts @@ -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 = { icon: { title: "Icon", - template: (_, entry) => { - const cssColor = stateColorCss(entry.stateObj); - return html` - - - `; - }, + template: (_, entry) => html` + + `, }, entity_id: { title: "Entity id", diff --git a/hassio/.gitignore b/hassio/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pyproject.toml b/pyproject.toml index 4a4e82f951..279db5ecde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/cast/receiver_messages.ts b/src/cast/receiver_messages.ts index 953d766abd..8b59962538 100644 --- a/src/cast/receiver_messages.ts +++ b/src/cast/receiver_messages.ts @@ -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) => diff --git a/src/cast/sender_messages.ts b/src/cast/sender_messages.ts index 1a6eda58f0..4706a0d1ae 100644 --- a/src/cast/sender_messages.ts +++ b/src/cast/sender_messages.ts @@ -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, } diff --git a/src/common/const.ts b/src/common/const.ts index 53ac073b8e..8f9902c31e 100644 --- a/src/common/const.ts +++ b/src/common/const.ts @@ -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 diff --git a/src/common/entity/color/person_color.ts b/src/common/entity/color/person_color.ts new file mode 100644 index 0000000000..7f76a62893 --- /dev/null +++ b/src/common/entity/color/person_color.ts @@ -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"; + } +}; diff --git a/src/common/entity/state_active.ts b/src/common/entity/state_active.ts index 3ec7f1bad7..5bd58e9f2c 100644 --- a/src/common/entity/state_active.ts +++ b/src/common/entity/state_active.ts @@ -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; } diff --git a/src/common/entity/state_color.ts b/src/common/entity/state_color.ts index 56317027e1..96bae8ef51 100644 --- a/src/common/entity/state_color.ts +++ b/src/common/entity/state_color.ts @@ -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" diff --git a/src/common/style/icon_color_css.ts b/src/common/style/icon_color_css.ts index bc63f3df1a..8e5f66d90c 100644 --- a/src/common/style/icon_color_css.ts +++ b/src/common/style/icon_color_css.ts @@ -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"] { diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index 85bb575985..488fc9ee58 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -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, }, diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts index fd98de2125..3425ccbd6a 100644 --- a/src/components/chart/state-history-chart-timeline.ts +++ b/src/components/chart/state-history-chart-timeline.ts @@ -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 = new Map(); -const stateColorMap: Map = 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] diff --git a/src/components/chart/state-history-charts.ts b/src/components/chart/state-history-charts.ts index 415ecbb4e7..a4f467cfd8 100644 --- a/src/components/chart/state-history-charts.ts +++ b/src/components/chart/state-history-charts.ts @@ -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} > @@ -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} diff --git a/src/components/chart/timeline-chart/timeline-color.ts b/src/components/chart/timeline-chart/timeline-color.ts new file mode 100644 index 0000000000..012c9315f6 --- /dev/null +++ b/src/components/chart/timeline-chart/timeline-color.ts @@ -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> = { + media_player: { + paused: 0.5, + idle: 1, + }, + vacuum: { + returning: 0.5, + }, +}; + +const cssColorMap: Map = 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 = 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) + ); +} diff --git a/src/data/calendar.ts b/src/data/calendar.ts index 819bbee25d..2cd6b0b944 100644 --- a/src/data/calendar.ts +++ b/src/data/calendar.ts @@ -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 => { +): Promise<{ events: CalendarEvent[]; errors: string[] }> => { const params = encodeURI( `?start=${start.toISOString()}&end=${end.toISOString()}` ); const calEvents: CalendarEvent[] = []; - const promises: Promise[] = []; + const errors: string[] = []; + const promises: Promise[] = []; 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, diff --git a/src/dialogs/more-info/ha-more-info-history.ts b/src/dialogs/more-info/ha-more-info-history.ts index 105fd43c63..a9f30604f1 100644 --- a/src/dialogs/more-info/ha-more-info-history.ts +++ b/src/dialogs/more-info/ha-more-info-history.ts @@ -67,12 +67,14 @@ export class MoreInfoHistory extends LitElement { .statTypes=${statTypes} .names=${this._statNames} hideLegend + .showNames=${false} >` : html``}` : ""}`; } diff --git a/src/panels/calendar/dialog-calendar-event-editor.ts b/src/panels/calendar/dialog-calendar-event-editor.ts index 06dc41c1fe..f8cb528300 100644 --- a/src/panels/calendar/dialog-calendar-event-editor.ts +++ b/src/panels/calendar/dialog-calendar-event-editor.ts @@ -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`${this._error}` : ""} + ${this._info + ? html`${this._info}` + : ""} ${this.error}` + : ""}
${!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( diff --git a/src/panels/calendar/ha-panel-calendar.ts b/src/panels/calendar/ha-panel-calendar.ts index 5dc7a28381..de3410f3a8 100644 --- a/src/panels/calendar/ha-panel-calendar.ts +++ b/src/panels/calendar/ha-panel-calendar.ts @@ -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} >
@@ -118,9 +122,9 @@ class PanelCalendar extends LitElement { start: Date, end: Date, calendars: Calendar[] - ): Promise { + ): 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 { 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 { - 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 { diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts index 9f184d3d12..025f048f9e 100644 --- a/src/panels/config/devices/ha-config-device-page.ts +++ b/src/panels/config/devices/ha-config-device-page.ts @@ -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< diff --git a/src/panels/config/entities/entity-registry-settings.ts b/src/panels/config/entities/entity-registry-settings.ts index de01367f8b..d53ed18f15 100644 --- a/src/panels/config/entities/entity-registry-settings.ts +++ b/src/panels/config/entities/entity-registry-settings.ts @@ -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"], diff --git a/src/panels/config/helpers/forms/ha-schedule-form.ts b/src/panels/config/helpers/forms/ha-schedule-form.ts index ba7376f492..960965d2c1 100644 --- a/src/panels/config/helpers/forms/ha-schedule-form.ts +++ b/src/panels/config/helpers/forms/ha-schedule-form.ts @@ -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 { + 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; diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index 8f07895d24..f78ca6b0ec 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -163,7 +163,6 @@ class HaPanelHistory extends SubscribeMixin(LitElement) { .hass=${this.hass} .historyData=${this._stateHistory} .endTime=${this._endDate} - no-single > `} diff --git a/src/panels/logbook/ha-logbook-renderer.ts b/src/panels/logbook/ha-logbook-renderer.ts index adf30e47f5..47edea75ce 100644 --- a/src/panels/logbook/ha-logbook-renderer.ts +++ b/src/panels/logbook/ha-logbook-renderer.ts @@ -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`
`; @@ -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; diff --git a/src/panels/lovelace/cards/hui-calendar-card.ts b/src/panels/lovelace/cards/hui-calendar-card.ts index 6b551746c3..f389f9388e 100644 --- a/src/panels/lovelace/cards/hui-calendar-card.ts +++ b/src/panels/lovelace/cards/hui-calendar-card.ts @@ -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} > @@ -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() { diff --git a/src/panels/lovelace/cards/hui-history-graph-card.ts b/src/panels/lovelace/cards/hui-history-graph-card.ts index a94a1b521b..eda1fa1239 100644 --- a/src/panels/lovelace/cards/hui-history-graph-card.ts +++ b/src/panels/lovelace/cards/hui-history-graph-card.ts @@ -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} > diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts index 75c21f56b9..2919639891 100644 --- a/src/panels/lovelace/cards/hui-tile-card.ts +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -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; } diff --git a/src/panels/lovelace/cards/tile/badges/tile-badge-person.ts b/src/panels/lovelace/cards/tile/badges/tile-badge-person.ts index 7eaa3ea340..6770366acd 100644 --- a/src/panels/lovelace/cards/tile/badges/tile-badge-person.ts +++ b/src/panels/lovelace/cards/tile/badges/tile-badge-person.ts @@ -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), }; }; diff --git a/src/panels/lovelace/cards/tile/badges/tile-badge.ts b/src/panels/lovelace/cards/tile/badges/tile-badge.ts index eebda6b5e4..b446097553 100644 --- a/src/panels/lovelace/cards/tile/badges/tile-badge.ts +++ b/src/panels/lovelace/cards/tile/badges/tile-badge.ts @@ -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": diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 5bde49b650..0e3bea773d 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -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 { diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index 6a01fdba05..865de77944 100644 --- a/src/panels/lovelace/common/generate-lovelace-config.ts +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -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, renderFooterEntities = true ): LovelaceCardConfig[] => { const cards: LovelaceCardConfig[] = []; // For entity card - const entities: Array = []; + const entitiesConf: Array = []; 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; }; diff --git a/src/panels/lovelace/editor/card-editor/hui-dialog-suggest-card.ts b/src/panels/lovelace/editor/card-editor/hui-dialog-suggest-card.ts index 2ac61bee0f..71851cf06b 100755 --- a/src/panels/lovelace/editor/card-editor/hui-dialog-suggest-card.ts +++ b/src/panels/lovelace/editor/card-editor/hui-dialog-suggest-card.ts @@ -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); } diff --git a/src/panels/lovelace/editor/config-elements/hui-history-graph-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-history-graph-card-editor.ts index fb9e05f42f..8ca713ba7b 100644 --- a/src/panels/lovelace/editor/config-elements/hui-history-graph-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-history-graph-card-editor.ts @@ -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()), }) ); diff --git a/src/panels/lovelace/special-rows/hui-cast-row.ts b/src/panels/lovelace/special-rows/hui-cast-row.ts index c384bce228..c29edd95e8 100644 --- a/src/panels/lovelace/special-rows/hui-cast-row.ts +++ b/src/panels/lovelace/special-rows/hui-cast-row.ts @@ -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 ); diff --git a/src/resources/ha-style.ts b/src/resources/ha-style.ts index f0b32c00ed..24ca54f038 100644 --- a/src/resources/ha-style.ts +++ b/src/resources/ha-style.ts @@ -137,10 +137,13 @@ documentContainer.innerHTML = ` /* 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 = ` --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 = ` --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); diff --git a/src/translations/en.json b/src/translations/en.json index c7e2d39589..34f5efdcc2 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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",