From 552c474febf0122baef7caf9648d4c6da8b87ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 4 May 2022 15:17:09 +0200 Subject: [PATCH 001/112] Fix setting _externalAccess (#12584) --- .../config/core/ha-config-system-navigation.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/panels/config/core/ha-config-system-navigation.ts b/src/panels/config/core/ha-config-system-navigation.ts index 82087c1a07..a3335d5903 100644 --- a/src/panels/config/core/ha-config-system-navigation.ts +++ b/src/panels/config/core/ha-config-system-navigation.ts @@ -218,14 +218,13 @@ class HaConfigSystemNavigation extends LitElement { private async _fetchNetworkStatus() { if (isComponentLoaded(this.hass, "cloud")) { - fetchCloudStatus(this.hass).then((cloudStatus) => { - if (cloudStatus.logged_in) { - this._externalAccess = true; - } - }); - } else { - this._externalAccess = this.hass.config.external_url !== null; + const cloudStatus = await fetchCloudStatus(this.hass); + if (cloudStatus.logged_in) { + this._externalAccess = true; + return; + } } + this._externalAccess = this.hass.config.external_url !== null; } static get styles(): CSSResultGroup { From cf05fbaa9dc83a66486e4cb596f030caf73c262d Mon Sep 17 00:00:00 2001 From: Steve Repsher Date: Fri, 6 May 2022 07:32:44 -0400 Subject: [PATCH 002/112] Fix enter key support for generic dialog box (#12600) --- src/dialogs/generic/dialog-box.ts | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/src/dialogs/generic/dialog-box.ts b/src/dialogs/generic/dialog-box.ts index 1e8aae7406..4cdb0ef6c9 100644 --- a/src/dialogs/generic/dialog-box.ts +++ b/src/dialogs/generic/dialog-box.ts @@ -1,12 +1,13 @@ import "@material/mwc-button/mwc-button"; import { mdiAlertOutline } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-dialog"; import "../../components/ha-svg-icon"; import "../../components/ha-switch"; -import "../../components/ha-textfield"; +import { HaTextField } from "../../components/ha-textfield"; import { haStyleDialog } from "../../resources/styles"; import { HomeAssistant } from "../../types"; import { DialogBoxParams } from "./show-dialog-box"; @@ -17,13 +18,10 @@ class DialogBox extends LitElement { @state() private _params?: DialogBoxParams; - @state() private _value?: string; + @query("ha-textfield") private _textField?: HaTextField; public async showDialog(params: DialogBoxParams): Promise { this._params = params; - if (params.prompt) { - this._value = params.defaultValue; - } } public closeDialog(): boolean { @@ -75,9 +73,7 @@ class DialogBox extends LitElement { ? html` Date: Fri, 6 May 2022 21:48:57 -0500 Subject: [PATCH 003/112] Revert #10991 (#12618) --- package.json | 1 - src/common/string/filter/char-code.ts | 244 ++++++++ src/common/string/filter/filter.ts | 551 ++++++++++++++++++ src/common/string/filter/sequence-matching.ts | 90 +-- .../data-table/sort_filter_worker.ts | 15 +- src/components/entity/ha-entity-picker.ts | 18 +- src/dialogs/quick-bar/ha-quick-bar.ts | 4 +- test/common/string/sequence_matching.test.ts | 207 ++----- yarn.lock | 8 - 9 files changed, 911 insertions(+), 227 deletions(-) create mode 100644 src/common/string/filter/char-code.ts create mode 100644 src/common/string/filter/filter.ts diff --git a/package.json b/package.json index 23438c75eb..25cbfbe761 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,6 @@ "deep-clone-simple": "^1.1.1", "deep-freeze": "^0.0.1", "fuse.js": "^6.0.0", - "fuzzysort": "^1.2.1", "google-timezones-json": "^1.0.2", "hls.js": "^1.1.5", "home-assistant-js-websocket": "^7.0.3", diff --git a/src/common/string/filter/char-code.ts b/src/common/string/filter/char-code.ts new file mode 100644 index 0000000000..faa7210898 --- /dev/null +++ b/src/common/string/filter/char-code.ts @@ -0,0 +1,244 @@ +// MIT License + +// Copyright (c) 2015 - present Microsoft Corporation + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/ + +/** + * An inlined enum containing useful character codes (to be used with String.charCodeAt). + * Please leave the const keyword such that it gets inlined when compiled to JavaScript! + */ +export enum CharCode { + Null = 0, + /** + * The `\b` character. + */ + Backspace = 8, + /** + * The `\t` character. + */ + Tab = 9, + /** + * The `\n` character. + */ + LineFeed = 10, + /** + * The `\r` character. + */ + CarriageReturn = 13, + Space = 32, + /** + * The `!` character. + */ + ExclamationMark = 33, + /** + * The `"` character. + */ + DoubleQuote = 34, + /** + * The `#` character. + */ + Hash = 35, + /** + * The `$` character. + */ + DollarSign = 36, + /** + * The `%` character. + */ + PercentSign = 37, + /** + * The `&` character. + */ + Ampersand = 38, + /** + * The `'` character. + */ + SingleQuote = 39, + /** + * The `(` character. + */ + OpenParen = 40, + /** + * The `)` character. + */ + CloseParen = 41, + /** + * The `*` character. + */ + Asterisk = 42, + /** + * The `+` character. + */ + Plus = 43, + /** + * The `,` character. + */ + Comma = 44, + /** + * The `-` character. + */ + Dash = 45, + /** + * The `.` character. + */ + Period = 46, + /** + * The `/` character. + */ + Slash = 47, + + Digit0 = 48, + Digit1 = 49, + Digit2 = 50, + Digit3 = 51, + Digit4 = 52, + Digit5 = 53, + Digit6 = 54, + Digit7 = 55, + Digit8 = 56, + Digit9 = 57, + + /** + * The `:` character. + */ + Colon = 58, + /** + * The `;` character. + */ + Semicolon = 59, + /** + * The `<` character. + */ + LessThan = 60, + /** + * The `=` character. + */ + Equals = 61, + /** + * The `>` character. + */ + GreaterThan = 62, + /** + * The `?` character. + */ + QuestionMark = 63, + /** + * The `@` character. + */ + AtSign = 64, + + A = 65, + B = 66, + C = 67, + D = 68, + E = 69, + F = 70, + G = 71, + H = 72, + I = 73, + J = 74, + K = 75, + L = 76, + M = 77, + N = 78, + O = 79, + P = 80, + Q = 81, + R = 82, + S = 83, + T = 84, + U = 85, + V = 86, + W = 87, + X = 88, + Y = 89, + Z = 90, + + /** + * The `[` character. + */ + OpenSquareBracket = 91, + /** + * The `\` character. + */ + Backslash = 92, + /** + * The `]` character. + */ + CloseSquareBracket = 93, + /** + * The `^` character. + */ + Caret = 94, + /** + * The `_` character. + */ + Underline = 95, + /** + * The ``(`)`` character. + */ + BackTick = 96, + + a = 97, + b = 98, + c = 99, + d = 100, + e = 101, + f = 102, + g = 103, + h = 104, + i = 105, + j = 106, + k = 107, + l = 108, + m = 109, + n = 110, + o = 111, + p = 112, + q = 113, + r = 114, + s = 115, + t = 116, + u = 117, + v = 118, + w = 119, + x = 120, + y = 121, + z = 122, + + /** + * The `{` character. + */ + OpenCurlyBrace = 123, + /** + * The `|` character. + */ + Pipe = 124, + /** + * The `}` character. + */ + CloseCurlyBrace = 125, + /** + * The `~` character. + */ + Tilde = 126, +} diff --git a/src/common/string/filter/filter.ts b/src/common/string/filter/filter.ts new file mode 100644 index 0000000000..e7c0103263 --- /dev/null +++ b/src/common/string/filter/filter.ts @@ -0,0 +1,551 @@ +/* eslint-disable no-console */ +// MIT License + +// Copyright (c) 2015 - present Microsoft Corporation + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { CharCode } from "./char-code"; + +const _debug = false; + +export interface Match { + start: number; + end: number; +} + +const _maxLen = 128; + +function initTable() { + const table: number[][] = []; + const row: number[] = []; + for (let i = 0; i <= _maxLen; i++) { + row[i] = 0; + } + for (let i = 0; i <= _maxLen; i++) { + table.push(row.slice(0)); + } + return table; +} + +function isSeparatorAtPos(value: string, index: number): boolean { + if (index < 0 || index >= value.length) { + return false; + } + const code = value.codePointAt(index); + switch (code) { + case CharCode.Underline: + case CharCode.Dash: + case CharCode.Period: + case CharCode.Space: + case CharCode.Slash: + case CharCode.Backslash: + case CharCode.SingleQuote: + case CharCode.DoubleQuote: + case CharCode.Colon: + case CharCode.DollarSign: + case CharCode.LessThan: + case CharCode.OpenParen: + case CharCode.OpenSquareBracket: + return true; + case undefined: + return false; + default: + if (isEmojiImprecise(code)) { + return true; + } + return false; + } +} + +function isWhitespaceAtPos(value: string, index: number): boolean { + if (index < 0 || index >= value.length) { + return false; + } + const code = value.charCodeAt(index); + switch (code) { + case CharCode.Space: + case CharCode.Tab: + return true; + default: + return false; + } +} + +function isUpperCaseAtPos(pos: number, word: string, wordLow: string): boolean { + return word[pos] !== wordLow[pos]; +} + +export function isPatternInWord( + patternLow: string, + patternPos: number, + patternLen: number, + wordLow: string, + wordPos: number, + wordLen: number, + fillMinWordPosArr = false +): boolean { + while (patternPos < patternLen && wordPos < wordLen) { + if (patternLow[patternPos] === wordLow[wordPos]) { + if (fillMinWordPosArr) { + // Remember the min word position for each pattern position + _minWordMatchPos[patternPos] = wordPos; + } + patternPos += 1; + } + wordPos += 1; + } + return patternPos === patternLen; // pattern must be exhausted +} + +enum Arrow { + Diag = 1, + Left = 2, + LeftLeft = 3, +} + +/** + * An array representing a fuzzy match. + * + * 0. the score + * 1. the offset at which matching started + * 2. `` + * 3. `` + * 4. `` etc + */ +// export type FuzzyScore = [score: number, wordStart: number, ...matches: number[]];// [number, number, number]; +export type FuzzyScore = Array; + +export function fuzzyScore( + pattern: string, + patternLow: string, + patternStart: number, + word: string, + wordLow: string, + wordStart: number, + firstMatchCanBeWeak: boolean +): FuzzyScore | undefined { + const patternLen = pattern.length > _maxLen ? _maxLen : pattern.length; + const wordLen = word.length > _maxLen ? _maxLen : word.length; + + if ( + patternStart >= patternLen || + wordStart >= wordLen || + patternLen - patternStart > wordLen - wordStart + ) { + return undefined; + } + + // Run a simple check if the characters of pattern occur + // (in order) at all in word. If that isn't the case we + // stop because no match will be possible + if ( + !isPatternInWord( + patternLow, + patternStart, + patternLen, + wordLow, + wordStart, + wordLen, + true + ) + ) { + return undefined; + } + + // Find the max matching word position for each pattern position + // NOTE: the min matching word position was filled in above, in the `isPatternInWord` call + _fillInMaxWordMatchPos( + patternLen, + wordLen, + patternStart, + wordStart, + patternLow, + wordLow + ); + + let row: number; + let column = 1; + let patternPos: number; + let wordPos: number; + + const hasStrongFirstMatch = [false]; + + // There will be a match, fill in tables + for ( + row = 1, patternPos = patternStart; + patternPos < patternLen; + row++, patternPos++ + ) { + // Reduce search space to possible matching word positions and to possible access from next row + const minWordMatchPos = _minWordMatchPos[patternPos]; + const maxWordMatchPos = _maxWordMatchPos[patternPos]; + const nextMaxWordMatchPos = + patternPos + 1 < patternLen ? _maxWordMatchPos[patternPos + 1] : wordLen; + + for ( + column = minWordMatchPos - wordStart + 1, wordPos = minWordMatchPos; + wordPos < nextMaxWordMatchPos; + column++, wordPos++ + ) { + let score = Number.MIN_SAFE_INTEGER; + let canComeDiag = false; + + if (wordPos <= maxWordMatchPos) { + score = _doScore( + pattern, + patternLow, + patternPos, + patternStart, + word, + wordLow, + wordPos, + wordLen, + wordStart, + _diag[row - 1][column - 1] === 0, + hasStrongFirstMatch + ); + } + + let diagScore = 0; + if (score !== Number.MAX_SAFE_INTEGER) { + canComeDiag = true; + diagScore = score + _table[row - 1][column - 1]; + } + + const canComeLeft = wordPos > minWordMatchPos; + const leftScore = canComeLeft + ? _table[row][column - 1] + (_diag[row][column - 1] > 0 ? -5 : 0) + : 0; // penalty for a gap start + + const canComeLeftLeft = + wordPos > minWordMatchPos + 1 && _diag[row][column - 1] > 0; + const leftLeftScore = canComeLeftLeft + ? _table[row][column - 2] + (_diag[row][column - 2] > 0 ? -5 : 0) + : 0; // penalty for a gap start + + if ( + canComeLeftLeft && + (!canComeLeft || leftLeftScore >= leftScore) && + (!canComeDiag || leftLeftScore >= diagScore) + ) { + // always prefer choosing left left to jump over a diagonal because that means a match is earlier in the word + _table[row][column] = leftLeftScore; + _arrows[row][column] = Arrow.LeftLeft; + _diag[row][column] = 0; + } else if (canComeLeft && (!canComeDiag || leftScore >= diagScore)) { + // always prefer choosing left since that means a match is earlier in the word + _table[row][column] = leftScore; + _arrows[row][column] = Arrow.Left; + _diag[row][column] = 0; + } else if (canComeDiag) { + _table[row][column] = diagScore; + _arrows[row][column] = Arrow.Diag; + _diag[row][column] = _diag[row - 1][column - 1] + 1; + } else { + throw new Error(`not possible`); + } + } + } + + if (_debug) { + printTables(pattern, patternStart, word, wordStart); + } + + if (!hasStrongFirstMatch[0] && !firstMatchCanBeWeak) { + return undefined; + } + + row--; + column--; + + const result: FuzzyScore = [_table[row][column], wordStart]; + + let backwardsDiagLength = 0; + let maxMatchColumn = 0; + + while (row >= 1) { + // Find the column where we go diagonally up + let diagColumn = column; + do { + const arrow = _arrows[row][diagColumn]; + if (arrow === Arrow.LeftLeft) { + diagColumn -= 2; + } else if (arrow === Arrow.Left) { + diagColumn -= 1; + } else { + // found the diagonal + break; + } + } while (diagColumn >= 1); + + // Overturn the "forwards" decision if keeping the "backwards" diagonal would give a better match + if ( + backwardsDiagLength > 1 && // only if we would have a contiguous match of 3 characters + patternLow[patternStart + row - 1] === wordLow[wordStart + column - 1] && // only if we can do a contiguous match diagonally + !isUpperCaseAtPos(diagColumn + wordStart - 1, word, wordLow) && // only if the forwards chose diagonal is not an uppercase + backwardsDiagLength + 1 > _diag[row][diagColumn] // only if our contiguous match would be longer than the "forwards" contiguous match + ) { + diagColumn = column; + } + + if (diagColumn === column) { + // this is a contiguous match + backwardsDiagLength++; + } else { + backwardsDiagLength = 1; + } + + if (!maxMatchColumn) { + // remember the last matched column + maxMatchColumn = diagColumn; + } + + row--; + column = diagColumn - 1; + result.push(column); + } + + if (wordLen === patternLen) { + // the word matches the pattern with all characters! + // giving the score a total match boost (to come up ahead other words) + result[0] += 2; + } + + // Add 1 penalty for each skipped character in the word + const skippedCharsCount = maxMatchColumn - patternLen; + result[0] -= skippedCharsCount; + + return result; +} + +function _doScore( + pattern: string, + patternLow: string, + patternPos: number, + patternStart: number, + word: string, + wordLow: string, + wordPos: number, + wordLen: number, + wordStart: number, + newMatchStart: boolean, + outFirstMatchStrong: boolean[] +): number { + if (patternLow[patternPos] !== wordLow[wordPos]) { + return Number.MIN_SAFE_INTEGER; + } + + let score = 1; + let isGapLocation = false; + if (wordPos === patternPos - patternStart) { + // common prefix: `foobar <-> foobaz` + // ^^^^^ + score = pattern[patternPos] === word[wordPos] ? 7 : 5; + } else if ( + isUpperCaseAtPos(wordPos, word, wordLow) && + (wordPos === 0 || !isUpperCaseAtPos(wordPos - 1, word, wordLow)) + ) { + // hitting upper-case: `foo <-> forOthers` + // ^^ ^ + score = pattern[patternPos] === word[wordPos] ? 7 : 5; + isGapLocation = true; + } else if ( + isSeparatorAtPos(wordLow, wordPos) && + (wordPos === 0 || !isSeparatorAtPos(wordLow, wordPos - 1)) + ) { + // hitting a separator: `. <-> foo.bar` + // ^ + score = 5; + } else if ( + isSeparatorAtPos(wordLow, wordPos - 1) || + isWhitespaceAtPos(wordLow, wordPos - 1) + ) { + // post separator: `foo <-> bar_foo` + // ^^^ + score = 5; + isGapLocation = true; + } + + if (score > 1 && patternPos === patternStart) { + outFirstMatchStrong[0] = true; + } + + if (!isGapLocation) { + isGapLocation = + isUpperCaseAtPos(wordPos, word, wordLow) || + isSeparatorAtPos(wordLow, wordPos - 1) || + isWhitespaceAtPos(wordLow, wordPos - 1); + } + + // + if (patternPos === patternStart) { + // first character in pattern + if (wordPos > wordStart) { + // the first pattern character would match a word character that is not at the word start + // so introduce a penalty to account for the gap preceding this match + score -= isGapLocation ? 3 : 5; + } + } else if (newMatchStart) { + // this would be the beginning of a new match (i.e. there would be a gap before this location) + score += isGapLocation ? 2 : 0; + } else { + // this is part of a contiguous match, so give it a slight bonus, but do so only if it would not be a prefered gap location + score += isGapLocation ? 0 : 1; + } + + if (wordPos + 1 === wordLen) { + // we always penalize gaps, but this gives unfair advantages to a match that would match the last character in the word + // so pretend there is a gap after the last character in the word to normalize things + score -= isGapLocation ? 3 : 5; + } + + return score; +} + +function printTable( + table: number[][], + pattern: string, + patternLen: number, + word: string, + wordLen: number +): string { + function pad(s: string, n: number, _pad = " ") { + while (s.length < n) { + s = _pad + s; + } + return s; + } + let ret = ` | |${word + .split("") + .map((c) => pad(c, 3)) + .join("|")}\n`; + + for (let i = 0; i <= patternLen; i++) { + if (i === 0) { + ret += " |"; + } else { + ret += `${pattern[i - 1]}|`; + } + ret += + table[i] + .slice(0, wordLen + 1) + .map((n) => pad(n.toString(), 3)) + .join("|") + "\n"; + } + return ret; +} + +function printTables( + pattern: string, + patternStart: number, + word: string, + wordStart: number +): void { + pattern = pattern.substr(patternStart); + word = word.substr(wordStart); + console.log(printTable(_table, pattern, pattern.length, word, word.length)); + console.log(printTable(_arrows, pattern, pattern.length, word, word.length)); + console.log(printTable(_diag, pattern, pattern.length, word, word.length)); +} + +const _minWordMatchPos = initArr(2 * _maxLen); // min word position for a certain pattern position +const _maxWordMatchPos = initArr(2 * _maxLen); // max word position for a certain pattern position +const _diag = initTable(); // the length of a contiguous diagonal match +const _table = initTable(); +const _arrows = initTable(); + +function initArr(maxLen: number) { + const row: number[] = []; + for (let i = 0; i <= maxLen; i++) { + row[i] = 0; + } + return row; +} + +function _fillInMaxWordMatchPos( + patternLen: number, + wordLen: number, + patternStart: number, + wordStart: number, + patternLow: string, + wordLow: string +) { + let patternPos = patternLen - 1; + let wordPos = wordLen - 1; + while (patternPos >= patternStart && wordPos >= wordStart) { + if (patternLow[patternPos] === wordLow[wordPos]) { + _maxWordMatchPos[patternPos] = wordPos; + patternPos--; + } + wordPos--; + } +} + +export interface FuzzyScorer { + ( + pattern: string, + lowPattern: string, + patternPos: number, + word: string, + lowWord: string, + wordPos: number, + firstMatchCanBeWeak: boolean + ): FuzzyScore | undefined; +} + +export function createMatches(score: undefined | FuzzyScore): Match[] { + if (typeof score === "undefined") { + return []; + } + const res: Match[] = []; + const wordPos = score[1]; + for (let i = score.length - 1; i > 1; i--) { + const pos = score[i] + wordPos; + const last = res[res.length - 1]; + if (last && last.end === pos) { + last.end = pos + 1; + } else { + res.push({ start: pos, end: pos + 1 }); + } + } + return res; +} + +/** + * A fast function (therefore imprecise) to check if code points are emojis. + * Generated using https://github.com/alexdima/unicode-utils/blob/master/generate-emoji-test.js + */ +export function isEmojiImprecise(x: number): boolean { + return ( + (x >= 0x1f1e6 && x <= 0x1f1ff) || + x === 8986 || + x === 8987 || + x === 9200 || + x === 9203 || + (x >= 9728 && x <= 10175) || + x === 11088 || + x === 11093 || + (x >= 127744 && x <= 128591) || + (x >= 128640 && x <= 128764) || + (x >= 128992 && x <= 129003) || + (x >= 129280 && x <= 129535) || + (x >= 129648 && x <= 129750) + ); +} diff --git a/src/common/string/filter/sequence-matching.ts b/src/common/string/filter/sequence-matching.ts index d0ec1f1e52..d502cec350 100644 --- a/src/common/string/filter/sequence-matching.ts +++ b/src/common/string/filter/sequence-matching.ts @@ -1,4 +1,52 @@ -import fuzzysort from "fuzzysort"; +import { fuzzyScore } from "./filter"; + +/** + * Determine whether a sequence of letters exists in another string, + * in that order, allowing for skipping. Ex: "chdr" exists in "chandelier") + * + * @param {string} filter - Sequence of letters to check for + * @param {ScorableTextItem} item - Item against whose strings will be checked + * + * @return {number} Score representing how well the word matches the filter. Return of 0 means no match. + */ + +export const fuzzySequentialMatch = ( + filter: string, + item: ScorableTextItem +) => { + let topScore = Number.NEGATIVE_INFINITY; + + for (const word of item.strings) { + const scores = fuzzyScore( + filter, + filter.toLowerCase(), + 0, + word, + word.toLowerCase(), + 0, + true + ); + + if (!scores) { + continue; + } + + // The VS Code implementation of filter returns a 0 for a weak match. + // But if .filter() sees a "0", it considers that a failed match and will remove it. + // So, we set score to 1 in these cases so the match will be included, and mostly respect correct ordering. + const score = scores[0] === 0 ? 1 : scores[0]; + + if (score > topScore) { + topScore = score; + } + } + + if (topScore === Number.NEGATIVE_INFINITY) { + return undefined; + } + + return topScore; +}; /** * An interface that objects must extend in order to use the fuzzy sequence matcher @@ -18,48 +66,18 @@ export interface ScorableTextItem { strings: string[]; } -export type FuzzyFilterSort = ( +type FuzzyFilterSort = ( filter: string, items: T[] ) => T[]; -export function fuzzyMatcher(search: string | null): (string) => boolean { - const scorer = fuzzyScorer(search); - return (value: string) => scorer([value]) !== Number.NEGATIVE_INFINITY; -} - -export function fuzzyScorer( - search: string | null -): (values: string[]) => number { - const searchTerms = (search || "").match(/("[^"]+"|[^"\s]+)/g); - if (!searchTerms) { - return () => 0; - } - return (values) => - searchTerms - .map((term) => { - const resultsForTerm = fuzzysort.go(term, values, { - allowTypo: true, - }); - if (resultsForTerm.length > 0) { - return Math.max(...resultsForTerm.map((result) => result.score)); - } - return Number.NEGATIVE_INFINITY; - }) - .reduce((partial, current) => partial + current, 0); -} - -export const fuzzySortFilterSort: FuzzyFilterSort = (filter, items) => { - const scorer = fuzzyScorer(filter); - return items +export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => + items .map((item) => { - item.score = scorer(item.strings); + item.score = fuzzySequentialMatch(filter, item); return item; }) - .filter((item) => item.score !== undefined && item.score > -100000) + .filter((item) => item.score !== undefined) .sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) => scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0 ); -}; - -export const defaultFuzzyFilterSort = fuzzySortFilterSort; diff --git a/src/components/data-table/sort_filter_worker.ts b/src/components/data-table/sort_filter_worker.ts index c2b252f859..286bae3014 100644 --- a/src/components/data-table/sort_filter_worker.ts +++ b/src/components/data-table/sort_filter_worker.ts @@ -7,26 +7,25 @@ import type { SortableColumnContainer, SortingDirection, } from "./ha-data-table"; -import { fuzzyMatcher } from "../../common/string/filter/sequence-matching"; const filterData = ( data: DataTableRowData[], columns: SortableColumnContainer, filter: string ) => { - const matcher = fuzzyMatcher(filter); + filter = filter.toUpperCase(); return data.filter((row) => Object.entries(columns).some((columnEntry) => { const [key, column] = columnEntry; if (column.filterable) { if ( - matcher( - String( - column.filterKey - ? row[column.valueColumn || key][column.filterKey] - : row[column.valueColumn || key] - ) + String( + column.filterKey + ? row[column.valueColumn || key][column.filterKey] + : row[column.valueColumn || key] ) + .toUpperCase() + .includes(filter) ) { return true; } diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index ad921650ce..7f9c8d3d80 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -15,7 +15,6 @@ import type { HaComboBox } from "../ha-combo-box"; import "../ha-icon-button"; import "../ha-svg-icon"; import "./state-badge"; -import { defaultFuzzyFilterSort } from "../../common/string/filter/sequence-matching"; interface HassEntityWithCachedName extends HassEntity { friendly_name: string; @@ -337,18 +336,11 @@ export class HaEntityPicker extends LitElement { } private _filterChanged(ev: CustomEvent): void { - const filterString = ev.detail.value; - - const sortableEntityStates = this._states.map((entityState) => ({ - strings: [entityState.entity_id, computeStateName(entityState)], - entityState: entityState, - })); - const sortedEntityStates = defaultFuzzyFilterSort( - filterString, - sortableEntityStates - ); - (this.comboBox as any).filteredItems = sortedEntityStates.map( - (sortableItem) => sortableItem.entityState + const filterString = ev.detail.value.toLowerCase(); + (this.comboBox as any).filteredItems = this._states.filter( + (entityState) => + entityState.entity_id.toLowerCase().includes(filterString) || + computeStateName(entityState).toLowerCase().includes(filterString) ); } diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index 42007f2684..c87ef4244b 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -25,7 +25,7 @@ import { domainIcon } from "../../common/entity/domain_icon"; import { navigate } from "../../common/navigate"; import { caseInsensitiveStringCompare } from "../../common/string/compare"; import { - defaultFuzzyFilterSort, + fuzzyFilterSort, ScorableTextItem, } from "../../common/string/filter/sequence-matching"; import { debounce } from "../../common/util/debounce"; @@ -725,7 +725,7 @@ export class QuickBar extends LitElement { private _filterItems = memoizeOne( (items: QuickBarItem[], filter: string): QuickBarItem[] => - defaultFuzzyFilterSort(filter.trimLeft(), items) + fuzzyFilterSort(filter.trimLeft(), items) ); static get styles() { diff --git a/test/common/string/sequence_matching.test.ts b/test/common/string/sequence_matching.test.ts index 8f8f63bada..f631a23285 100644 --- a/test/common/string/sequence_matching.test.ts +++ b/test/common/string/sequence_matching.test.ts @@ -1,7 +1,8 @@ -import { assert, expect } from "chai"; +import { assert } from "chai"; import { - fuzzySortFilterSort, + fuzzyFilterSort, + fuzzySequentialMatch, ScorableTextItem, } from "../../../src/common/string/filter/sequence-matching"; @@ -10,34 +11,45 @@ describe("fuzzySequentialMatch", () => { strings: ["automation.ticker", "Stocks"], }; + const createExpectation: ( + pattern, + expected + ) => { + pattern: string; + expected: string | number | undefined; + } = (pattern, expected) => ({ + pattern, + expected, + }); + const shouldMatchEntity = [ - "", - " ", - "automation.ticker", - "stocks", - "automation.ticke", - "automation. ticke", - "automation.", - "automationticker", - "automation.r", - "aumatick", - "tion.tick", - "aion.tck", - "s", - "au.tce", - "au", - "ticker", - "tick", - "ioticker", - "sks", - "tomaontkr", - "atmto.ikr", - "uoaintce", + createExpectation("automation.ticker", 131), + createExpectation("automation.ticke", 121), + createExpectation("automation.", 82), + createExpectation("au", 10), + createExpectation("automationticker", 85), + createExpectation("tion.tick", 8), + createExpectation("ticker", -4), + createExpectation("automation.r", 73), + createExpectation("tick", -8), + createExpectation("aumatick", 9), + createExpectation("aion.tck", 4), + createExpectation("ioticker", -4), + createExpectation("atmto.ikr", -34), + createExpectation("uoaintce", -39), + createExpectation("au.tce", -3), + createExpectation("tomaontkr", -19), + createExpectation("s", 1), + createExpectation("stocks", 42), + createExpectation("sks", -5), ]; const shouldNotMatchEntity = [ + "", + " ", "abcdefghijklmnopqrstuvwxyz", "automation.tickerz", + "automation. ticke", "1", "noitamotua", "autostocks", @@ -45,23 +57,23 @@ describe("fuzzySequentialMatch", () => { ]; describe(`Entity '${item.strings[0]}'`, () => { - for (const filter of shouldMatchEntity) { - it(`Should matches ${filter}`, () => { - const res = fuzzySortFilterSort(filter, [item]); - assert.lengthOf(res, 1); + for (const expectation of shouldMatchEntity) { + it(`matches '${expectation.pattern}' with return of '${expectation.expected}'`, () => { + const res = fuzzySequentialMatch(expectation.pattern, item); + assert.equal(res, expectation.expected); }); } for (const badFilter of shouldNotMatchEntity) { it(`fails to match with '${badFilter}'`, () => { - const res = fuzzySortFilterSort(badFilter, [item]); - assert.lengthOf(res, 0); + const res = fuzzySequentialMatch(badFilter, item); + assert.equal(res, undefined); }); } }); }); -describe("fuzzyFilterSort original tests", () => { +describe("fuzzyFilterSort", () => { const filter = "ticker"; const automationTicker = { strings: ["automation.ticker", "Stocks"], @@ -93,137 +105,14 @@ describe("fuzzyFilterSort original tests", () => { it(`filters and sorts correctly`, () => { const expectedItemsAfterFilter = [ - { ...ticker, score: 0 }, - { ...sensorTicker, score: -14 }, - { ...automationTicker, score: -22 }, - { ...timerCheckRouter, score: -32012 }, + { ...ticker, score: 44 }, + { ...sensorTicker, score: 1 }, + { ...automationTicker, score: -4 }, + { ...timerCheckRouter, score: -8 }, ]; - const res = fuzzySortFilterSort(filter, itemsBeforeFilter); + const res = fuzzyFilterSort(filter, itemsBeforeFilter); assert.deepEqual(res, expectedItemsAfterFilter); }); }); - -describe("Fuzzy filter new tests", () => { - const testEntities = [ - { - id: "binary_sensor.garage_door_opened", - name: "Garage Door Opened (Sensor, Binary)", - }, - { - id: "sensor.garage_door_status", - name: "Garage Door Opened (Sensor)", - }, - { - id: "sensor.temperature_living_room", - name: "[Living room] temperature", - }, - { - id: "sensor.temperature_parents_bedroom", - name: "[Parents bedroom] temperature", - }, - { - id: "sensor.temperature_children_bedroom", - name: "[Children bedroom] temperature", - }, - ]; - - function testEntitySearch( - searchInput: string | null, - expectedResults: string[] - ) { - const sortableEntities = testEntities.map((entity) => ({ - strings: [entity.id, entity.name], - entity: entity, - })); - const sortedEntities = fuzzySortFilterSort( - searchInput || "", - sortableEntities - ); - // console.log(sortedEntities); - expect(sortedEntities.map((it) => it.entity.id)).to.have.ordered.members( - expectedResults - ); - } - - it(`test empty or null query`, () => { - testEntitySearch( - "", - testEntities.map((it) => it.id) - ); - testEntitySearch( - null, - testEntities.map((it) => it.id) - ); - }); - - it(`test single word search`, () => { - testEntitySearch("bedroom", [ - "sensor.temperature_parents_bedroom", - "sensor.temperature_children_bedroom", - ]); - }); - - it(`test no result`, () => { - testEntitySearch("does not exist", []); - testEntitySearch("betroom", []); - }); - - it(`test single word search with typo`, () => { - testEntitySearch("bedorom", [ - "sensor.temperature_parents_bedroom", - "sensor.temperature_children_bedroom", - ]); - }); - - it(`test multi word search`, () => { - testEntitySearch("bedroom children", [ - "sensor.temperature_children_bedroom", - ]); - }); - - it(`test partial word search`, () => { - testEntitySearch("room", [ - "sensor.temperature_living_room", - "sensor.temperature_parents_bedroom", - "sensor.temperature_children_bedroom", - ]); - }); - - it(`test mixed cased word search`, () => { - testEntitySearch("garage binary", ["binary_sensor.garage_door_opened"]); - }); - - it(`test mixed id and name search`, () => { - testEntitySearch("status opened", ["sensor.garage_door_status"]); - }); - - it(`test special chars in query`, () => { - testEntitySearch("sensor.temperature", [ - "sensor.temperature_living_room", - "sensor.temperature_parents_bedroom", - "sensor.temperature_children_bedroom", - ]); - - testEntitySearch("sensor.temperature parents", [ - "sensor.temperature_parents_bedroom", - ]); - testEntitySearch("parents_Bedroom", ["sensor.temperature_parents_bedroom"]); - }); - - it(`test search in name`, () => { - testEntitySearch("Binary)", ["binary_sensor.garage_door_opened"]); - - testEntitySearch("Binary)NotExists", []); - }); - - it(`test regex special chars`, () => { - // Should return an empty result, but no error - testEntitySearch("\\{}()*+?.,[])", []); - - testEntitySearch("[Children bedroom]", [ - "sensor.temperature_children_bedroom", - ]); - }); -}); diff --git a/yarn.lock b/yarn.lock index 1275415a57..af02cfdc2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8433,13 +8433,6 @@ fsevents@^1.2.7: languageName: node linkType: hard -"fuzzysort@npm:^1.2.1": - version: 1.2.1 - resolution: "fuzzysort@npm:1.2.1" - checksum: 74dad902a0aef6c3237d5ae5330aacca23d408f0e07125fcc39b57561b4c29da512fbf3826c3f3918da89f132f5b393cf5d56b3217282ecfb80a90124bdf03d1 - languageName: node - linkType: hard - "gauge@npm:~2.7.3": version: 2.7.4 resolution: "gauge@npm:2.7.4" @@ -9126,7 +9119,6 @@ fsevents@^1.2.7: fancy-log: ^1.3.3 fs-extra: ^7.0.1 fuse.js: ^6.0.0 - fuzzysort: ^1.2.1 glob: ^7.2.0 google-timezones-json: ^1.0.2 gulp: ^4.0.2 From 6c73ae5bf74c27a0c7217dc90df8254e51344650 Mon Sep 17 00:00:00 2001 From: Yosi Levy Date: Sat, 7 May 2022 06:39:39 +0300 Subject: [PATCH 004/112] Replace host-context with css properties --- src/components/ha-fab.ts | 8 +++----- src/panels/config/logs/error-log-card.ts | 4 ++-- src/state/translations-mixin.ts | 19 ++++++++++++++++++- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/components/ha-fab.ts b/src/components/ha-fab.ts index 563ee32e32..8d73e8bda4 100644 --- a/src/components/ha-fab.ts +++ b/src/components/ha-fab.ts @@ -11,11 +11,9 @@ export class HaFab extends Fab { static override styles = Fab.styles.concat([ css` - :host-context([style*="direction: rtl;"]) - .mdc-fab--extended - .mdc-fab__icon { - margin-left: 12px !important; - margin-right: calc(12px - 20px) !important; + .mdc-fab--extended .mdc-fab__icon { + margin-left: var(--rtl-12px, calc(12px - 20px)) !important; + margin-right: var(--rtl--8px, 12px) !important; } `, ]); diff --git a/src/panels/config/logs/error-log-card.ts b/src/panels/config/logs/error-log-card.ts index 2a7f91fba2..f3458df109 100644 --- a/src/panels/config/logs/error-log-card.ts +++ b/src/panels/config/logs/error-log-card.ts @@ -235,8 +235,8 @@ class ErrorLogCard extends LitElement { color: var(--warning-color); } - :host-context([style*="direction: rtl;"]) mwc-button { - direction: rtl; + mwc-button { + direction: var(--rtl-dir, ltr); } `; } diff --git a/src/state/translations-mixin.ts b/src/state/translations-mixin.ts index 5a118b67cc..37f7d9893d 100644 --- a/src/state/translations-mixin.ts +++ b/src/state/translations-mixin.ts @@ -180,12 +180,29 @@ export default >(superClass: T) => private _applyTranslations(hass: HomeAssistant) { document.querySelector("html")!.setAttribute("lang", hass.language); - this.style.direction = computeRTL(hass) ? "rtl" : "ltr"; + this._applyDirection(hass); this._loadCoreTranslations(hass.language); this.__loadedFragmetTranslations = new Set(); this._loadFragmentTranslations(hass.language, hass.panelUrl); } + private _applyDirection(hass: HomeAssistant) { + if (computeRTL(hass)) { + this.style.direction = "rtl"; + // apply custom properties used to fix RTL appearance throughout the system + this.style.setProperty("--rtl-12px", "12px"); + this.style.setProperty("--rtl--8px", "-8px"); + this.style.setProperty("--rtl-dir", "rtl"); + } else { + // clear all custom properties (can't use "all" for this) + for (let i = this.style.length; i--; ) { + this.style.removeProperty(this.style[i]); + } + + this.style.direction = "ltr"; + } + } + /** * Load translations from the backend * @param language language to fetch From 6faa3eb848f95490467f323e311e011de335db36 Mon Sep 17 00:00:00 2001 From: Philip Allgaier Date: Mon, 9 May 2022 09:47:39 +0200 Subject: [PATCH 005/112] Remove "Lovelace" from Github issue templates (#12614) * Remove "Lovelace" from Github issue templates * Changes from review --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 ++-- .github/ISSUE_TEMPLATE/config.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3529bccbf4..58ef3a040a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,4 +1,4 @@ -name: Report a bug with the UI, Frontend or Lovelace +name: Report a bug with the UI / Dashboards description: Report an issue related to the Home Assistant frontend. labels: bug body: @@ -9,7 +9,7 @@ body: If you have a feature or enhancement request for the frontend, please [start an discussion][fr] instead of creating an issue. - **Please not not report issues for custom Lovelace cards.** + **Please not not report issues for custom cards.** [fr]: https://github.com/home-assistant/frontend/discussions [releases]: https://github.com/home-assistant/home-assistant/releases diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7468455df2..689b0c011d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,17 +1,17 @@ blank_issues_enabled: false contact_links: - - name: Request a feature for the UI, Frontend or Lovelace + - name: Request a feature for the UI / Dashboards url: https://github.com/home-assistant/frontend/discussions/category_choices about: Request an new feature for the Home Assistant frontend. - - name: Report a bug that is NOT related to the UI, Frontend or Lovelace + - name: Report a bug that is NOT related to the UI / Dashboards url: https://github.com/home-assistant/core/issues - about: This is the issue tracker for our frontend. Please report other issues with the backend repository. + about: This is the issue tracker for our frontend. Please report other issues in the backend ("core") repository. - name: Report incorrect or missing information on our website url: https://github.com/home-assistant/home-assistant.io/issues about: Our documentation has its own issue tracker. Please report issues with the website there. - name: I have a question or need support url: https://www.home-assistant.io/help - about: We use GitHub for tracking bugs, check our website for resources on getting help. + about: We use GitHub for tracking bugs. Check our website for resources on getting help. - name: I'm unsure where to go url: https://www.home-assistant.io/join-chat about: If you are unsure where to go, then joining our chat is recommended; Just ask! From 9ed069ef6ae4973ad629e6aa78ee4bc48c73bc19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 9 May 2022 15:07:01 +0200 Subject: [PATCH 006/112] Get full core logs from core (#12639) --- src/panels/config/logs/error-log-card.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panels/config/logs/error-log-card.ts b/src/panels/config/logs/error-log-card.ts index 2a7f91fba2..465ab0ad0c 100644 --- a/src/panels/config/logs/error-log-card.ts +++ b/src/panels/config/logs/error-log-card.ts @@ -124,7 +124,7 @@ class ErrorLogCard extends LitElement { this._logHTML = this.hass.localize("ui.panel.config.logs.loading_log"); let log: string; - if (isComponentLoaded(this.hass, "hassio")) { + if (this.provider !== "core" && isComponentLoaded(this.hass, "hassio")) { try { log = await fetchHassioLogs(this.hass, this.provider); if (this.filter) { From ca37aff47d3d5085c9bf49c3bbe9598f657c519a Mon Sep 17 00:00:00 2001 From: Zack Barett Date: Mon, 9 May 2022 08:07:17 -0500 Subject: [PATCH 007/112] Move YAML to first tab of Developer Tools (#12589) --- src/panels/developer-tools/developer-tools-router.ts | 2 +- src/panels/developer-tools/ha-panel-developer-tools.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/panels/developer-tools/developer-tools-router.ts b/src/panels/developer-tools/developer-tools-router.ts index 7fc389ae84..b614f5e817 100644 --- a/src/panels/developer-tools/developer-tools-router.ts +++ b/src/panels/developer-tools/developer-tools-router.ts @@ -14,7 +14,7 @@ class DeveloperToolsRouter extends HassRouterPage { beforeRender: (page) => { if (!page || page === "not_found") { // If we can, we are going to restore the last visited page. - return this._currentPage ? this._currentPage : "state"; + return this._currentPage ? this._currentPage : "yaml"; } return undefined; }, diff --git a/src/panels/developer-tools/ha-panel-developer-tools.ts b/src/panels/developer-tools/ha-panel-developer-tools.ts index cc8e80500c..9f9f6f1f7f 100644 --- a/src/panels/developer-tools/ha-panel-developer-tools.ts +++ b/src/panels/developer-tools/ha-panel-developer-tools.ts @@ -42,6 +42,9 @@ class PanelDeveloperTools extends LitElement { .selected=${page} @iron-activate=${this.handlePageSelected} > + + ${this.hass.localize("ui.panel.developer-tools.tabs.yaml.title")} + ${this.hass.localize( "ui.panel.developer-tools.tabs.states.title" @@ -67,9 +70,6 @@ class PanelDeveloperTools extends LitElement { "ui.panel.developer-tools.tabs.statistics.title" )} - - ${this.hass.localize("ui.panel.developer-tools.tabs.yaml.title")} - Date: Mon, 9 May 2022 08:03:59 -0700 Subject: [PATCH 008/112] Add configuration panel for Application Credentials (#12344) Co-authored-by: Zack Barett Co-authored-by: Zack --- src/components/data-table/ha-data-table.ts | 4 +- src/data/application_credential.ts | 44 +++ .../dialog-add-application-credential.ts | 224 +++++++++++++++ .../ha-config-application-credentials.ts | 259 ++++++++++++++++++ .../show-dialog-add-application-credential.ts | 22 ++ .../devices/ha-config-devices-dashboard.ts | 7 +- .../config/entities/ha-config-entities.ts | 5 + src/panels/config/ha-panel-config.ts | 5 + .../config/helpers/ha-config-helpers.ts | 5 + .../integrations/ha-config-integrations.ts | 93 ++++--- .../ha-integration-overflow-menu.ts | 45 +++ src/translations/en.json | 24 ++ 12 files changed, 696 insertions(+), 41 deletions(-) create mode 100644 src/data/application_credential.ts create mode 100644 src/panels/config/application_credentials/dialog-add-application-credential.ts create mode 100644 src/panels/config/application_credentials/ha-config-application-credentials.ts create mode 100644 src/panels/config/application_credentials/show-dialog-add-application-credential.ts create mode 100644 src/panels/config/integrations/ha-integration-overflow-menu.ts diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index d5741cafeb..a9fa5ba391 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -269,8 +269,8 @@ export class HaDataTable extends LitElement { @change=${this._handleHeaderRowCheckboxClick} .indeterminate=${this._checkedRows.length && this._checkedRows.length !== this._checkableRowsCount} - .checked=${this._checkedRows.length === - this._checkableRowsCount} + .checked=${this._checkedRows.length && + this._checkedRows.length === this._checkableRowsCount} > diff --git a/src/data/application_credential.ts b/src/data/application_credential.ts new file mode 100644 index 0000000000..0062301597 --- /dev/null +++ b/src/data/application_credential.ts @@ -0,0 +1,44 @@ +import { HomeAssistant } from "../types"; + +export interface ApplicationCredentialsConfig { + domains: string[]; +} + +export interface ApplicationCredential { + id: string; + domain: string; + client_id: string; + client_secret: string; +} + +export const fetchApplicationCredentialsConfig = async (hass: HomeAssistant) => + hass.callWS({ + type: "application_credentials/config", + }); + +export const fetchApplicationCredentials = async (hass: HomeAssistant) => + hass.callWS({ + type: "application_credentials/list", + }); + +export const createApplicationCredential = async ( + hass: HomeAssistant, + domain: string, + clientId: string, + clientSecret: string +) => + hass.callWS({ + type: "application_credentials/create", + domain, + client_id: clientId, + client_secret: clientSecret, + }); + +export const deleteApplicationCredential = async ( + hass: HomeAssistant, + applicationCredentialsId: string +) => + hass.callWS({ + type: "application_credentials/delete", + application_credentials_id: applicationCredentialsId, + }); diff --git a/src/panels/config/application_credentials/dialog-add-application-credential.ts b/src/panels/config/application_credentials/dialog-add-application-credential.ts new file mode 100644 index 0000000000..f60ea765b1 --- /dev/null +++ b/src/panels/config/application_credentials/dialog-add-application-credential.ts @@ -0,0 +1,224 @@ +import "@material/mwc-button"; +import "@material/mwc-list/mwc-list-item"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { ComboBoxLitRenderer } from "lit-vaadin-helpers"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-circular-progress"; +import "../../../components/ha-combo-box"; +import { createCloseHeading } from "../../../components/ha-dialog"; +import "../../../components/ha-textfield"; +import { + fetchApplicationCredentialsConfig, + createApplicationCredential, + ApplicationCredential, +} from "../../../data/application_credential"; +import { domainToName } from "../../../data/integration"; +import { PolymerChangedEvent } from "../../../polymer-types"; +import { haStyleDialog } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import { AddApplicationCredentialDialogParams } from "./show-dialog-add-application-credential"; + +interface Domain { + id: string; + name: string; +} + +const rowRenderer: ComboBoxLitRenderer = (item) => html` + ${item.name} +`; + +@customElement("dialog-add-application-credential") +export class DialogAddApplicationCredential extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _loading = false; + + // Error message when can't talk to server etc + @state() private _error?: string; + + @state() private _params?: AddApplicationCredentialDialogParams; + + @state() private _domain?: string; + + @state() private _clientId?: string; + + @state() private _clientSecret?: string; + + @state() private _domains?: Domain[]; + + public showDialog(params: AddApplicationCredentialDialogParams) { + this._params = params; + this._domain = ""; + this._clientId = ""; + this._clientSecret = ""; + this._error = undefined; + this._loading = false; + this._fetchConfig(); + } + + private async _fetchConfig() { + const config = await fetchApplicationCredentialsConfig(this.hass); + this._domains = config.domains.map((domain) => ({ + id: domain, + name: domainToName(this.hass.localize, domain), + })); + } + + protected render(): TemplateResult { + if (!this._params || !this._domains) { + return html``; + } + return html` + +
+ ${this._error ? html`
${this._error}
` : ""} + + + +
+ ${this._loading + ? html` +
+ +
+ ` + : html` + + ${this.hass.localize( + "ui.panel.config.application_credentials.editor.create" + )} + + `} +
+ `; + } + + public closeDialog() { + this._params = undefined; + this._domains = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private async _handleDomainPicked(ev: PolymerChangedEvent) { + const target = ev.target as any; + if (target.selectedItem) { + this._domain = target.selectedItem.id; + } + } + + private _handleValueChanged(ev: CustomEvent) { + this._error = undefined; + const name = (ev.target as any).name; + const value = (ev.target as any).value; + this[`_${name}`] = value; + } + + private async _createApplicationCredential(ev) { + ev.preventDefault(); + if (!this._domain || !this._clientId || !this._clientSecret) { + return; + } + + this._loading = true; + this._error = ""; + + let applicationCredential: ApplicationCredential; + try { + applicationCredential = await createApplicationCredential( + this.hass, + this._domain, + this._clientId, + this._clientSecret + ); + } catch (err: any) { + this._loading = false; + this._error = err.message; + return; + } + this._params!.applicationCredentialAddedCallback(applicationCredential); + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + ha-dialog { + --mdc-dialog-max-width: 500px; + --dialog-z-index: 10; + } + .row { + display: flex; + padding: 8px 0; + } + ha-combo-box { + display: block; + margin-bottom: 24px; + } + ha-textfield { + display: block; + margin-bottom: 24px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-add-application-credential": DialogAddApplicationCredential; + } +} diff --git a/src/panels/config/application_credentials/ha-config-application-credentials.ts b/src/panels/config/application_credentials/ha-config-application-credentials.ts new file mode 100644 index 0000000000..fa55f07681 --- /dev/null +++ b/src/panels/config/application_credentials/ha-config-application-credentials.ts @@ -0,0 +1,259 @@ +import { mdiDelete, mdiPlus } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; +import type { HASSDomEvent } from "../../../common/dom/fire_event"; +import { LocalizeFunc } from "../../../common/translations/localize"; +import { + DataTableColumnContainer, + SelectionChangedEvent, +} from "../../../components/data-table/ha-data-table"; +import "../../../components/data-table/ha-data-table-icon"; +import "../../../components/ha-fab"; +import "../../../components/ha-help-tooltip"; +import "../../../components/ha-svg-icon"; +import { + ApplicationCredential, + deleteApplicationCredential, + fetchApplicationCredentials, +} from "../../../data/application_credential"; +import { domainToName } from "../../../data/integration"; +import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; +import "../../../layouts/hass-tabs-subpage-data-table"; +import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table"; +import { HomeAssistant, Route } from "../../../types"; +import { configSections } from "../ha-panel-config"; +import { showAddApplicationCredentialDialog } from "./show-dialog-add-application-credential"; + +@customElement("ha-config-application-credentials") +export class HaConfigApplicationCredentials extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() public _applicationCredentials: ApplicationCredential[] = []; + + @property() public isWide!: boolean; + + @property() public narrow!: boolean; + + @property() public route!: Route; + + @state() private _selected: string[] = []; + + @query("hass-tabs-subpage-data-table", true) + private _dataTable!: HaTabsSubpageDataTable; + + private _columns = memoizeOne( + (narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => { + const columns: DataTableColumnContainer = { + clientId: { + title: localize( + "ui.panel.config.application_credentials.picker.headers.client_id" + ), + width: "25%", + direction: "asc", + grows: true, + template: (_, entry: ApplicationCredential) => + html`${entry.client_id}`, + }, + application: { + title: localize( + "ui.panel.config.application_credentials.picker.headers.application" + ), + sortable: true, + width: "20%", + direction: "asc", + hidden: narrow, + template: (_, entry) => html`${domainToName(localize, entry.domain)}`, + }, + }; + + return columns; + } + ); + + protected firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + this._loadTranslations(); + this._fetchApplicationCredentials(); + } + + protected render() { + return html` + + ${this._selected.length + ? html` +
+

+ ${this.hass.localize( + "ui.panel.config.application_credentials.picker.selected", + "number", + this._selected.length + )} +

+
+ ${!this.narrow + ? html` + ${this.hass.localize( + "ui.panel.config.application_credentials.picker.remove_selected.button" + )} + ` + : html` + + + + `} +
+
+ ` + : html``} + + + +
+ `; + } + + private _handleSelectionChanged( + ev: HASSDomEvent + ): void { + this._selected = ev.detail.value; + } + + private _removeSelected() { + showConfirmationDialog(this, { + title: this.hass.localize( + `ui.panel.config.application_credentials.picker.remove_selected.confirm_title`, + "number", + this._selected.length + ), + text: this.hass.localize( + "ui.panel.config.application_credentials.picker.remove_selected.confirm_text" + ), + confirmText: this.hass.localize("ui.common.remove"), + dismissText: this.hass.localize("ui.common.cancel"), + confirm: async () => { + await Promise.all( + this._selected.map(async (applicationCredential) => { + await deleteApplicationCredential(this.hass, applicationCredential); + }) + ); + this._dataTable.clearSelection(); + this._fetchApplicationCredentials(); + }, + }); + } + + private async _loadTranslations() { + await this.hass.loadBackendTranslation("title", undefined, true); + } + + private async _fetchApplicationCredentials() { + this._applicationCredentials = await fetchApplicationCredentials(this.hass); + } + + private _addApplicationCredential() { + showAddApplicationCredentialDialog(this, { + applicationCredentialAddedCallback: async ( + applicationCredential: ApplicationCredential + ) => { + if (applicationCredential) { + this._applicationCredentials = [ + ...this._applicationCredentials, + applicationCredential, + ]; + } + }, + }); + } + + static get styles(): CSSResultGroup { + return css` + .table-header { + display: flex; + justify-content: space-between; + align-items: center; + height: 56px; + background-color: var(--mdc-text-field-fill-color, whitesmoke); + border-bottom: 1px solid + var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42)); + box-sizing: border-box; + } + .header-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--secondary-text-color); + position: relative; + top: -4px; + } + .selected-txt { + font-weight: bold; + padding-left: 16px; + } + .table-header .selected-txt { + margin-top: 20px; + } + .header-toolbar .selected-txt { + font-size: 16px; + } + .header-toolbar .header-btns { + margin-right: -12px; + } + .header-btns { + display: flex; + } + .header-btns > mwc-button, + .header-btns > ha-icon-button { + margin: 8px; + } + ha-button-menu { + margin-left: 8px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-application-credentials": HaConfigApplicationCredentials; + } +} diff --git a/src/panels/config/application_credentials/show-dialog-add-application-credential.ts b/src/panels/config/application_credentials/show-dialog-add-application-credential.ts new file mode 100644 index 0000000000..1b779f60b3 --- /dev/null +++ b/src/panels/config/application_credentials/show-dialog-add-application-credential.ts @@ -0,0 +1,22 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { ApplicationCredential } from "../../../data/application_credential"; + +export interface AddApplicationCredentialDialogParams { + applicationCredentialAddedCallback: ( + applicationCredential: ApplicationCredential + ) => void; +} + +export const loadAddApplicationCredentialDialog = () => + import("./dialog-add-application-credential"); + +export const showAddApplicationCredentialDialog = ( + element: HTMLElement, + dialogParams: AddApplicationCredentialDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-add-application-credential", + dialogImport: loadAddApplicationCredentialDialog, + dialogParams, + }); +}; diff --git a/src/panels/config/devices/ha-config-devices-dashboard.ts b/src/panels/config/devices/ha-config-devices-dashboard.ts index d7027a9512..460356a2bb 100644 --- a/src/panels/config/devices/ha-config-devices-dashboard.ts +++ b/src/panels/config/devices/ha-config-devices-dashboard.ts @@ -16,9 +16,9 @@ import { } from "../../../components/data-table/ha-data-table"; import "../../../components/entity/ha-battery-icon"; import "../../../components/ha-button-menu"; +import "../../../components/ha-check-list-item"; import "../../../components/ha-fab"; import "../../../components/ha-icon-button"; -import "../../../components/ha-check-list-item"; import { AreaRegistryEntry } from "../../../data/area_registry"; import { ConfigEntry } from "../../../data/config_entries"; import { @@ -36,6 +36,7 @@ import "../../../layouts/hass-tabs-subpage-data-table"; import { haStyle } from "../../../resources/styles"; import { HomeAssistant, Route } from "../../../types"; import { configSections } from "../ha-panel-config"; +import "../integrations/ha-integration-overflow-menu"; import { showZWaveJSAddNodeDialog } from "../integrations/integration-panels/zwave_js/show-dialog-zwave_js-add-node"; interface DeviceRowData extends DeviceRegistryEntry { @@ -408,6 +409,10 @@ export class HaConfigDeviceDashboard extends LitElement { (filteredConfigEntry.domain === "zha" || filteredConfigEntry.domain === "zwave_js")} > + ${!filteredConfigEntry ? "" : filteredConfigEntry.domain === "zwave_js" diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index 6fd3314830..8385f08758 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -61,6 +61,7 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant, Route } from "../../../types"; import { configSections } from "../ha-panel-config"; +import "../integrations/ha-integration-overflow-menu"; import { DialogEntityEditor } from "./dialog-entity-editor"; import { loadEntityEditorDialog, @@ -526,6 +527,10 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { id="entity_id" .hasFab=${includeZHAFab} > + ${this._selectedEntities.length ? html`
+ import("./application_credentials/ha-config-application-credentials"), + }, }, }; diff --git a/src/panels/config/helpers/ha-config-helpers.ts b/src/panels/config/helpers/ha-config-helpers.ts index fbaa0e1c2f..228576574d 100644 --- a/src/panels/config/helpers/ha-config-helpers.ts +++ b/src/panels/config/helpers/ha-config-helpers.ts @@ -35,6 +35,7 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { HomeAssistant, Route } from "../../../types"; import { showEntityEditorDialog } from "../entities/show-dialog-entity-editor"; import { configSections } from "../ha-panel-config"; +import "../integrations/ha-integration-overflow-menu"; import { HELPER_DOMAINS } from "./const"; import { showHelperDetailDialog } from "./show-dialog-helper-detail"; @@ -210,6 +211,10 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { "ui.panel.config.helpers.picker.no_helpers" )} > + - ${!this._showDisabled && this.narrow && disabledCount - ? html`${disabledCount}` - : ""} - - - - - ${this.hass.localize( - "ui.panel.config.integrations.ignore.show_ignored" - )} - - - ${this.hass.localize( - "ui.panel.config.integrations.disable.show_disabled" - )} - - -
`; + const filterMenu = html` +
+ + ${this.narrow + ? html` + + ` + : ""} +
+ `; return html`
{ + const url = ev.currentTarget.url; + await copyToClipboard(url); + showToast(this, { + message: this.hass.localize("ui.common.copied_clipboard"), + }); + } + static get styles(): CSSResultGroup { return css` .preparing { @@ -154,9 +175,6 @@ export class CloudRemotePref extends LitElement { font-weight: bold; margin-bottom: 1em; } - .warning ha-svg-icon { - color: var(--warning-color); - } .break-word { overflow-wrap: break-word; } @@ -178,6 +196,11 @@ export class CloudRemotePref extends LitElement { .spacer { flex-grow: 1; } + ha-svg-icon { + --mdc-icon-size: 18px; + color: var(--secondary-text-color); + cursor: pointer; + } `; } } From 5ec7193e5ce7565a47a10524fdac2980b0007f30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 May 2022 16:32:09 -0500 Subject: [PATCH 015/112] Show script traces in logbook (#12643) --- src/panels/logbook/ha-logbook.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/panels/logbook/ha-logbook.ts b/src/panels/logbook/ha-logbook.ts index 5c59dfa783..0e42b26240 100644 --- a/src/panels/logbook/ha-logbook.ts +++ b/src/panels/logbook/ha-logbook.ts @@ -217,13 +217,20 @@ class HaLogbook extends LitElement { .datetime=${item.when} capitalize > - ${item.domain === "automation" && + ${["script", "automation"].includes(item.domain!) && item.context_id! in this.traceContexts ? html` - Date: Wed, 11 May 2022 12:01:45 +0300 Subject: [PATCH 016/112] Rtl menu fix (#12561) Co-authored-by: Bram Kragten --- src/components/ha-button-menu.ts | 15 +++++++++++++++ src/state/translations-mixin.ts | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/ha-button-menu.ts b/src/components/ha-button-menu.ts index bf0d1d3487..d3a93cac68 100644 --- a/src/components/ha-button-menu.ts +++ b/src/components/ha-button-menu.ts @@ -50,6 +50,21 @@ export class HaButtonMenu extends LitElement { `; } + protected firstUpdated(changedProps): void { + super.firstUpdated(changedProps); + + if (document.dir === "rtl") { + this.updateComplete.then(() => { + this.querySelectorAll("mwc-list-item").forEach((item) => { + const style = document.createElement("style"); + style.innerHTML = + "span.material-icons:first-of-type { margin-left: var(--mdc-list-item-graphic-margin, 32px) !important; margin-right: 0px !important;}"; + item!.shadowRoot!.appendChild(style); + }); + }); + } + } + private _handleClick(): void { if (this.disabled) { return; diff --git a/src/state/translations-mixin.ts b/src/state/translations-mixin.ts index 5a118b67cc..76a37c6640 100644 --- a/src/state/translations-mixin.ts +++ b/src/state/translations-mixin.ts @@ -1,6 +1,6 @@ import { atLeastVersion } from "../common/config/version"; import { computeLocalize, LocalizeFunc } from "../common/translations/localize"; -import { computeRTL } from "../common/util/compute_rtl"; +import { computeRTLDirection } from "../common/util/compute_rtl"; import { debounce } from "../common/util/debounce"; import { getHassTranslations, @@ -180,7 +180,7 @@ export default >(superClass: T) => private _applyTranslations(hass: HomeAssistant) { document.querySelector("html")!.setAttribute("lang", hass.language); - this.style.direction = computeRTL(hass) ? "rtl" : "ltr"; + document.dir = computeRTLDirection(hass); this._loadCoreTranslations(hass.language); this.__loadedFragmetTranslations = new Set(); this._loadFragmentTranslations(hass.language, hass.panelUrl); From 96d375cb84c4e6254de4adc84598cc7bb17ef2e3 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 11 May 2022 14:16:44 +0200 Subject: [PATCH 017/112] Use / --- src/components/ha-fab.ts | 5 +++-- src/state/translations-mixin.ts | 18 +++++------------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/components/ha-fab.ts b/src/components/ha-fab.ts index 5a215f3907..57f2a2bbf4 100644 --- a/src/components/ha-fab.ts +++ b/src/components/ha-fab.ts @@ -12,8 +12,9 @@ export class HaFab extends Fab { static override styles = Fab.styles.concat([ css` .mdc-fab--extended .mdc-fab__icon { - margin-left: var(--rtl-12px, -8px) !important; - margin-right: var(--rtl--8px, 12px) !important; + margin-inline-start: -8px !important; + margin-inline-end: 12px !important; + direction: var(--direction) !important; } `, ]); diff --git a/src/state/translations-mixin.ts b/src/state/translations-mixin.ts index be798b6dc3..10eacb090e 100644 --- a/src/state/translations-mixin.ts +++ b/src/state/translations-mixin.ts @@ -1,6 +1,6 @@ import { atLeastVersion } from "../common/config/version"; import { computeLocalize, LocalizeFunc } from "../common/translations/localize"; -import { computeRTL } from "../common/util/compute_rtl"; +import { computeRTLDirection } from "../common/util/compute_rtl"; import { debounce } from "../common/util/debounce"; import { getHassTranslations, @@ -187,18 +187,10 @@ export default >(superClass: T) => } private _applyDirection(hass: HomeAssistant) { - if (computeRTL(hass)) { - this.style.direction = "rtl"; - // apply custom properties used to fix RTL appearance throughout the system - this.style.setProperty("--rtl-12px", "12px"); - this.style.setProperty("--rtl--8px", "-8px"); - this.style.setProperty("--dir", "rtl"); - } else { - // clear all custom properties (can't use "all" for this) - this.style.cssText = ""; - - this.style.direction = "ltr"; - } + const direction = computeRTLDirection(hass); + this.style.direction = direction; + document.dir = direction; + this.style.setProperty("--direction", direction); } /** From 2040a49458a47668c0ef5993b50d58fadf5bca16 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 11 May 2022 14:21:02 +0200 Subject: [PATCH 018/112] Update var name --- src/panels/config/logs/error-log-card.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panels/config/logs/error-log-card.ts b/src/panels/config/logs/error-log-card.ts index 313b153644..7bbbf3ff3c 100644 --- a/src/panels/config/logs/error-log-card.ts +++ b/src/panels/config/logs/error-log-card.ts @@ -236,7 +236,7 @@ class ErrorLogCard extends LitElement { } mwc-button { - direction: var(--rtl-dir, ltr); + direction: var(--direction); } `; } From a08a23a93dd5a53016201172a7f410c1c342ee26 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 11 May 2022 14:25:43 +0200 Subject: [PATCH 019/112] Use FabBase --- src/components/ha-fab.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/ha-fab.ts b/src/components/ha-fab.ts index 57f2a2bbf4..da769ad773 100644 --- a/src/components/ha-fab.ts +++ b/src/components/ha-fab.ts @@ -1,23 +1,25 @@ -import { Fab } from "@material/mwc-fab"; +import { FabBase } from "@material/mwc-fab/mwc-fab-base"; +import { styles } from "@material/mwc-fab/mwc-fab.css"; import { customElement } from "lit/decorators"; import { css } from "lit"; @customElement("ha-fab") -export class HaFab extends Fab { +export class HaFab extends FabBase { protected firstUpdated(changedProperties) { super.firstUpdated(changedProperties); this.style.setProperty("--mdc-theme-secondary", "var(--primary-color)"); } - static override styles = Fab.styles.concat([ + static override styles = [ + styles, css` .mdc-fab--extended .mdc-fab__icon { - margin-inline-start: -8px !important; - margin-inline-end: 12px !important; - direction: var(--direction) !important; + margin-inline-start: -8px; + margin-inline-end: 12px; + direction: var(--direction); } `, - ]); + ]; } declare global { From f1b965dcc55a704b8c5fa0169a786e1522184537 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 11 May 2022 15:19:03 +0200 Subject: [PATCH 020/112] Update ha-fab.ts --- src/components/ha-fab.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ha-fab.ts b/src/components/ha-fab.ts index da769ad773..b36871e381 100644 --- a/src/components/ha-fab.ts +++ b/src/components/ha-fab.ts @@ -13,7 +13,7 @@ export class HaFab extends FabBase { static override styles = [ styles, css` - .mdc-fab--extended .mdc-fab__icon { + :host .mdc-fab--extended .mdc-fab__icon { margin-inline-start: -8px; margin-inline-end: 12px; direction: var(--direction); From 4c982b3323461e88628bf2f03bd7a070c975eb89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 May 2022 22:28:18 -0500 Subject: [PATCH 021/112] Switch logbook calls to use the new websocket (#12665) --- gallery/src/data/traces/basic_trace.ts | 10 ++-- gallery/src/data/traces/motion-light-trace.ts | 6 +-- src/data/logbook.ts | 47 ++++++------------- src/dialogs/more-info/ha-more-info-logbook.ts | 3 +- src/panels/logbook/ha-logbook.ts | 10 ++-- src/panels/lovelace/cards/hui-logbook-card.ts | 5 +- 6 files changed, 31 insertions(+), 50 deletions(-) diff --git a/gallery/src/data/traces/basic_trace.ts b/gallery/src/data/traces/basic_trace.ts index 1df0446a1e..6345b4f845 100644 --- a/gallery/src/data/traces/basic_trace.ts +++ b/gallery/src/data/traces/basic_trace.ts @@ -298,11 +298,11 @@ export const basicTrace: DemoTrace = { source: "state of input_boolean.toggle_1", entity_id: "automation.toggle_toggles", context_id: "6cfcae368e7b3686fad6c59e83ae76c9", - when: "2021-03-25T04:36:51.240832+00:00", + when: 1616647011.240832, domain: "automation", }, { - when: "2021-03-25T04:36:51.249828+00:00", + when: 1616647011.249828, name: "Toggle 4", state: "on", entity_id: "input_boolean.toggle_4", @@ -313,7 +313,7 @@ export const basicTrace: DemoTrace = { context_name: "Ensure Party mode", }, { - when: "2021-03-25T04:36:51.258947+00:00", + when: 1616647011.258947, name: "Toggle 2", state: "on", entity_id: "input_boolean.toggle_2", @@ -324,7 +324,7 @@ export const basicTrace: DemoTrace = { context_name: "Ensure Party mode", }, { - when: "2021-03-25T04:36:51.261806+00:00", + when: 1616647011.261806, name: "Toggle 3", state: "off", entity_id: "input_boolean.toggle_3", @@ -335,7 +335,7 @@ export const basicTrace: DemoTrace = { context_name: "Ensure Party mode", }, { - when: "2021-03-25T04:36:51.265246+00:00", + when: 1616647011.265246, name: "Toggle 4", state: "off", entity_id: "input_boolean.toggle_4", diff --git a/gallery/src/data/traces/motion-light-trace.ts b/gallery/src/data/traces/motion-light-trace.ts index b735e28b03..fa566e5556 100644 --- a/gallery/src/data/traces/motion-light-trace.ts +++ b/gallery/src/data/traces/motion-light-trace.ts @@ -185,11 +185,11 @@ export const motionLightTrace: DemoTrace = { "has been triggered by state of binary_sensor.pauluss_macbook_pro_camera_in_use", source: "state of binary_sensor.pauluss_macbook_pro_camera_in_use", entity_id: "automation.auto_elgato", - when: "2021-03-14T06:07:01.768492+00:00", + when: 1615702021.768492, domain: "automation", }, { - when: "2021-03-14T06:07:01.872187+00:00", + when: 1615702021.872187, name: "Elgato Key Light Air", state: "on", entity_id: "light.elgato_key_light_air", @@ -200,7 +200,7 @@ export const motionLightTrace: DemoTrace = { context_name: "Auto Elgato", }, { - when: "2021-03-14T06:07:53.284505+00:00", + when: 1615702073.284505, name: "Elgato Key Light Air", state: "off", entity_id: "light.elgato_key_light_air", diff --git a/src/data/logbook.ts b/src/data/logbook.ts index 0ce969c3f4..6e7a289b90 100644 --- a/src/data/logbook.ts +++ b/src/data/logbook.ts @@ -10,7 +10,7 @@ const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages"; export const CONTINUOUS_DOMAINS = ["proximity", "sensor"]; export interface LogbookEntry { - when: string; + when: number; name: string; message?: string; entity_id?: string; @@ -46,7 +46,6 @@ export const getLogbookDataForContext = async ( startDate, undefined, undefined, - undefined, contextId ) ); @@ -56,20 +55,13 @@ export const getLogbookData = async ( hass: HomeAssistant, startDate: string, endDate: string, - entityId?: string, - entity_matches_only?: boolean + entityId?: string ): Promise => { const localize = await hass.loadBackendTranslation("device_class"); return addLogbookMessage( hass, localize, - await getLogbookDataCache( - hass, - startDate, - endDate, - entityId, - entity_matches_only - ) + await getLogbookDataCache(hass, startDate, endDate, entityId) ); }; @@ -97,8 +89,7 @@ export const getLogbookDataCache = async ( hass: HomeAssistant, startDate: string, endDate: string, - entityId?: string, - entity_matches_only?: boolean + entityId?: string ) => { const ALL_ENTITIES = "*"; @@ -125,39 +116,31 @@ export const getLogbookDataCache = async ( hass, startDate, endDate, - entityId !== ALL_ENTITIES ? entityId : undefined, - entity_matches_only + entityId !== ALL_ENTITIES ? entityId : undefined ).then((entries) => entries.reverse()); return DATA_CACHE[cacheKey][entityId]; }; -const getLogbookDataFromServer = async ( +export const getLogbookDataFromServer = ( hass: HomeAssistant, startDate: string, endDate?: string, entityId?: string, - entitymatchesOnly?: boolean, contextId?: string ) => { - const params = new URLSearchParams(); - + let params: any = { + type: "logbook/get_events", + start_time: startDate, + }; if (endDate) { - params.append("end_time", endDate); + params = { ...params, end_time: endDate }; } if (entityId) { - params.append("entity", entityId); + params = { ...params, entity_ids: entityId.split(",") }; + } else if (contextId) { + params = { ...params, context_id: contextId }; } - if (entitymatchesOnly) { - params.append("entity_matches_only", ""); - } - if (contextId) { - params.append("context_id", contextId); - } - - return hass.callApi( - "GET", - `logbook/${startDate}?${params.toString()}` - ); + return hass.callWS(params); }; export const clearLogbookCache = (startDate: string, endDate: string) => { diff --git a/src/dialogs/more-info/ha-more-info-logbook.ts b/src/dialogs/more-info/ha-more-info-logbook.ts index 05bc1bfe0f..e577ff7cb6 100644 --- a/src/dialogs/more-info/ha-more-info-logbook.ts +++ b/src/dialogs/more-info/ha-more-info-logbook.ts @@ -147,8 +147,7 @@ export class MoreInfoLogbook extends LitElement { this.hass, lastDate.toISOString(), now.toISOString(), - this.entityId, - true + this.entityId ), this.hass.user?.is_admin ? loadTraceContexts(this.hass) : {}, this._fetchUserPromise, diff --git a/src/panels/logbook/ha-logbook.ts b/src/panels/logbook/ha-logbook.ts index 0e42b26240..adb946efa8 100644 --- a/src/panels/logbook/ha-logbook.ts +++ b/src/panels/logbook/ha-logbook.ts @@ -135,11 +135,11 @@ class HaLogbook extends LitElement { ${index === 0 || (item?.when && previous?.when && - new Date(item.when).toDateString() !== - new Date(previous.when).toDateString()) + new Date(item.when * 1000).toDateString() !== + new Date(previous.when * 1000).toDateString()) ? html`

- ${formatDate(new Date(item.when), this.hass.locale)} + ${formatDate(new Date(item.when * 1000), this.hass.locale)}

` : html``} @@ -207,14 +207,14 @@ class HaLogbook extends LitElement {
${formatTimeWithSeconds( - new Date(item.when), + new Date(item.when * 1000), this.hass.locale )} - ${["script", "automation"].includes(item.domain!) && diff --git a/src/panels/lovelace/cards/hui-logbook-card.ts b/src/panels/lovelace/cards/hui-logbook-card.ts index ae459a11d3..3adf6f40e8 100644 --- a/src/panels/lovelace/cards/hui-logbook-card.ts +++ b/src/panels/lovelace/cards/hui-logbook-card.ts @@ -249,8 +249,7 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { this.hass, lastDate.toISOString(), now.toISOString(), - this._configEntities!.map((entity) => entity.entity).toString(), - true + this._configEntities!.map((entity) => entity.entity).toString() ), this._fetchUserPromise, ]); @@ -264,7 +263,7 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { : newEntries; this._logbookEntries = logbookEntries.filter( - (logEntry) => new Date(logEntry.when) > hoursToShowDate + (logEntry) => new Date(logEntry.when * 1000) > hoursToShowDate ); this._lastLogbookDate = now; From 72a36fb1cdba310ff713a15c42fd9c94cb68a763 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 12 May 2022 05:42:15 -0700 Subject: [PATCH 022/112] Add calendar trigger offsets in automation editor (#12486) * Add calendar trigger offsets in automation editor * Use duration selector for offset * Fix typing for offsets/duratons --- src/data/automation.ts | 1 + .../types/ha-automation-trigger-calendar.ts | 50 ++++++++++++++++++- src/translations/en.json | 5 +- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/data/automation.ts b/src/data/automation.ts index a4fbcf4f47..3259df2f67 100644 --- a/src/data/automation.ts +++ b/src/data/automation.ts @@ -157,6 +157,7 @@ export interface CalendarTrigger extends BaseTrigger { platform: "calendar"; event: "start" | "end"; entity_id: string; + offset: string; } export type Trigger = diff --git a/src/panels/config/automation/trigger/types/ha-automation-trigger-calendar.ts b/src/panels/config/automation/trigger/types/ha-automation-trigger-calendar.ts index 8a458434aa..51717108c2 100644 --- a/src/panels/config/automation/trigger/types/ha-automation-trigger-calendar.ts +++ b/src/panels/config/automation/trigger/types/ha-automation-trigger-calendar.ts @@ -5,7 +5,9 @@ import { fireEvent } from "../../../../../common/dom/fire_event"; import type { CalendarTrigger } from "../../../../../data/automation"; import type { HomeAssistant } from "../../../../../types"; import type { TriggerElement } from "../ha-automation-trigger-row"; +import type { HaDurationData } from "../../../../../components/ha-duration-input"; import type { HaFormSchema } from "../../../../../components/ha-form/types"; +import { createDurationData } from "../../../../../common/datetime/create_duration_data"; import type { LocalizeFunc } from "../../../../../common/translations/localize"; @customElement("ha-automation-trigger-calendar") @@ -39,20 +41,57 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement { ], ], }, + { name: "offset", selector: { duration: {} } }, + { + name: "offset_type", + type: "select", + required: true, + options: [ + [ + "before", + localize( + "ui.panel.config.automation.editor.triggers.type.calendar.before" + ), + ], + [ + "after", + localize( + "ui.panel.config.automation.editor.triggers.type.calendar.after" + ), + ], + ], + }, ]); public static get defaultConfig() { return { event: "start" as CalendarTrigger["event"], + offset: 0, }; } protected render() { const schema = this._schema(this.hass.localize); + // Convert from string representation to ha form duration representation + const trigger_offset = this.trigger.offset; + const duration: HaDurationData = createDurationData(trigger_offset)!; + let offset_type = "after"; + if ( + (typeof trigger_offset === "object" && duration!.hours! < 0) || + (typeof trigger_offset === "string" && trigger_offset.startsWith("-")) + ) { + duration.hours = Math.abs(duration.hours!); + offset_type = "before"; + } + const data = { + ...this.trigger, + offset: duration, + offset_type: offset_type, + }; return html` Date: Fri, 13 May 2022 13:17:56 +0200 Subject: [PATCH 023/112] Fix strict error handling in Markdown card templates (#12661) --- src/panels/lovelace/cards/hui-markdown-card.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/panels/lovelace/cards/hui-markdown-card.ts b/src/panels/lovelace/cards/hui-markdown-card.ts index 0f91a15b34..d03a9cd45d 100644 --- a/src/panels/lovelace/cards/hui-markdown-card.ts +++ b/src/panels/lovelace/cards/hui-markdown-card.ts @@ -135,6 +135,7 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard { config: this._config, user: this.hass.user!.name, }, + strict: true, } ); } catch (_err) { From f36c91550d0cd496f92c6159e4b739c074d83e35 Mon Sep 17 00:00:00 2001 From: Philip Allgaier Date: Sat, 14 May 2022 00:58:01 +0200 Subject: [PATCH 024/112] Add missing label to search icon (#12671) --- src/panels/lovelace/hui-root.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index 83e2ebdfd6..bffb7ea861 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -269,6 +269,9 @@ class HUIRoot extends LitElement { ${!this.narrow ? html` From 09ee8dbeb6af0a3ae243708710331d5a2d1dccb1 Mon Sep 17 00:00:00 2001 From: Sven <85389871+wrt54g@users.noreply.github.com> Date: Sun, 15 May 2022 17:59:31 +0200 Subject: [PATCH 025/112] Update Lokalise URL (#12684) --- translations/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/translations/README.md b/translations/README.md index 8477b66732..689b55fe9d 100644 --- a/translations/README.md +++ b/translations/README.md @@ -1,4 +1,4 @@ # Translation Resources -Translations are managed through [Lokalise](https://lokalise.co/). If you'd like to contribute, you can [join the project here](https://lokalise.co/signup/3420425759f6d6d241f598.13594006/all/). For more details, see our [translation guidelines documentation](https://developers.home-assistant.io/docs/en/internationalization_translation.html). +Translations are managed through [Lokalise](https://developers.home-assistant.io/docs/translations/). If you'd like to contribute, you can [join the project here](https://lokalise.co/signup/3420425759f6d6d241f598.13594006/all/). For more details, see our [translation guidelines documentation](https://developers.home-assistant.io/docs/translations/). Don't make changes to these files directly. Instead, use `script/translations_download` to fetch the latest translations from Lokalise. From 01a53439c4ff0db23c2d93fe063f7db3be9ea3c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 May 2022 23:25:55 -0500 Subject: [PATCH 026/112] Teach logbook about additional context data (#12667) Co-authored-by: Paulus Schoutsen --- src/data/logbook.ts | 1 + src/panels/logbook/ha-logbook.ts | 199 ++++++++++++++++++++++++++----- src/translations/en.json | 8 +- 3 files changed, 174 insertions(+), 34 deletions(-) diff --git a/src/data/logbook.ts b/src/data/logbook.ts index 6e7a289b90..9e5f2ac4a6 100644 --- a/src/data/logbook.ts +++ b/src/data/logbook.ts @@ -25,6 +25,7 @@ export interface LogbookEntry { context_entity_id?: string; context_entity_id_name?: string; context_name?: string; + context_message?: string; state?: string; } diff --git a/src/panels/logbook/ha-logbook.ts b/src/panels/logbook/ha-logbook.ts index adb946efa8..680eb38603 100644 --- a/src/panels/logbook/ha-logbook.ts +++ b/src/panels/logbook/ha-logbook.ts @@ -22,9 +22,17 @@ import "../../components/ha-circular-progress"; import "../../components/ha-relative-time"; import { LogbookEntry } from "../../data/logbook"; import { TraceContexts } from "../../data/trace"; -import { haStyle, haStyleScrollbar } from "../../resources/styles"; +import { + haStyle, + haStyleScrollbar, + buttonLinkStyle, +} from "../../resources/styles"; import { HomeAssistant } from "../../types"; +const EVENT_LOCALIZE_MAP = { + script_started: "from_script", +}; + @customElement("ha-logbook") class HaLogbook extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -119,6 +127,7 @@ class HaLogbook extends LitElement { return html``; } + const seenEntityIds: string[] = []; const previous = this.entries[index - 1]; const stateObj = item.entity_id ? this.hass.states[item.entity_id] @@ -167,42 +176,48 @@ class HaLogbook extends LitElement { : ""}
- ${!this.noName - ? html`${item.name}` + ${!this.noName // Used for more-info panel (single entity case) + ? this._renderEntity(item.entity_id, item.name) + : ""} + ${item.message + ? html`${this._formatMessageWithPossibleEntity( + item.message, + seenEntityIds, + item.entity_id + )}` + : item.source + ? html` ${this._formatMessageWithPossibleEntity( + item.source, + seenEntityIds, + undefined, + "ui.components.logbook.by" + )}` : ""} - ${item.message} ${item_username ? ` ${this.hass.localize( - "ui.components.logbook.by" + "ui.components.logbook.by_user" )} ${item_username}` - : !item.context_event_type - ? "" - : item.context_event_type === "call_service" - ? // Service Call - ` ${this.hass.localize("ui.components.logbook.by_service")} - ${item.context_domain}.${item.context_service}` - : item.context_entity_id === item.entity_id - ? // HomeKit or something that self references - ` ${this.hass.localize("ui.components.logbook.by")} - ${ - item.context_name - ? item.context_name - : item.context_event_type - }` - : // Another entity such as an automation or script - html` ${this.hass.localize("ui.components.logbook.by")} - ${item.context_entity_id_name}`} + : ``} + ${item.context_event_type + ? this._formatEventBy(item, seenEntityIds) + : ""} + ${item.context_message + ? html` ${this._formatMessageWithPossibleEntity( + item.context_message, + seenEntityIds, + item.context_entity_id, + "ui.components.logbook.for" + )}` + : ""} + ${item.context_entity_id && + !seenEntityIds.includes(item.context_entity_id) + ? // Another entity such as an automation or script + html` ${this.hass.localize("ui.components.logbook.for")} + ${this._renderEntity( + item.context_entity_id, + item.context_entity_id_name + )}` + : ""}
+ ${displayName} + `; + } + + private _formatMessageWithPossibleEntity( + message: string, + seenEntities: string[], + possibleEntity?: string, + localizePrefix?: string + ) { + // + // As we are looking at a log(book), we are doing entity_id + // "highlighting"/"colorizing". The goal is to make it easy for + // the user to access the entity that caused the event. + // + // If there is an entity_id in the message that is also in the + // state machine, we search the message for the entity_id and + // replace it with _renderEntity + // + if (message.indexOf(".") !== -1) { + const messageParts = message.split(" "); + for (let i = 0, size = messageParts.length; i < size; i++) { + if (messageParts[i] in this.hass.states) { + const entityId = messageParts[i]; + if (seenEntities.includes(entityId)) { + return ""; + } + seenEntities.push(entityId); + const messageEnd = messageParts.splice(i); + messageEnd.shift(); // remove the entity + return html` ${messageParts.join(" ")} + ${this._renderEntity( + entityId, + this.hass.states[entityId].attributes.friendly_name + )} + ${messageEnd.join(" ")}`; + } + } + } + // + // When we have a message has a specific entity_id attached to + // it, and the entity_id is not in the message, we look + // for the friendly name of the entity and replace that with + // _renderEntity if its there so the user can quickly get to + // that entity. + // + if (possibleEntity && possibleEntity in this.hass.states) { + const possibleEntityName = + this.hass.states[possibleEntity].attributes.friendly_name; + if (possibleEntityName && message.endsWith(possibleEntityName)) { + if (seenEntities.includes(possibleEntity)) { + return ""; + } + seenEntities.push(possibleEntity); + message = message.substring( + 0, + message.length - possibleEntityName.length + ); + return html` ${localizePrefix ? this.hass.localize(localizePrefix) : ""} + ${message} ${this._renderEntity(possibleEntity, possibleEntityName)}`; + } + } + return message; + } + private _entityClicked(ev: Event) { const entityId = (ev.currentTarget as any).entityId; if (!entityId) { @@ -275,6 +407,7 @@ class HaLogbook extends LitElement { return [ haStyle, haStyleScrollbar, + buttonLinkStyle, css` :host([virtualize]) { display: block; diff --git a/src/translations/en.json b/src/translations/en.json index 550d03c627..afcc88b7f9 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -344,8 +344,14 @@ }, "logbook": { "entries_not_found": "No logbook events found.", + "by_user": "by user", "by": "by", - "by_service": "by service", + "from": "from", + "for": "for", + "event": "event", + "from_service": "from service", + "from_automation": "from automation", + "from_script": "from script", "show_trace": "[%key:ui::panel::config::automation::editor::show_trace%]", "retrieval_error": "Could not load logbook", "messages": { From 3d66a6879135fdf0ebc6b5e6cbd5597a0d10ef3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 16 May 2022 13:39:41 +0200 Subject: [PATCH 027/112] Guard for missing backup integration (#12696) --- src/panels/config/core/ha-config-system-navigation.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/panels/config/core/ha-config-system-navigation.ts b/src/panels/config/core/ha-config-system-navigation.ts index a3335d5903..22860dbabf 100644 --- a/src/panels/config/core/ha-config-system-navigation.ts +++ b/src/panels/config/core/ha-config-system-navigation.ts @@ -189,9 +189,11 @@ class HaConfigSystemNavigation extends LitElement { private async _fetchBackupInfo(isHassioLoaded: boolean) { const backups: BackupContent[] | HassioBackup[] = isHassioLoaded ? await fetchHassioBackups(this.hass) - : await fetchBackupInfo(this.hass).then( + : isComponentLoaded(this.hass, "backup") + ? await fetchBackupInfo(this.hass).then( (backupData) => backupData.backups - ); + ) + : []; if (backups.length > 0) { this._latestBackupDate = (backups as any[]).reduce((a, b) => From 1bd760b455d502a66f8cb624eb88bff435336df8 Mon Sep 17 00:00:00 2001 From: Yosi Levy <37745463+yosilevy@users.noreply.github.com> Date: Mon, 16 May 2022 16:57:14 +0300 Subject: [PATCH 028/112] Rtl changes (#12693) --- src/components/ha-clickable-list-item.ts | 19 ++++++++------- src/components/ha-combo-box.ts | 18 +++++---------- src/components/ha-dialog.ts | 22 ++++++++---------- src/components/ha-file-upload.ts | 23 +++++++++---------- src/components/ha-form/ha-form-string.ts | 7 +++--- src/components/ha-select.ts | 7 +++--- src/components/ha-target-picker.ts | 7 +++--- src/components/ha-textfield.ts | 15 ++++++------ .../media-player/dialog-media-manage.ts | 7 +++--- .../media-player/ha-media-manage-button.ts | 7 +++--- .../media-player/ha-media-upload-button.ts | 7 +++--- .../config-flow/dialog-data-entry-flow.ts | 7 +++--- src/dialogs/config-flow/step-flow-form.ts | 7 ++---- .../config-flow/step-flow-pick-flow.ts | 7 ++---- .../config-flow/step-flow-pick-handler.ts | 7 ++---- src/dialogs/config-flow/styles.ts | 4 ++++ .../action/ha-automation-action-row.ts | 6 +---- .../condition/ha-automation-condition-row.ts | 5 +--- .../trigger/ha-automation-trigger-row.ts | 5 +--- .../integrations/ha-integration-header.ts | 14 ++++++----- src/panels/config/logs/error-log-card.ts | 7 +----- src/panels/config/logs/system-log-card.ts | 10 ++++---- .../media-browser/ha-bar-media-player.ts | 15 ++++++------ src/state/translations-mixin.ts | 1 + 24 files changed, 109 insertions(+), 125 deletions(-) diff --git a/src/components/ha-clickable-list-item.ts b/src/components/ha-clickable-list-item.ts index 032a2be2ed..71f207a129 100644 --- a/src/components/ha-clickable-list-item.ts +++ b/src/components/ha-clickable-list-item.ts @@ -60,15 +60,18 @@ export class HaClickableListItem extends ListItemBase { padding-right: var(--mdc-list-side-padding, 20px); overflow: hidden; } - :host-context([style*="direction: rtl;"]) - span.material-icons:first-of-type { - margin-left: var(--mdc-list-item-graphic-margin, 16px) !important; - margin-right: 0px !important; + span.material-icons:first-of-type { + margin-inline-start: 0px !important; + margin-inline-end: var( + --mdc-list-item-graphic-margin, + 16px + ) !important; + direction: var(--direction); } - :host-context([style*="direction: rtl;"]) - span.material-icons:last-of-type { - margin-left: 0px !important; - margin-right: auto !important; + span.material-icons:last-of-type { + margin-inline-start: auto !important; + margin-inline-end: 0px !important; + direction: var(--direction); } `, ]; diff --git a/src/components/ha-combo-box.ts b/src/components/ha-combo-box.ts index fb28ab211c..0e29a8d8fc 100644 --- a/src/components/ha-combo-box.ts +++ b/src/components/ha-combo-box.ts @@ -241,6 +241,9 @@ export class HaComboBox extends LitElement { .toggle-button { right: 12px; top: -10px; + inset-inline-start: initial; + inset-inline-end: 12px; + direction: var(--direction); } :host([opened]) .toggle-button { color: var(--primary-color); @@ -249,18 +252,9 @@ export class HaComboBox extends LitElement { --mdc-icon-size: 20px; top: -7px; right: 36px; - } - - :host-context([style*="direction: rtl;"]) .toggle-button { - left: 12px; - right: auto; - top: -10px; - } - :host-context([style*="direction: rtl;"]) .clear-button { - --mdc-icon-size: 20px; - top: -7px; - left: 36px; - right: auto; + inset-inline-start: initial; + inset-inline-end: 36px; + direction: var(--direction); } `; } diff --git a/src/components/ha-dialog.ts b/src/components/ha-dialog.ts index 3f33a4e349..2744d1eeed 100644 --- a/src/components/ha-dialog.ts +++ b/src/components/ha-dialog.ts @@ -3,7 +3,6 @@ import { styles } from "@material/mwc-dialog/mwc-dialog.css"; import { mdiClose } from "@mdi/js"; import { css, html, TemplateResult } from "lit"; import { customElement } from "lit/decorators"; -import { computeRTLDirection } from "../common/util/compute_rtl"; import type { HomeAssistant } from "../types"; import "./ha-icon-button"; @@ -17,7 +16,6 @@ export const createCloseHeading = ( .path=${mdiClose} dialogAction="close" class="header_button" - dir=${computeRTLDirection(hass)} > `; @@ -89,18 +87,18 @@ export class HaDialog extends DialogBase { } .header_title { margin-right: 40px; + margin-inline-end: 40px; + direction: var(--direction); } - [dir="rtl"].header_button { - right: auto; - left: 16px; + .header_button { + inset-inline-start: initial; + inset-inline-end: 16px; + direction: var(--direction); } - [dir="rtl"].header_title { - margin-left: 40px; - margin-right: 0px; - } - :host-context([style*="direction: rtl;"]) .dialog-actions { - left: 0px !important; - right: auto !important; + .dialog-actions { + inset-inline-start: initial !important; + inset-inline-end: 0px !important; + direction: var(--direction); } `, ]; diff --git a/src/components/ha-file-upload.ts b/src/components/ha-file-upload.ts index 6e90308ee4..fa85894bfc 100644 --- a/src/components/ha-file-upload.ts +++ b/src/components/ha-file-upload.ts @@ -175,24 +175,23 @@ export class HaFileUpload extends LitElement { } .mdc-text-field__icon--leading { margin-bottom: 12px; - } - :host-context([style*="direction: rtl;"]) - .mdc-text-field__icon--leading { - margin-right: 0px; + inset-inline-start: initial; + inset-inline-end: 0px; + direction: var(--direction); } .mdc-text-field--filled .mdc-floating-label--float-above { transform: scale(0.75); top: 8px; } - :host-context([style*="direction: rtl;"]) .mdc-floating-label { - left: initial; - right: 16px; + .mdc-floating-label { + inset-inline-start: 16px !important; + inset-inline-end: initial !important; + direction: var(--direction); } - :host-context([style*="direction: rtl;"]) - .mdc-text-field--filled - .mdc-floating-label { - left: initial; - right: 48px; + .mdc-text-field--filled .mdc-floating-label { + inset-inline-start: 48px !important; + inset-inline-end: initial !important; + direction: var(--direction); } .dragged:before { position: var(--layout-fit_-_position); diff --git a/src/components/ha-form/ha-form-string.ts b/src/components/ha-form/ha-form-string.ts index 04cdd3d182..93d8c247c4 100644 --- a/src/components/ha-form/ha-form-string.ts +++ b/src/components/ha-form/ha-form-string.ts @@ -133,9 +133,10 @@ export class HaFormString extends LitElement implements HaFormElement { color: var(--secondary-text-color); } - :host-context([style*="direction: rtl;"]) ha-icon-button { - right: auto; - left: 12px; + ha-icon-button { + inset-inline-start: initial; + inset-inline-end: 12px; + direction: var(--direction); } `; } diff --git a/src/components/ha-select.ts b/src/components/ha-select.ts index 148ffcd21d..f432bcb280 100644 --- a/src/components/ha-select.ts +++ b/src/components/ha-select.ts @@ -47,9 +47,10 @@ export class HaSelect extends SelectBase { .mdc-select__anchor { width: var(--ha-select-min-width, 200px); } - :host-context([style*="direction: rtl;"]) .mdc-floating-label { - right: 16px !important; - left: initial !important; + .mdc-floating-label { + inset-inline-start: 16px !important; + inset-inline-end: initial !important; + direction: var(--direction); } `, ]; diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 825b828cd2..adeba08722 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -616,9 +616,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { opacity: var(--light-disabled-opacity); pointer-events: none; } - :host-context([style*="direction: rtl;"]) .mdc-chip__icon { - margin-right: -14px !important; - margin-left: 4px !important; + .mdc-chip__icon { + margin-inline-start: -14px !important; + margin-inline-end: 4px !important; + direction: var(--direction); } `; } diff --git a/src/components/ha-textfield.ts b/src/components/ha-textfield.ts index 0e2c5a8cf8..98d85e56b4 100644 --- a/src/components/ha-textfield.ts +++ b/src/components/ha-textfield.ts @@ -92,17 +92,18 @@ export class HaTextField extends TextFieldBase { overflow: var(--text-field-overflow); } - :host-context([style*="direction: rtl;"]) .mdc-floating-label { - right: 10px !important; - left: initial !important; + .mdc-floating-label { + inset-inline-start: 16px !important; + inset-inline-end: initial !important; + direction: var(--direction); } - :host-context([style*="direction: rtl;"]) - .mdc-text-field--with-leading-icon.mdc-text-field--filled + .mdc-text-field--with-leading-icon.mdc-text-field--filled .mdc-floating-label { max-width: calc(100% - 48px); - right: 48px !important; - left: initial !important; + inset-inline-start: 48px !important; + inset-inline-end: initial !important; + direction: var(--direction); } `, ]; diff --git a/src/components/media-player/dialog-media-manage.ts b/src/components/media-player/dialog-media-manage.ts index ef2af8e57e..2761436bd4 100644 --- a/src/components/media-player/dialog-media-manage.ts +++ b/src/components/media-player/dialog-media-manage.ts @@ -314,9 +314,10 @@ class DialogMediaManage extends LitElement { vertical-align: middle; } - :host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] { - margin-left: 8px !important; - margin-right: 0px !important; + ha-svg-icon[slot="icon"] { + margin-inline-start: 0px !important; + margin-inline-end: 8px !important; + direction: var(--direction); } .refresh { diff --git a/src/components/media-player/ha-media-manage-button.ts b/src/components/media-player/ha-media-manage-button.ts index aeb9d7b2bc..ab12d1aa07 100644 --- a/src/components/media-player/ha-media-manage-button.ts +++ b/src/components/media-player/ha-media-manage-button.ts @@ -60,9 +60,10 @@ class MediaManageButton extends LitElement { vertical-align: middle; } - :host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] { - margin-left: 8px; - margin-right: 0px; + ha-svg-icon[slot="icon"] { + margin-inline-start: 0px; + margin-inline-end: 8px; + direction: var(--direction); } `; } diff --git a/src/components/media-player/ha-media-upload-button.ts b/src/components/media-player/ha-media-upload-button.ts index 413952b9a3..a28f3cfa5b 100644 --- a/src/components/media-player/ha-media-upload-button.ts +++ b/src/components/media-player/ha-media-upload-button.ts @@ -120,9 +120,10 @@ class MediaUploadButton extends LitElement { vertical-align: middle; } - :host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] { - margin-left: 8px; - margin-right: 0px; + ha-svg-icon[slot="icon"] { + margin-inline-start: 0px; + margin-inline-end: 8px; + direction: var(--direction); } `; } diff --git a/src/dialogs/config-flow/dialog-data-entry-flow.ts b/src/dialogs/config-flow/dialog-data-entry-flow.ts index d0a7d28d13..415be7f833 100644 --- a/src/dialogs/config-flow/dialog-data-entry-flow.ts +++ b/src/dialogs/config-flow/dialog-data-entry-flow.ts @@ -518,10 +518,9 @@ class DataEntryFlowDialog extends LitElement { position: absolute; top: 0; right: 0; - } - :host-context([style*="direction: rtl;"]) .dialog-actions { - right: auto; - left: 0; + inset-inline-start: initial; + inset-inline-end: 0px; + direction: var(--direction); } .dialog-actions > * { color: var(--secondary-text-color); diff --git a/src/dialogs/config-flow/step-flow-form.ts b/src/dialogs/config-flow/step-flow-form.ts index 1b0c8b4c1b..6b1926e31f 100644 --- a/src/dialogs/config-flow/step-flow-form.ts +++ b/src/dialogs/config-flow/step-flow-form.ts @@ -192,11 +192,8 @@ class StepFlowForm extends LitElement { } h2 { word-break: break-word; - padding-right: 72px; - } - :host-context([style*="direction: rtl;"]) h2 { - padding-right: auto !important; - padding-left: 72px !important; + padding-inline-end: 72px; + direction: var(--direction); } `, ]; diff --git a/src/dialogs/config-flow/step-flow-pick-flow.ts b/src/dialogs/config-flow/step-flow-pick-flow.ts index 1b7d401e8e..cb942fe3da 100644 --- a/src/dialogs/config-flow/step-flow-pick-flow.ts +++ b/src/dialogs/config-flow/step-flow-pick-flow.ts @@ -104,11 +104,8 @@ class StepFlowPickFlow extends LitElement { margin: 16px 0; } h2 { - padding-right: 66px; - } - :host-context([style*="direction: rtl;"]) h2 { - padding-right: auto !important; - padding-left: 66px !important; + padding-inline-end: 66px; + direction: var(--direction); } @media all and (max-height: 900px) { div { diff --git a/src/dialogs/config-flow/step-flow-pick-handler.ts b/src/dialogs/config-flow/step-flow-pick-handler.ts index c71ccb338f..797ae3c4af 100644 --- a/src/dialogs/config-flow/step-flow-pick-handler.ts +++ b/src/dialogs/config-flow/step-flow-pick-handler.ts @@ -311,11 +311,8 @@ class StepFlowPickHandler extends LitElement { border-bottom-color: var(--divider-color); } h2 { - padding-right: 66px; - } - :host-context([style*="direction: rtl;"]) h2 { - padding-right: auto !important; - padding-left: 66px !important; + padding-inline-end: 66px; + direction: var(--direction); } @media all and (max-height: 900px) { mwc-list { diff --git a/src/dialogs/config-flow/styles.ts b/src/dialogs/config-flow/styles.ts index e8257b92c8..3d93386476 100644 --- a/src/dialogs/config-flow/styles.ts +++ b/src/dialogs/config-flow/styles.ts @@ -3,7 +3,11 @@ import { css } from "lit"; export const configFlowContentStyles = css` h2 { margin: 24px 38px 0 0; + margin-inline-start: 0px; + margin-inline-end: 38px; padding: 0 24px; + padding-inline-start: 24px; + padding-inline-end: 24px; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; font-family: var( diff --git a/src/panels/config/automation/action/ha-automation-action-row.ts b/src/panels/config/automation/action/ha-automation-action-row.ts index ce98a213e7..7d7d9bbd86 100644 --- a/src/panels/config/automation/action/ha-automation-action-row.ts +++ b/src/panels/config/automation/action/ha-automation-action-row.ts @@ -460,17 +460,13 @@ export default class HaAutomationActionRow extends LitElement { border-top-left-radius: var(--ha-card-border-radius); } .card-menu { - float: right; + float: var(--float-end, right); z-index: 3; margin: 4px; --mdc-theme-text-primary-on-background: var(--primary-text-color); display: flex; align-items: center; } - :host-context([style*="direction: rtl;"]) .card-menu { - right: initial; - left: 16px; - } mwc-list-item[disabled] { --mdc-theme-text-primary-on-background: var(--disabled-text-color); } diff --git a/src/panels/config/automation/condition/ha-automation-condition-row.ts b/src/panels/config/automation/condition/ha-automation-condition-row.ts index 02509f76d2..f5450196e0 100644 --- a/src/panels/config/automation/condition/ha-automation-condition-row.ts +++ b/src/panels/config/automation/condition/ha-automation-condition-row.ts @@ -288,16 +288,13 @@ export default class HaAutomationConditionRow extends LitElement { border-top-left-radius: var(--ha-card-border-radius); } .card-menu { - float: right; + float: var(--float-end, right); z-index: 3; margin: 4px; --mdc-theme-text-primary-on-background: var(--primary-text-color); display: flex; align-items: center; } - :host-context([style*="direction: rtl;"]) .card-menu { - float: left; - } mwc-list-item[disabled] { --mdc-theme-text-primary-on-background: var(--disabled-text-color); } diff --git a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts index 459877933c..b7a2a6de2e 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts @@ -487,16 +487,13 @@ export default class HaAutomationTriggerRow extends LitElement { border-top-left-radius: var(--ha-card-border-radius); } .card-menu { - float: right; + float: var(--float-end, right); z-index: 3; margin: 4px; --mdc-theme-text-primary-on-background: var(--primary-text-color); display: flex; align-items: center; } - :host-context([style*="direction: rtl;"]) .card-menu { - float: left; - } .triggered { cursor: pointer; position: absolute; diff --git a/src/panels/config/integrations/ha-integration-header.ts b/src/panels/config/integrations/ha-integration-header.ts index 05506898cc..84988ab146 100644 --- a/src/panels/config/integrations/ha-integration-header.ts +++ b/src/panels/config/integrations/ha-integration-header.ts @@ -135,17 +135,19 @@ export class HaIntegrationHeader extends LitElement { .header { display: flex; position: relative; - padding: 0 8px 8px 16px; + padding-top: 0px; + padding-bottom: 8px; + padding-inline-start: 16px; + padding-inline-end: 8px; + direction: var(--direction); } .header img { - margin-right: 16px; margin-top: 16px; + margin-inline-start: initial; + margin-inline-end: 16px; width: 40px; height: 40px; - } - :host-context([style*="direction: rtl;"]) .header img { - margin-right: auto !important; - margin-left: 16px; + direction: var(--direction); } .header .info { flex: 1; diff --git a/src/panels/config/logs/error-log-card.ts b/src/panels/config/logs/error-log-card.ts index 7bbbf3ff3c..d540d23474 100644 --- a/src/panels/config/logs/error-log-card.ts +++ b/src/panels/config/logs/error-log-card.ts @@ -16,7 +16,6 @@ import "../../../components/ha-ansi-to-html"; import "../../../components/ha-card"; import "../../../components/ha-icon-button"; import "../../../components/ha-select"; -import { computeRTLDirection } from "../../../common/util/compute_rtl"; import { fetchErrorLog } from "../../../data/error_log"; import { extractApiErrorMessage } from "../../../data/hassio/common"; import { fetchHassioLogs } from "../../../data/hassio/supervisor"; @@ -64,11 +63,7 @@ class ErrorLogCard extends LitElement { : ""} ${!this._logHTML ? html` - + ${this.hass.localize("ui.panel.config.logs.load_logs")} ` diff --git a/src/panels/config/logs/system-log-card.ts b/src/panels/config/logs/system-log-card.ts index f774a657c3..8286791f25 100644 --- a/src/panels/config/logs/system-log-card.ts +++ b/src/panels/config/logs/system-log-card.ts @@ -18,7 +18,6 @@ import { import { HomeAssistant } from "../../../types"; import { showSystemLogDetailDialog } from "./show-dialog-system-log-detail"; import { formatSystemLogTime } from "./util"; -import { computeRTLDirection } from "../../../common/util/compute_rtl"; @customElement("system-log-card") export class SystemLogCard extends LitElement { @@ -86,7 +85,7 @@ export class SystemLogCard extends LitElement { : html` ${this._items.length === 0 ? html` -
+
${this.hass.localize("ui.panel.config.logs.no_issues")}
` @@ -132,7 +131,7 @@ export class SystemLogCard extends LitElement { ` )} -
+
>(superClass: T) => this.style.direction = direction; document.dir = direction; this.style.setProperty("--direction", direction); + this.style.setProperty("--float-end", "left"); } /** From ae2d48f2f4fc3d4d24397327aff84be098e8860c Mon Sep 17 00:00:00 2001 From: Steve Repsher Date: Mon, 16 May 2022 11:10:41 -0400 Subject: [PATCH 029/112] Return focus after dialogs close (#11999) --- src/common/dom/ancestors-with-property.ts | 41 ++++++++++ src/components/ha-button-menu.ts | 30 ++++++- src/components/ha-dialog.ts | 3 + src/components/ha-icon-button.ts | 9 ++- src/dialogs/make-dialog-manager.ts | 78 ++++++++++++++++--- src/dialogs/more-info/ha-more-info-dialog.ts | 2 +- .../config/entities/dialog-entity-editor.ts | 2 +- 7 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 src/common/dom/ancestors-with-property.ts diff --git a/src/common/dom/ancestors-with-property.ts b/src/common/dom/ancestors-with-property.ts new file mode 100644 index 0000000000..6525dcb451 --- /dev/null +++ b/src/common/dom/ancestors-with-property.ts @@ -0,0 +1,41 @@ +const DEFAULT_OWN = true; + +// Finds the closest ancestor of an element that has a specific optionally owned property, +// traversing slot and shadow root boundaries until the body element is reached +export const closestWithProperty = ( + element: Element | null, + property: string | symbol, + own = DEFAULT_OWN +) => { + if (!element || element === document.body) return null; + + element = element.assignedSlot ?? element; + if (element.parentElement) { + element = element.parentElement; + } else { + const root = element.getRootNode(); + element = root instanceof ShadowRoot ? root.host : null; + } + + if ( + own + ? Object.prototype.hasOwnProperty.call(element, property) + : element && property in element + ) + return element; + return closestWithProperty(element, property, own); +}; + +// Finds the set of all such ancestors and includes starting element as first in the set +export const ancestorsWithProperty = ( + element: Element | null, + property: string | symbol, + own = DEFAULT_OWN +) => { + const ancestors: Set = new Set(); + while (element) { + ancestors.add(element); + element = closestWithProperty(element, property, own); + } + return ancestors; +}; diff --git a/src/components/ha-button-menu.ts b/src/components/ha-button-menu.ts index d3a93cac68..22b2538102 100644 --- a/src/components/ha-button-menu.ts +++ b/src/components/ha-button-menu.ts @@ -1,17 +1,27 @@ +import type { Button } from "@material/mwc-button"; import "@material/mwc-menu"; import type { Corner, Menu, MenuCorner } from "@material/mwc-menu"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, query } from "lit/decorators"; +import { + customElement, + property, + query, + queryAssignedElements, +} from "lit/decorators"; +import { FOCUS_TARGET } from "../dialogs/make-dialog-manager"; +import type { HaIconButton } from "./ha-icon-button"; @customElement("ha-button-menu") export class HaButtonMenu extends LitElement { + protected readonly [FOCUS_TARGET]; + @property() public corner: Corner = "TOP_START"; @property() public menuCorner: MenuCorner = "START"; - @property({ type: Number }) public x?: number; + @property({ type: Number }) public x: number | null = null; - @property({ type: Number }) public y?: number; + @property({ type: Number }) public y: number | null = null; @property({ type: Boolean }) public multi = false; @@ -23,6 +33,12 @@ export class HaButtonMenu extends LitElement { @query("mwc-menu", true) private _menu?: Menu; + @queryAssignedElements({ + slot: "trigger", + selector: "ha-icon-button, mwc-button", + }) + private _triggerButton!: Array; + public get items() { return this._menu?.items; } @@ -31,6 +47,14 @@ export class HaButtonMenu extends LitElement { return this._menu?.selected; } + public override focus() { + if (this._menu?.open) { + this._menu.focusItemAtIndex(0); + } else { + this._triggerButton[0]?.focus(); + } + } + protected render(): TemplateResult { return html`
diff --git a/src/components/ha-dialog.ts b/src/components/ha-dialog.ts index 2744d1eeed..64fcbc4846 100644 --- a/src/components/ha-dialog.ts +++ b/src/components/ha-dialog.ts @@ -4,6 +4,7 @@ import { mdiClose } from "@mdi/js"; import { css, html, TemplateResult } from "lit"; import { customElement } from "lit/decorators"; import type { HomeAssistant } from "../types"; +import { FOCUS_TARGET } from "../dialogs/make-dialog-manager"; import "./ha-icon-button"; export const createCloseHeading = ( @@ -21,6 +22,8 @@ export const createCloseHeading = ( @customElement("ha-dialog") export class HaDialog extends DialogBase { + protected readonly [FOCUS_TARGET]; + public scrollToPos(x: number, y: number) { this.contentElement?.scrollTo(x, y); } diff --git a/src/components/ha-icon-button.ts b/src/components/ha-icon-button.ts index d4f9ce8ba7..ac4b24c5d6 100644 --- a/src/components/ha-icon-button.ts +++ b/src/components/ha-icon-button.ts @@ -1,6 +1,7 @@ import "@material/mwc-icon-button"; +import type { IconButton } from "@material/mwc-icon-button"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators"; +import { customElement, property, query } from "lit/decorators"; import "./ha-svg-icon"; @customElement("ha-icon-button") @@ -15,6 +16,12 @@ export class HaIconButton extends LitElement { @property({ type: Boolean }) hideTitle = false; + @query("mwc-icon-button", true) private _button?: IconButton; + + public override focus() { + this._button?.focus(); + } + static shadowRootOptions: ShadowRootInit = { mode: "open", delegatesFocus: true, diff --git a/src/dialogs/make-dialog-manager.ts b/src/dialogs/make-dialog-manager.ts index 9a96b34312..c3fdb750f0 100644 --- a/src/dialogs/make-dialog-manager.ts +++ b/src/dialogs/make-dialog-manager.ts @@ -1,6 +1,9 @@ import { HASSDomEvent, ValidHassDomEvent } from "../common/dom/fire_event"; import { mainWindow } from "../common/dom/get_main_window"; import { ProvideHassElement } from "../mixins/provide-hass-lit-mixin"; +import { ancestorsWithProperty } from "../common/dom/ancestors-with-property"; +import { deepActiveElement } from "../common/dom/deep-active-element"; +import { nextRender } from "../common/util/render-status"; declare global { // for fire event @@ -40,7 +43,17 @@ export interface DialogState { dialogParams?: unknown; } -const LOADED = {}; +interface LoadedDialogInfo { + element: Promise; + closedFocusTargets?: Set; +} + +interface LoadedDialogsDict { + [tag: string]: LoadedDialogInfo; +} + +const LOADED: LoadedDialogsDict = {}; +export const FOCUS_TARGET = Symbol.for("HA focus target"); export const showDialog = async ( element: HTMLElement & ProvideHassElement, @@ -60,11 +73,24 @@ export const showDialog = async ( } return; } - LOADED[dialogTag] = dialogImport().then(() => { - const dialogEl = document.createElement(dialogTag) as HassDialog; - element.provideHass(dialogEl); - return dialogEl; - }); + LOADED[dialogTag] = { + element: dialogImport().then(() => { + const dialogEl = document.createElement(dialogTag) as HassDialog; + element.provideHass(dialogEl); + return dialogEl; + }), + }; + } + + // Get the focus targets after the dialog closes, but keep the original if dialog is being replaced + if (mainWindow.history.state?.replaced) { + LOADED[dialogTag].closedFocusTargets = + LOADED[mainWindow.history.state.dialog].closedFocusTargets; + } else { + LOADED[dialogTag].closedFocusTargets = ancestorsWithProperty( + deepActiveElement(), + FOCUS_TARGET + ); } if (addHistory) { @@ -93,25 +119,29 @@ export const showDialog = async ( ); } } - const dialogElement = await LOADED[dialogTag]; + + const dialogElement = await LOADED[dialogTag].element; + dialogElement.addEventListener("dialog-closed", _handleClosedFocus); + // Append it again so it's the last element in the root, // so it's guaranteed to be on top of the other elements root.appendChild(dialogElement); dialogElement.showDialog(dialogParams); }; -export const replaceDialog = () => { +export const replaceDialog = (dialogElement: HassDialog) => { mainWindow.history.replaceState( { ...mainWindow.history.state, replaced: true }, "" ); + dialogElement.removeEventListener("dialog-closed", _handleClosedFocus); }; export const closeDialog = async (dialogTag: string): Promise => { if (!(dialogTag in LOADED)) { return true; } - const dialogElement: HassDialog = await LOADED[dialogTag]; + const dialogElement = await LOADED[dialogTag].element; if (dialogElement.closeDialog) { return dialogElement.closeDialog() !== false; } @@ -137,3 +167,33 @@ export const makeDialogManager = ( } ); }; + +const _handleClosedFocus = async (ev: HASSDomEvent) => { + const closedFocusTargets = LOADED[ev.detail.dialog].closedFocusTargets; + delete LOADED[ev.detail.dialog].closedFocusTargets; + if (!closedFocusTargets) return; + + // Undo whatever the browser focused to provide easy checking + let focusedElement = deepActiveElement(); + if (focusedElement instanceof HTMLElement) focusedElement.blur(); + + // Make sure backdrop is fully updated before trying (especially needed for underlay dialogs) + await nextRender(); + + // Try all targets in order and stop when one works + for (const focusTarget of closedFocusTargets) { + if (focusTarget instanceof HTMLElement) { + focusTarget.focus(); + focusedElement = deepActiveElement(); + if (focusedElement && focusedElement !== document.body) return; + } + } + + if (__DEV__) { + // eslint-disable-next-line no-console + console.warn( + "Failed to focus any targets after closing dialog: %o", + closedFocusTargets + ); + } +}; diff --git a/src/dialogs/more-info/ha-more-info-dialog.ts b/src/dialogs/more-info/ha-more-info-dialog.ts index 2198a69a9e..fb7179a782 100644 --- a/src/dialogs/more-info/ha-more-info-dialog.ts +++ b/src/dialogs/more-info/ha-more-info-dialog.ts @@ -295,7 +295,7 @@ export class MoreInfoDialog extends LitElement { } private _gotoSettings() { - replaceDialog(); + replaceDialog(this); showEntityEditorDialog(this, { entity_id: this._entityId!, }); diff --git a/src/panels/config/entities/dialog-entity-editor.ts b/src/panels/config/entities/dialog-entity-editor.ts index ece37b5348..8b1d412ec1 100644 --- a/src/panels/config/entities/dialog-entity-editor.ts +++ b/src/panels/config/entities/dialog-entity-editor.ts @@ -220,7 +220,7 @@ export class DialogEntityEditor extends LitElement { } private _openMoreInfo(): void { - replaceDialog(); + replaceDialog(this); fireEvent(this, "hass-more-info", { entityId: this._params!.entity_id, }); From ee0de942f71bbddbc57da45c91f23190afb0ed2e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 16 May 2022 20:37:50 +0200 Subject: [PATCH 030/112] Bumped version to 20220516.0 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index fd31a6964f..e5bb95e409 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = home-assistant-frontend -version = 20220504.0 +version = 20220516.0 author = The Home Assistant Authors author_email = hello@home-assistant.io license = Apache-2.0 From b37f97128a0c110f0b256ccff60b51cf4ea67d4d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 16 May 2022 23:20:19 -0700 Subject: [PATCH 031/112] Fix float-end for LTR (#12707) --- src/state/translations-mixin.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/state/translations-mixin.ts b/src/state/translations-mixin.ts index 3178271b6d..8205817911 100644 --- a/src/state/translations-mixin.ts +++ b/src/state/translations-mixin.ts @@ -191,7 +191,10 @@ export default >(superClass: T) => this.style.direction = direction; document.dir = direction; this.style.setProperty("--direction", direction); - this.style.setProperty("--float-end", "left"); + this.style.setProperty( + "--float-end", + direction === "ltr" ? "right" : "left" + ); } /** From ba27c184f6002f90f46f96d1017677669bd8d8c6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 17 May 2022 12:13:46 +0200 Subject: [PATCH 032/112] Add my support for Application Credentials (#12709) --- src/panels/my/ha-panel-my.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index 9883f6a20b..8c35aa4420 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -13,6 +13,9 @@ import { HomeAssistant, Route } from "../../types"; import { documentationUrl } from "../../util/documentation-url"; export const getMyRedirects = (hasSupervisor: boolean): Redirects => ({ + application_credentials: { + redirect: "/config/application_credentials", + }, developer_states: { redirect: "/developer-tools/state", }, From 6f67da09c05bacb770f245be8611e881ca8467d1 Mon Sep 17 00:00:00 2001 From: Zack Barett Date: Tue, 17 May 2022 05:14:43 -0500 Subject: [PATCH 033/112] Show manage cloud link to config (#12673) --- src/panels/config/network/ha-config-url-form.ts | 11 ++++++++++- src/translations/en.json | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/panels/config/network/ha-config-url-form.ts b/src/panels/config/network/ha-config-url-form.ts index e5fb9ef4e9..b4938cc764 100644 --- a/src/panels/config/network/ha-config-url-form.ts +++ b/src/panels/config/network/ha-config-url-form.ts @@ -154,7 +154,16 @@ class ConfigUrlForm extends LitElement { ${!this._showCustomExternalUrl && hasCloud ? html` ${remoteEnabled - ? "" + ? html` + + ` : html` ${this.hass.localize( diff --git a/src/translations/en.json b/src/translations/en.json index afcc88b7f9..33358a964b 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1529,6 +1529,7 @@ "internal_url_label": "Local Network", "external_url_label": "Internet", "external_use_ha_cloud": "Use Home Assistant Cloud", + "manage_ha_cloud": "Manage Home Assistant Cloud", "external_get_ha_cloud": "Access from anywhere, add Google & Alexa easily", "ha_cloud_remote_not_enabled": "Your Home Assistant Cloud remote connection is currently not enabled.", "enable_remote": "[%key:ui::common::enable%]", From dd3a3ec586e6abfb7a0bf2b00f277e59762ef0e6 Mon Sep 17 00:00:00 2001 From: breakthestatic Date: Tue, 17 May 2022 03:25:32 -0700 Subject: [PATCH 034/112] Add guard logic from PR home-assistant#12181 to input select row (#12703) --- .../lovelace/entity-rows/hui-input-select-entity-row.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/panels/lovelace/entity-rows/hui-input-select-entity-row.ts b/src/panels/lovelace/entity-rows/hui-input-select-entity-row.ts index 1db8a8c1b6..931117bc58 100644 --- a/src/panels/lovelace/entity-rows/hui-input-select-entity-row.ts +++ b/src/panels/lovelace/entity-rows/hui-input-select-entity-row.ts @@ -92,9 +92,14 @@ class HuiInputSelectEntityRow extends LitElement implements LovelaceRow { `; private _selectedChanged(ev): void { - const stateObj = this.hass!.states[this._config!.entity]; + const stateObj = this.hass!.states[ + this._config!.entity + ] as InputSelectEntity; const option = ev.target.value; - if (option === stateObj.state) { + if ( + option === stateObj.state || + !stateObj.attributes.options.includes(option) + ) { return; } From 90c234ffad32ecf8cf0d45a1d57547255099a4da Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 May 2022 08:53:22 -0700 Subject: [PATCH 035/112] Refactor logbook data fetch logic into reusable class (#12701) --- src/common/util/throttle.ts | 8 +- src/components/entity/ha-entity-picker.ts | 2 +- src/components/trace/ha-trace-logbook.ts | 6 +- src/components/trace/ha-trace-path-details.ts | 6 +- src/dialogs/more-info/ha-more-info-logbook.ts | 194 +---- src/panels/logbook/ha-logbook-renderer.ts | 532 ++++++++++++++ src/panels/logbook/ha-logbook.ts | 664 ++++++------------ src/panels/logbook/ha-panel-logbook.ts | 261 +++---- src/panels/lovelace/cards/hui-logbook-card.ts | 214 +----- 9 files changed, 883 insertions(+), 1004 deletions(-) create mode 100644 src/panels/logbook/ha-logbook-renderer.ts diff --git a/src/common/util/throttle.ts b/src/common/util/throttle.ts index 2860f66be5..02327a5789 100644 --- a/src/common/util/throttle.ts +++ b/src/common/util/throttle.ts @@ -13,7 +13,7 @@ export const throttle = ( ) => { let timeout: number | undefined; let previous = 0; - return (...args: T): void => { + const throttledFunc = (...args: T): void => { const later = () => { previous = leading === false ? 0 : Date.now(); timeout = undefined; @@ -35,4 +35,10 @@ export const throttle = ( timeout = window.setTimeout(later, remaining); } }; + throttledFunc.cancel = () => { + clearTimeout(timeout); + timeout = undefined; + previous = 0; + }; + return throttledFunc; }; diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index 7f9c8d3d80..3b6c3e6304 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -20,7 +20,7 @@ interface HassEntityWithCachedName extends HassEntity { friendly_name: string; } -export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; +export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean; // eslint-disable-next-line lit/prefer-static-styles const rowRenderer: ComboBoxLitRenderer = (item) => diff --git a/src/components/trace/ha-trace-logbook.ts b/src/components/trace/ha-trace-logbook.ts index 2f4da4de66..7b0118b3cf 100644 --- a/src/components/trace/ha-trace-logbook.ts +++ b/src/components/trace/ha-trace-logbook.ts @@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators"; import { LogbookEntry } from "../../data/logbook"; import { HomeAssistant } from "../../types"; import "./hat-logbook-note"; -import "../../panels/logbook/ha-logbook"; +import "../../panels/logbook/ha-logbook-renderer"; import { TraceExtended } from "../../data/trace"; @customElement("ha-trace-logbook") @@ -19,12 +19,12 @@ export class HaTraceLogbook extends LitElement { protected render(): TemplateResult { return this.logbookEntries.length ? html` - + > ` : html`
diff --git a/src/components/trace/ha-trace-path-details.ts b/src/components/trace/ha-trace-path-details.ts index ebf77a2ab6..dcf97366bd 100644 --- a/src/components/trace/ha-trace-path-details.ts +++ b/src/components/trace/ha-trace-path-details.ts @@ -13,7 +13,7 @@ import { getDataFromPath, TraceExtended, } from "../../data/trace"; -import "../../panels/logbook/ha-logbook"; +import "../../panels/logbook/ha-logbook-renderer"; import { traceTabStyles } from "./trace-tab-styles"; import { HomeAssistant } from "../../types"; import type { NodeInfo } from "./hat-script-graph"; @@ -224,12 +224,12 @@ export class HaTracePathDetails extends LitElement { return entries.length ? html` - + > ` : html`
diff --git a/src/dialogs/more-info/ha-more-info-logbook.ts b/src/dialogs/more-info/ha-more-info-logbook.ts index e577ff7cb6..15712055c0 100644 --- a/src/dialogs/more-info/ha-more-info-logbook.ts +++ b/src/dialogs/more-info/ha-more-info-logbook.ts @@ -1,17 +1,10 @@ import { startOfYesterday } from "date-fns"; import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators"; +import { customElement, property } from "lit/decorators"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { fireEvent } from "../../common/dom/fire_event"; -import { computeStateDomain } from "../../common/entity/compute_state_domain"; -import { throttle } from "../../common/util/throttle"; -import "../../components/ha-circular-progress"; -import { getLogbookData, LogbookEntry } from "../../data/logbook"; -import { loadTraceContexts, TraceContexts } from "../../data/trace"; -import { fetchUsers } from "../../data/user"; import "../../panels/logbook/ha-logbook"; -import { haStyle } from "../../resources/styles"; -import { HomeAssistant } from "../../types"; +import type { HomeAssistant } from "../../types"; @customElement("ha-more-info-logbook") export class MoreInfoLogbook extends LitElement { @@ -19,26 +12,12 @@ export class MoreInfoLogbook extends LitElement { @property() public entityId!: string; - @state() private _logbookEntries?: LogbookEntry[]; - - @state() private _traceContexts?: TraceContexts; - - @state() private _userIdToName = {}; - - private _lastLogbookDate?: Date; - - private _fetchUserPromise?: Promise; - - private _error?: string; - private _showMoreHref = ""; - private _throttleGetLogbookEntries = throttle(() => { - this._getLogBookData(); - }, 10000); + private _time = { recent: 86400 }; protected render(): TemplateResult { - if (!this.entityId) { + if (!isComponentLoaded(this.hass, "logbook") || !this.entityId) { return html``; } const stateObj = this.hass.states[this.entityId]; @@ -48,149 +27,34 @@ export class MoreInfoLogbook extends LitElement { } return html` - ${isComponentLoaded(this.hass, "logbook") - ? this._error - ? html`
- ${`${this.hass.localize( - "ui.components.logbook.retrieval_error" - )}: ${this._error}`} -
` - : !this._logbookEntries - ? html` - - ` - : this._logbookEntries.length - ? html` -
-
- ${this.hass.localize("ui.dialogs.more_info_control.logbook")} -
- ${this.hass.localize( - "ui.dialogs.more_info_control.show_more" - )} -
- - ` - : html`
- ${this.hass.localize("ui.components.logbook.entries_not_found")} -
` - : ""} +
+
+ ${this.hass.localize("ui.dialogs.more_info_control.logbook")} +
+ ${this.hass.localize("ui.dialogs.more_info_control.show_more")} +
+ `; } - protected firstUpdated(): void { - this._fetchUserPromise = this._fetchUserNames(); - } - - protected updated(changedProps: PropertyValues): void { - super.updated(changedProps); - - if (changedProps.has("entityId")) { - this._lastLogbookDate = undefined; - this._logbookEntries = undefined; - - if (!this.entityId) { - return; - } + protected willUpdate(changedProps: PropertyValues): void { + super.willUpdate(changedProps); + if (changedProps.has("entityId") && this.entityId) { this._showMoreHref = `/logbook?entity_id=${ this.entityId }&start_date=${startOfYesterday().toISOString()}`; - - this._throttleGetLogbookEntries(); - return; } - - if (!this.entityId || !changedProps.has("hass")) { - return; - } - - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - - if ( - oldHass && - this.hass.states[this.entityId] !== oldHass?.states[this.entityId] - ) { - // wait for commit of data (we only account for the default setting of 1 sec) - setTimeout(this._throttleGetLogbookEntries, 1000); - } - } - - private async _getLogBookData() { - if (!isComponentLoaded(this.hass, "logbook")) { - return; - } - const lastDate = - this._lastLogbookDate || - new Date(new Date().getTime() - 24 * 60 * 60 * 1000); - const now = new Date(); - let newEntries; - let traceContexts; - - try { - [newEntries, traceContexts] = await Promise.all([ - getLogbookData( - this.hass, - lastDate.toISOString(), - now.toISOString(), - this.entityId - ), - this.hass.user?.is_admin ? loadTraceContexts(this.hass) : {}, - this._fetchUserPromise, - ]); - } catch (err: any) { - this._error = err.message; - } - - this._logbookEntries = this._logbookEntries - ? [...newEntries, ...this._logbookEntries] - : newEntries; - this._lastLogbookDate = now; - this._traceContexts = traceContexts; - } - - private async _fetchUserNames() { - const userIdToName = {}; - - // Start loading users - const userProm = this.hass.user?.is_admin && fetchUsers(this.hass); - - // Process persons - Object.values(this.hass.states).forEach((entity) => { - if ( - entity.attributes.user_id && - computeStateDomain(entity) === "person" - ) { - this._userIdToName[entity.attributes.user_id] = - entity.attributes.friendly_name; - } - }); - - // Process users - if (userProm) { - const users = await userProm; - for (const user of users) { - if (!(user.id in userIdToName)) { - userIdToName[user.id] = user.name; - } - } - } - - this._userIdToName = userIdToName; } private _close(): void { @@ -199,13 +63,7 @@ export class MoreInfoLogbook extends LitElement { static get styles() { return [ - haStyle, css` - .no-entries { - text-align: center; - padding: 16px; - color: var(--secondary-text-color); - } ha-logbook { --logbook-max-height: 250px; } @@ -214,10 +72,6 @@ export class MoreInfoLogbook extends LitElement { --logbook-max-height: unset; } } - ha-circular-progress { - display: flex; - justify-content: center; - } .header { display: flex; flex-direction: row; diff --git a/src/panels/logbook/ha-logbook-renderer.ts b/src/panels/logbook/ha-logbook-renderer.ts new file mode 100644 index 0000000000..4514499dac --- /dev/null +++ b/src/panels/logbook/ha-logbook-renderer.ts @@ -0,0 +1,532 @@ +import "@lit-labs/virtualizer"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, eventOptions, property } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../../common/const"; +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 { domainIcon } from "../../common/entity/domain_icon"; +import { computeRTL, emitRTLDirection } from "../../common/util/compute_rtl"; +import "../../components/entity/state-badge"; +import "../../components/ha-circular-progress"; +import "../../components/ha-relative-time"; +import { LogbookEntry } from "../../data/logbook"; +import { TraceContexts } from "../../data/trace"; +import { + haStyle, + haStyleScrollbar, + buttonLinkStyle, +} from "../../resources/styles"; +import { HomeAssistant } from "../../types"; + +const EVENT_LOCALIZE_MAP = { + script_started: "from_script", +}; + +@customElement("ha-logbook-renderer") +class HaLogbookRenderer extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public userIdToName = {}; + + @property({ attribute: false }) + public traceContexts: TraceContexts = {}; + + @property({ attribute: false }) public entries: LogbookEntry[] = []; + + @property({ type: Boolean, attribute: "narrow" }) + public narrow = false; + + @property({ attribute: "rtl", type: Boolean }) + private _rtl = false; + + @property({ type: Boolean, attribute: "virtualize", reflect: true }) + public virtualize = false; + + @property({ type: Boolean, attribute: "no-icon" }) + public noIcon = false; + + @property({ type: Boolean, attribute: "no-name" }) + public noName = false; + + @property({ type: Boolean, attribute: "relative-time" }) + public relativeTime = false; + + // @ts-ignore + @restoreScroll(".container") private _savedScrollPos?: number; + + protected shouldUpdate(changedProps: PropertyValues) { + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + const languageChanged = + oldHass === undefined || oldHass.locale !== this.hass.locale; + + return ( + changedProps.has("entries") || + changedProps.has("traceContexts") || + languageChanged + ); + } + + protected updated(_changedProps: PropertyValues) { + const oldHass = _changedProps.get("hass") as HomeAssistant | undefined; + + if (oldHass === undefined || oldHass.language !== this.hass.language) { + this._rtl = computeRTL(this.hass); + } + } + + protected render(): TemplateResult { + if (!this.entries?.length) { + return html` +
+ ${this.hass.localize("ui.components.logbook.entries_not_found")} +
+ `; + } + + return html` +
+ ${this.virtualize + ? html` + ` + : this.entries.map((item, index) => + this._renderLogbookItem(item, index) + )} +
+ `; + } + + private _renderLogbookItem = ( + item: LogbookEntry, + index: number + ): TemplateResult => { + if (!item || index === undefined) { + return html``; + } + + const seenEntityIds: string[] = []; + const previous = this.entries[index - 1]; + const stateObj = item.entity_id + ? this.hass.states[item.entity_id] + : undefined; + const item_username = + item.context_user_id && this.userIdToName[item.context_user_id]; + const domain = item.entity_id + ? computeDomain(item.entity_id) + : // Domain is there if there is no entity ID. + item.domain!; + + return html` +
+ ${index === 0 || + (item?.when && + previous?.when && + new Date(item.when * 1000).toDateString() !== + new Date(previous.when * 1000).toDateString()) + ? html` +

+ ${formatDate(new Date(item.when * 1000), this.hass.locale)} +

+ ` + : html``} + +
+
+ ${!this.noIcon + ? // We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering, + // as they would present a false state in the log (played media right now vs actual historic data). + html` + + ` + : ""} +
+
+ ${!this.noName // Used for more-info panel (single entity case) + ? this._renderEntity(item.entity_id, item.name) + : ""} + ${item.message + ? html`${this._formatMessageWithPossibleEntity( + item.message, + seenEntityIds, + item.entity_id + )}` + : item.source + ? html` ${this._formatMessageWithPossibleEntity( + item.source, + seenEntityIds, + undefined, + "ui.components.logbook.by" + )}` + : ""} + ${item_username + ? ` ${this.hass.localize( + "ui.components.logbook.by_user" + )} ${item_username}` + : ``} + ${item.context_event_type + ? this._formatEventBy(item, seenEntityIds) + : ""} + ${item.context_message + ? html` ${this._formatMessageWithPossibleEntity( + item.context_message, + seenEntityIds, + item.context_entity_id, + "ui.components.logbook.for" + )}` + : ""} + ${item.context_entity_id && + !seenEntityIds.includes(item.context_entity_id) + ? // Another entity such as an automation or script + html` ${this.hass.localize("ui.components.logbook.for")} + ${this._renderEntity( + item.context_entity_id, + item.context_entity_id_name + )}` + : ""} +
+
+ ${formatTimeWithSeconds( + new Date(item.when * 1000), + this.hass.locale + )} + - + + ${["script", "automation"].includes(item.domain!) && + item.context_id! in this.traceContexts + ? html` + - + ${this.hass.localize( + "ui.components.logbook.show_trace" + )} + ` + : ""} +
+
+
+
+
+ `; + }; + + @eventOptions({ passive: true }) + private _saveScrollPos(e: Event) { + this._savedScrollPos = (e.target as HTMLDivElement).scrollTop; + } + + private _formatEventBy(item: LogbookEntry, seenEntities: string[]) { + if (item.context_event_type === "call_service") { + return `${this.hass.localize("ui.components.logbook.from_service")} ${ + item.context_domain + }.${item.context_service}`; + } + if (item.context_event_type === "automation_triggered") { + if (seenEntities.includes(item.context_entity_id!)) { + return ""; + } + seenEntities.push(item.context_entity_id!); + return html`${this.hass.localize("ui.components.logbook.from_automation")} + ${this._renderEntity(item.context_entity_id, item.context_name)}`; + } + if (item.context_name) { + return `${this.hass.localize("ui.components.logbook.from")} ${ + item.context_name + }`; + } + if (item.context_event_type === "state_changed") { + return ""; + } + if (item.context_event_type! in EVENT_LOCALIZE_MAP) { + return `${this.hass.localize( + `ui.components.logbook.${EVENT_LOCALIZE_MAP[item.context_event_type!]}` + )}`; + } + return `${this.hass.localize( + "ui.components.logbook.from" + )} ${this.hass.localize("ui.components.logbook.event")} ${ + item.context_event_type + }`; + } + + private _renderEntity( + entityId: string | undefined, + entityName: string | undefined + ) { + const hasState = entityId && entityId in this.hass.states; + const displayName = + entityName || + (hasState + ? this.hass.states[entityId].attributes.friendly_name || entityId + : entityId); + if (!hasState) { + return displayName; + } + return html``; + } + + private _formatMessageWithPossibleEntity( + message: string, + seenEntities: string[], + possibleEntity?: string, + localizePrefix?: string + ) { + // + // As we are looking at a log(book), we are doing entity_id + // "highlighting"/"colorizing". The goal is to make it easy for + // the user to access the entity that caused the event. + // + // If there is an entity_id in the message that is also in the + // state machine, we search the message for the entity_id and + // replace it with _renderEntity + // + if (message.indexOf(".") !== -1) { + const messageParts = message.split(" "); + for (let i = 0, size = messageParts.length; i < size; i++) { + if (messageParts[i] in this.hass.states) { + const entityId = messageParts[i]; + if (seenEntities.includes(entityId)) { + return ""; + } + seenEntities.push(entityId); + const messageEnd = messageParts.splice(i); + messageEnd.shift(); // remove the entity + return html` ${messageParts.join(" ")} + ${this._renderEntity( + entityId, + this.hass.states[entityId].attributes.friendly_name + )} + ${messageEnd.join(" ")}`; + } + } + } + // + // When we have a message has a specific entity_id attached to + // it, and the entity_id is not in the message, we look + // for the friendly name of the entity and replace that with + // _renderEntity if its there so the user can quickly get to + // that entity. + // + if (possibleEntity && possibleEntity in this.hass.states) { + const possibleEntityName = + this.hass.states[possibleEntity].attributes.friendly_name; + if (possibleEntityName && message.endsWith(possibleEntityName)) { + if (seenEntities.includes(possibleEntity)) { + return ""; + } + seenEntities.push(possibleEntity); + message = message.substring( + 0, + message.length - possibleEntityName.length + ); + return html` ${localizePrefix ? this.hass.localize(localizePrefix) : ""} + ${message} ${this._renderEntity(possibleEntity, possibleEntityName)}`; + } + } + return message; + } + + private _entityClicked(ev: Event) { + const entityId = (ev.currentTarget as any).entityId; + if (!entityId) { + return; + } + + ev.preventDefault(); + ev.stopPropagation(); + fireEvent(this, "hass-more-info", { + entityId: entityId, + }); + } + + private _close(): void { + setTimeout(() => fireEvent(this, "closed"), 500); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleScrollbar, + buttonLinkStyle, + css` + :host([virtualize]) { + display: block; + height: 100%; + } + + .rtl { + direction: ltr; + } + + .entry-container { + width: 100%; + } + + .entry { + display: flex; + width: 100%; + line-height: 2em; + padding: 8px 16px; + box-sizing: border-box; + border-top: 1px solid var(--divider-color); + } + + .entry.no-entity, + .no-name .entry { + cursor: default; + } + + .entry:hover { + background-color: rgba(var(--rgb-primary-text-color), 0.04); + } + + .narrow:not(.no-icon) .time { + margin-left: 32px; + } + + .message-relative_time { + display: flex; + flex-direction: column; + } + + .secondary { + font-size: 12px; + line-height: 1.7; + } + + .secondary a { + color: var(--secondary-text-color); + } + + .date { + margin: 8px 0; + padding: 0 16px; + } + + .narrow .date { + padding: 0 8px; + } + + .rtl .date { + direction: rtl; + } + + .icon-message { + display: flex; + align-items: center; + } + + .no-entries { + text-align: center; + color: var(--secondary-text-color); + } + + state-badge { + margin-right: 16px; + flex-shrink: 0; + color: var(--state-icon-color); + } + + .message { + color: var(--primary-text-color); + } + + .no-name .message:first-letter { + text-transform: capitalize; + } + + a { + color: var(--primary-color); + } + + .container { + max-height: var(--logbook-max-height); + } + + .container, + lit-virtualizer { + height: 100%; + } + + lit-virtualizer { + contain: size layout !important; + } + + .narrow .entry { + line-height: 1.5; + padding: 8px; + } + + .narrow .icon-message state-badge { + margin-left: 0; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-logbook-renderer": HaLogbookRenderer; + } +} diff --git a/src/panels/logbook/ha-logbook.ts b/src/panels/logbook/ha-logbook.ts index 680eb38603..c797249032 100644 --- a/src/panels/logbook/ha-logbook.ts +++ b/src/panels/logbook/ha-logbook.ts @@ -1,55 +1,31 @@ -import "@lit-labs/virtualizer"; -import { - css, - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, -} from "lit"; -import { customElement, eventOptions, property } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; -import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../../common/const"; -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 { domainIcon } from "../../common/entity/domain_icon"; -import { computeRTL, emitRTLDirection } from "../../common/util/compute_rtl"; -import "../../components/entity/state-badge"; +import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { isComponentLoaded } from "../../common/config/is_component_loaded"; +import { ensureArray } from "../../common/ensure-array"; +import { computeStateDomain } from "../../common/entity/compute_state_domain"; +import { throttle } from "../../common/util/throttle"; import "../../components/ha-circular-progress"; -import "../../components/ha-relative-time"; -import { LogbookEntry } from "../../data/logbook"; -import { TraceContexts } from "../../data/trace"; import { - haStyle, - haStyleScrollbar, - buttonLinkStyle, -} from "../../resources/styles"; + clearLogbookCache, + getLogbookData, + LogbookEntry, +} from "../../data/logbook"; +import { loadTraceContexts, TraceContexts } from "../../data/trace"; +import { fetchUsers } from "../../data/user"; import { HomeAssistant } from "../../types"; - -const EVENT_LOCALIZE_MAP = { - script_started: "from_script", -}; +import "./ha-logbook-renderer"; @customElement("ha-logbook") -class HaLogbook extends LitElement { +export class HaLogbook extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public userIdToName = {}; + @property() public time!: { range: [Date, Date] } | { recent: number }; - @property({ attribute: false }) - public traceContexts: TraceContexts = {}; - - @property({ attribute: false }) public entries: LogbookEntry[] = []; + @property() public entityId?: string | string[]; @property({ type: Boolean, attribute: "narrow" }) public narrow = false; - @property({ attribute: "rtl", type: Boolean }) - private _rtl = false; - @property({ type: Boolean, attribute: "virtualize", reflect: true }) public virtualize = false; @@ -62,463 +38,227 @@ class HaLogbook extends LitElement { @property({ type: Boolean, attribute: "relative-time" }) public relativeTime = false; - // @ts-ignore - @restoreScroll(".container") private _savedScrollPos?: number; + @property({ type: Boolean }) public showMoreLink = true; - protected shouldUpdate(changedProps: PropertyValues) { - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - const languageChanged = - oldHass === undefined || oldHass.locale !== this.hass.locale; + @state() private _logbookEntries?: LogbookEntry[]; - return ( - changedProps.has("entries") || - changedProps.has("traceContexts") || - languageChanged - ); - } + @state() private _traceContexts?: TraceContexts; - protected updated(_changedProps: PropertyValues) { - const oldHass = _changedProps.get("hass") as HomeAssistant | undefined; + @state() private _userIdToName = {}; - if (oldHass === undefined || oldHass.language !== this.hass.language) { - this._rtl = computeRTL(this.hass); - } - } + @state() private _error?: string; + + private _lastLogbookDate?: Date; + + private _renderId = 1; + + private _throttleGetLogbookEntries = throttle( + () => this._getLogBookData(), + 10000 + ); protected render(): TemplateResult { - if (!this.entries?.length) { + if (!isComponentLoaded(this.hass, "logbook")) { + return html``; + } + + if (this._error) { + return html`
+ ${`${this.hass.localize("ui.components.logbook.retrieval_error")}: ${ + this._error + }`} +
`; + } + + if (this._logbookEntries === undefined) { return html` -
- ${this.hass.localize("ui.components.logbook.entries_not_found")} +
+
`; } + if (this._logbookEntries.length === 0) { + return html`
+ ${this.hass.localize("ui.components.logbook.entries_not_found")} +
`; + } + return html` -
- ${this.virtualize - ? html` - ` - : this.entries.map((item, index) => - this._renderLogbookItem(item, index) - )} -
+ `; } - private _renderLogbookItem = ( - item: LogbookEntry, - index: number - ): TemplateResult => { - if (!item || index === undefined) { - return html``; - } - - const seenEntityIds: string[] = []; - const previous = this.entries[index - 1]; - const stateObj = item.entity_id - ? this.hass.states[item.entity_id] - : undefined; - const item_username = - item.context_user_id && this.userIdToName[item.context_user_id]; - const domain = item.entity_id - ? computeDomain(item.entity_id) - : // Domain is there if there is no entity ID. - item.domain!; - - return html` -
- ${index === 0 || - (item?.when && - previous?.when && - new Date(item.when * 1000).toDateString() !== - new Date(previous.when * 1000).toDateString()) - ? html` -

- ${formatDate(new Date(item.when * 1000), this.hass.locale)} -

- ` - : html``} - -
-
- ${!this.noIcon - ? // We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering, - // as they would present a false state in the log (played media right now vs actual historic data). - html` - - ` - : ""} -
-
- ${!this.noName // Used for more-info panel (single entity case) - ? this._renderEntity(item.entity_id, item.name) - : ""} - ${item.message - ? html`${this._formatMessageWithPossibleEntity( - item.message, - seenEntityIds, - item.entity_id - )}` - : item.source - ? html` ${this._formatMessageWithPossibleEntity( - item.source, - seenEntityIds, - undefined, - "ui.components.logbook.by" - )}` - : ""} - ${item_username - ? ` ${this.hass.localize( - "ui.components.logbook.by_user" - )} ${item_username}` - : ``} - ${item.context_event_type - ? this._formatEventBy(item, seenEntityIds) - : ""} - ${item.context_message - ? html` ${this._formatMessageWithPossibleEntity( - item.context_message, - seenEntityIds, - item.context_entity_id, - "ui.components.logbook.for" - )}` - : ""} - ${item.context_entity_id && - !seenEntityIds.includes(item.context_entity_id) - ? // Another entity such as an automation or script - html` ${this.hass.localize("ui.components.logbook.for")} - ${this._renderEntity( - item.context_entity_id, - item.context_entity_id_name - )}` - : ""} -
-
- ${formatTimeWithSeconds( - new Date(item.when * 1000), - this.hass.locale - )} - - - - ${["script", "automation"].includes(item.domain!) && - item.context_id! in this.traceContexts - ? html` - - - ${this.hass.localize( - "ui.components.logbook.show_trace" - )} - ` - : ""} -
-
-
-
-
- `; - }; - - @eventOptions({ passive: true }) - private _saveScrollPos(e: Event) { - this._savedScrollPos = (e.target as HTMLDivElement).scrollTop; - } - - private _formatEventBy(item: LogbookEntry, seenEntities: string[]) { - if (item.context_event_type === "call_service") { - return `${this.hass.localize("ui.components.logbook.from_service")} ${ - item.context_domain - }.${item.context_service}`; - } - if (item.context_event_type === "automation_triggered") { - if (seenEntities.includes(item.context_entity_id!)) { - return ""; - } - seenEntities.push(item.context_entity_id!); - return html`${this.hass.localize("ui.components.logbook.from_automation")} - ${this._renderEntity(item.context_entity_id, item.context_name)}`; - } - if (item.context_name) { - return `${this.hass.localize("ui.components.logbook.from")} ${ - item.context_name - }`; - } - if (item.context_event_type === "state_changed") { - return ""; - } - if (item.context_event_type! in EVENT_LOCALIZE_MAP) { - return `${this.hass.localize( - `ui.components.logbook.${EVENT_LOCALIZE_MAP[item.context_event_type!]}` - )}`; - } - return `${this.hass.localize( - "ui.components.logbook.from" - )} ${this.hass.localize("ui.components.logbook.event")} ${ - item.context_event_type - }`; - } - - private _renderEntity( - entityId: string | undefined, - entityName: string | undefined - ) { - const hasState = entityId && entityId in this.hass.states; - const displayName = - entityName || - (hasState - ? this.hass.states[entityId].attributes.friendly_name || entityId - : entityId); - if (!hasState) { - return displayName; - } - return html``; - } - - private _formatMessageWithPossibleEntity( - message: string, - seenEntities: string[], - possibleEntity?: string, - localizePrefix?: string - ) { - // - // As we are looking at a log(book), we are doing entity_id - // "highlighting"/"colorizing". The goal is to make it easy for - // the user to access the entity that caused the event. - // - // If there is an entity_id in the message that is also in the - // state machine, we search the message for the entity_id and - // replace it with _renderEntity - // - if (message.indexOf(".") !== -1) { - const messageParts = message.split(" "); - for (let i = 0, size = messageParts.length; i < size; i++) { - if (messageParts[i] in this.hass.states) { - const entityId = messageParts[i]; - if (seenEntities.includes(entityId)) { - return ""; - } - seenEntities.push(entityId); - const messageEnd = messageParts.splice(i); - messageEnd.shift(); // remove the entity - return html` ${messageParts.join(" ")} - ${this._renderEntity( - entityId, - this.hass.states[entityId].attributes.friendly_name - )} - ${messageEnd.join(" ")}`; - } - } - } - // - // When we have a message has a specific entity_id attached to - // it, and the entity_id is not in the message, we look - // for the friendly name of the entity and replace that with - // _renderEntity if its there so the user can quickly get to - // that entity. - // - if (possibleEntity && possibleEntity in this.hass.states) { - const possibleEntityName = - this.hass.states[possibleEntity].attributes.friendly_name; - if (possibleEntityName && message.endsWith(possibleEntityName)) { - if (seenEntities.includes(possibleEntity)) { - return ""; - } - seenEntities.push(possibleEntity); - message = message.substring( - 0, - message.length - possibleEntityName.length - ); - return html` ${localizePrefix ? this.hass.localize(localizePrefix) : ""} - ${message} ${this._renderEntity(possibleEntity, possibleEntityName)}`; - } - } - return message; - } - - private _entityClicked(ev: Event) { - const entityId = (ev.currentTarget as any).entityId; - if (!entityId) { + public async refresh(force = false) { + if (!force && this._logbookEntries === undefined) { return; } - ev.preventDefault(); - ev.stopPropagation(); - fireEvent(this, "hass-more-info", { - entityId: entityId, - }); + this._throttleGetLogbookEntries.cancel(); + this._updateTraceContexts.cancel(); + this._updateUsers.cancel(); + + if ("range" in this.time) { + clearLogbookCache( + this.time.range[0].toISOString(), + this.time.range[1].toISOString() + ); + } + + this._lastLogbookDate = undefined; + this._logbookEntries = undefined; + this._error = undefined; + this._throttleGetLogbookEntries(); } - private _close(): void { - setTimeout(() => fireEvent(this, "closed"), 500); + protected updated(changedProps: PropertyValues): void { + super.updated(changedProps); + + if (changedProps.has("time") || changedProps.has("entityId")) { + this.refresh(true); + return; + } + + // We only need to fetch again if we track recent entries for an entity + if ( + !("recent" in this.time) || + !changedProps.has("hass") || + !this.entityId + ) { + return; + } + + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + + // Refresh data if we know the entity has changed. + if ( + !oldHass || + ensureArray(this.entityId).some( + (entityId) => this.hass.states[entityId] !== oldHass?.states[entityId] + ) + ) { + // wait for commit of data (we only account for the default setting of 1 sec) + setTimeout(this._throttleGetLogbookEntries, 1000); + } } - static get styles(): CSSResultGroup { + private async _getLogBookData() { + this._renderId += 1; + const renderId = this._renderId; + let startTime: Date; + let endTime: Date; + let appendData = false; + + if ("range" in this.time) { + [startTime, endTime] = this.time.range; + } else { + // Recent data + appendData = true; + startTime = + this._lastLogbookDate || + new Date(new Date().getTime() - 24 * 60 * 60 * 1000); + endTime = new Date(); + } + + this._updateUsers(); + if (this.hass.user?.is_admin) { + this._updateTraceContexts(); + } + + let newEntries: LogbookEntry[]; + + try { + newEntries = await getLogbookData( + this.hass, + startTime.toISOString(), + endTime.toISOString(), + this.entityId ? ensureArray(this.entityId).toString() : undefined + ); + } catch (err: any) { + if (renderId === this._renderId) { + this._error = err.message; + } + return; + } + + // New render happening. + if (renderId !== this._renderId) { + return; + } + + this._logbookEntries = + appendData && this._logbookEntries + ? newEntries.concat(...this._logbookEntries) + : newEntries; + this._lastLogbookDate = endTime; + } + + private _updateTraceContexts = throttle(async () => { + this._traceContexts = await loadTraceContexts(this.hass); + }, 60000); + + private _updateUsers = throttle(async () => { + const userIdToName = {}; + + // Start loading users + const userProm = this.hass.user?.is_admin && fetchUsers(this.hass); + + // Process persons + for (const entity of Object.values(this.hass.states)) { + if ( + entity.attributes.user_id && + computeStateDomain(entity) === "person" + ) { + userIdToName[entity.attributes.user_id] = + entity.attributes.friendly_name; + } + } + + // Process users + if (userProm) { + const users = await userProm; + for (const user of users) { + if (!(user.id in userIdToName)) { + userIdToName[user.id] = user.name; + } + } + } + + this._userIdToName = userIdToName; + }, 60000); + + static get styles() { return [ - haStyle, - haStyleScrollbar, - buttonLinkStyle, css` :host([virtualize]) { display: block; height: 100%; } - .rtl { - direction: ltr; - } - - .entry-container { - width: 100%; - } - - .entry { - display: flex; - width: 100%; - line-height: 2em; - padding: 8px 16px; - box-sizing: border-box; - border-top: 1px solid var(--divider-color); - } - - .entry.no-entity, - .no-name .entry { - cursor: default; - } - - .entry:hover { - background-color: rgba(var(--rgb-primary-text-color), 0.04); - } - - .narrow:not(.no-icon) .time { - margin-left: 32px; - } - - .message-relative_time { - display: flex; - flex-direction: column; - } - - .secondary { - font-size: 12px; - line-height: 1.7; - } - - .secondary a { - color: var(--secondary-text-color); - } - - .date { - margin: 8px 0; - padding: 0 16px; - } - - .narrow .date { - padding: 0 8px; - } - - .rtl .date { - direction: rtl; - } - - .icon-message { - display: flex; - align-items: center; - } - .no-entries { text-align: center; + padding: 16px; color: var(--secondary-text-color); } - state-badge { - margin-right: 16px; - flex-shrink: 0; - color: var(--state-icon-color); - } - - .message { - color: var(--primary-text-color); - } - - .no-name .message:first-letter { - text-transform: capitalize; - } - - a { - color: var(--primary-color); - } - - .container { - max-height: var(--logbook-max-height); - } - - .container, - lit-virtualizer { + .progress-wrapper { + display: flex; + justify-content: center; height: 100%; - } - - lit-virtualizer { - contain: size layout !important; - } - - .narrow .entry { - line-height: 1.5; - padding: 8px; - } - - .narrow .icon-message state-badge { - margin-left: 0; + align-items: center; } `, ]; diff --git a/src/panels/logbook/ha-panel-logbook.ts b/src/panels/logbook/ha-panel-logbook.ts index feac004259..864532d5f2 100644 --- a/src/panels/logbook/ha-panel-logbook.ts +++ b/src/panels/logbook/ha-panel-logbook.ts @@ -12,28 +12,19 @@ import { } from "date-fns"; import { css, html, LitElement, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { navigate } from "../../common/navigate"; import { createSearchParam, - extractSearchParam, + extractSearchParamsObject, } from "../../common/url/search-params"; import { computeRTL } from "../../common/util/compute_rtl"; import "../../components/entity/ha-entity-picker"; -import "../../components/ha-circular-progress"; +import type { HaEntityPickerEntityFilterFunc } from "../../components/entity/ha-entity-picker"; import "../../components/ha-date-range-picker"; import type { DateRangePickerRanges } from "../../components/ha-date-range-picker"; import "../../components/ha-icon-button"; import "../../components/ha-menu-button"; -import { - clearLogbookCache, - getLogbookData, - LogbookEntry, -} from "../../data/logbook"; -import { loadTraceContexts, TraceContexts } from "../../data/trace"; -import { fetchUsers } from "../../data/user"; -import { showAlertDialog } from "../../dialogs/generic/show-dialog-box"; import "../../layouts/ha-app-layout"; import { haStyle } from "../../resources/styles"; import { HomeAssistant } from "../../types"; @@ -45,36 +36,24 @@ export class HaPanelLogbook extends LitElement { @property({ reflect: true, type: Boolean }) narrow!: boolean; - @property() _startDate: Date; + @state() _time: { range: [Date, Date] }; - @property() _endDate: Date; - - @property() _entityId = ""; - - @property() _isLoading = false; - - @property() _entries: LogbookEntry[] = []; + @state() _entityId = ""; @property({ reflect: true, type: Boolean }) rtl = false; @state() private _ranges?: DateRangePickerRanges; - private _fetchUserPromise?: Promise; - - @state() private _userIdToName = {}; - - @state() private _traceContexts: TraceContexts = {}; - public constructor() { super(); const start = new Date(); start.setHours(start.getHours() - 2, 0, 0, 0); - this._startDate = start; const end = new Date(); end.setHours(end.getHours() + 1, 0, 0, 0); - this._endDate = end; + + this._time = { range: [start, end] }; } protected render() { @@ -91,19 +70,15 @@ export class HaPanelLogbook extends LitElement { @click=${this._refreshLogbook} .path=${mdiRefresh} .label=${this.hass!.localize("ui.common.refresh")} - .disabled=${this._isLoading} > - ${this._isLoading ? html`` : ""} -
@@ -114,38 +89,27 @@ export class HaPanelLogbook extends LitElement { .label=${this.hass.localize( "ui.components.entity.entity-picker.entity" )} - .disabled=${this._isLoading} + .entityFilter=${this._entityFilter} @change=${this._entityPicked} >
- ${this._isLoading - ? html` -
- -
- ` - : html` - - `} + `; } - protected firstUpdated(changedProps: PropertyValues) { - super.firstUpdated(changedProps); - this.hass.loadBackendTranslation("title"); + protected willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); - this._fetchUserPromise = this._fetchUserNames(); + if (this.hasUpdated) { + return; + } const today = new Date(); const weekStart = startOfWeek(today); @@ -164,151 +128,125 @@ export class HaPanelLogbook extends LitElement { [addDays(weekStart, -7), addDays(weekEnd, -7)], }; - this._entityId = extractSearchParam("entity_id") ?? ""; - - const startDate = extractSearchParam("start_date"); - if (startDate) { - this._startDate = new Date(startDate); - } - const endDate = extractSearchParam("end_date"); - if (endDate) { - this._endDate = new Date(endDate); - } + this._applyURLParams(); } - protected updated(changedProps: PropertyValues) { - if ( - changedProps.has("_startDate") || - changedProps.has("_endDate") || - changedProps.has("_entityId") - ) { - this._getData(); - } + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + this.hass.loadBackendTranslation("title"); + } + public connectedCallback(): void { + super.connectedCallback(); + window.addEventListener("location-changed", this._locationChanged); + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener("location-changed", this._locationChanged); + } + + private _locationChanged = () => { + this._applyURLParams(); + }; + + protected updated(changedProps: PropertyValues) { if (changedProps.has("hass")) { const oldHass = changedProps.get("hass") as HomeAssistant | undefined; if (!oldHass || oldHass.language !== this.hass.language) { this.rtl = computeRTL(this.hass); } } + + this._applyURLParams(); } - private async _fetchUserNames() { - const userIdToName = {}; + private _applyURLParams() { + const searchParams = new URLSearchParams(location.search); - // Start loading users - const userProm = this.hass.user?.is_admin && fetchUsers(this.hass); - - // Process persons - Object.values(this.hass.states).forEach((entity) => { - if ( - entity.attributes.user_id && - computeStateDomain(entity) === "person" - ) { - this._userIdToName[entity.attributes.user_id] = - entity.attributes.friendly_name; - } - }); - - // Process users - if (userProm) { - const users = await userProm; - for (const user of users) { - if (!(user.id in userIdToName)) { - userIdToName[user.id] = user.name; - } - } + if (searchParams.has("entity_id")) { + this._entityId = searchParams.get("entity_id") ?? ""; } - this._userIdToName = userIdToName; + const startDateStr = searchParams.get("start_date"); + const endDateStr = searchParams.get("end_date"); + + if (startDateStr || endDateStr) { + const startDate = startDateStr + ? new Date(startDateStr) + : this._time.range[0]; + const endDate = endDateStr ? new Date(endDateStr) : this._time.range[1]; + + // Only set if date has changed. + if ( + startDate.getTime() !== this._time.range[0].getTime() || + endDate.getTime() !== this._time.range[1].getTime() + ) { + this._time = { + range: [ + startDateStr ? new Date(startDateStr) : this._time.range[0], + endDateStr ? new Date(endDateStr) : this._time.range[1], + ], + }; + } + } } private _dateRangeChanged(ev) { - this._startDate = ev.detail.startDate; + const startDate = ev.detail.startDate; const endDate = ev.detail.endDate; if (endDate.getHours() === 0 && endDate.getMinutes() === 0) { endDate.setDate(endDate.getDate() + 1); endDate.setMilliseconds(endDate.getMilliseconds() - 1); } - this._endDate = endDate; - - this._updatePath(); + this._time = { range: [startDate, endDate] }; + this._updatePath({ + start_date: this._time.range[0].toISOString(), + end_date: this._time.range[1].toISOString(), + }); } private _entityPicked(ev) { this._entityId = ev.target.value; - - this._updatePath(); + this._updatePath({ entity_id: this._entityId }); } - private _updatePath() { - const params: Record = {}; - - if (this._entityId) { - params.entity_id = this._entityId; + private _updatePath(update: Record) { + const params = extractSearchParamsObject(); + for (const [key, value] of Object.entries(update)) { + if (value === undefined) { + delete params[key]; + } else { + params[key] = value; + } } - - if (this._startDate) { - params.start_date = this._startDate.toISOString(); - } - - if (this._endDate) { - params.end_date = this._endDate.toISOString(); - } - navigate(`/logbook?${createSearchParam(params)}`, { replace: true }); } private _refreshLogbook() { - this._entries = []; - clearLogbookCache( - this._startDate.toISOString(), - this._endDate.toISOString() - ); - this._getData(); + this.shadowRoot!.querySelector("ha-logbook")?.refresh(); } - private async _getData() { - this._isLoading = true; - let entries; - let traceContexts; - - try { - [entries, traceContexts] = await Promise.all([ - getLogbookData( - this.hass, - this._startDate.toISOString(), - this._endDate.toISOString(), - this._entityId - ), - isComponentLoaded(this.hass, "trace") && this.hass.user?.is_admin - ? loadTraceContexts(this.hass) - : {}, - this._fetchUserPromise, - ]); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize("ui.components.logbook.retrieval_error"), - text: err.message, - }); + private _entityFilter: HaEntityPickerEntityFilterFunc = (entity) => { + if (computeStateDomain(entity) !== "sensor") { + return true; } - this._entries = entries; - this._traceContexts = traceContexts; - this._isLoading = false; - } + return ( + entity.attributes.unit_of_measurement === undefined && + entity.attributes.state_class === undefined + ); + }; static get styles() { return [ haStyle, css` - ha-logbook, - .progress-wrapper { + ha-logbook { height: calc(100vh - 136px); } - :host([narrow]) ha-logbook, - :host([narrow]) .progress-wrapper { + :host([narrow]) ha-logbook { height: calc(100vh - 198px); } @@ -321,17 +259,6 @@ export class HaPanelLogbook extends LitElement { margin-right: 0; } - .progress-wrapper { - position: relative; - } - - ha-circular-progress { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - } - .filters { display: flex; align-items: flex-end; diff --git a/src/panels/lovelace/cards/hui-logbook-card.ts b/src/panels/lovelace/cards/hui-logbook-card.ts index 3adf6f40e8..963644fdf8 100644 --- a/src/panels/lovelace/cards/hui-logbook-card.ts +++ b/src/panels/lovelace/cards/hui-logbook-card.ts @@ -9,15 +9,11 @@ import { import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; -import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; -import { throttle } from "../../../common/util/throttle"; import "../../../components/ha-card"; -import "../../../components/ha-circular-progress"; -import { fetchUsers } from "../../../data/user"; -import { getLogbookData, LogbookEntry } from "../../../data/logbook"; import type { HomeAssistant } from "../../../types"; import "../../logbook/ha-logbook"; +import type { HaLogbook } from "../../logbook/ha-logbook"; import { findEntities } from "../common/find-entities"; import { processConfigEntities } from "../common/process-config-entities"; import "../components/hui-warning"; @@ -56,21 +52,9 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { @state() private _config?: LogbookCardConfig; - @state() private _logbookEntries?: LogbookEntry[]; + @state() private _time?: HaLogbook["time"]; - @state() private _configEntities?: EntityConfig[]; - - @state() private _userIdToName = {}; - - private _lastLogbookDate?: Date; - - private _fetchUserPromise?: Promise; - - private _error?: string; - - private _throttleGetLogbookEntries = throttle(() => { - this._getLogBookData(); - }, 10000); + @state() private _entityId?: string[]; public getCardSize(): number { return 9 + (this._config?.title ? 1 : 0); @@ -81,45 +65,16 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { throw new Error("Entities must be specified"); } - this._configEntities = processConfigEntities(config.entities); - this._config = { hours_to_show: 24, ...config, }; - } - - protected shouldUpdate(changedProps: PropertyValues): boolean { - if ( - changedProps.has("_config") || - changedProps.has("_persons") || - changedProps.has("_logbookEntries") - ) { - return true; - } - - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - - if ( - !this._configEntities || - !oldHass || - oldHass.themes !== this.hass!.themes || - oldHass.locale !== this.hass!.locale - ) { - return true; - } - - for (const entity of this._configEntities) { - if (oldHass.states[entity.entity] !== this.hass!.states[entity.entity]) { - return true; - } - } - - return false; - } - - protected firstUpdated(): void { - this._fetchUserPromise = this._fetchUserNames(); + this._time = { + recent: this._config!.hours_to_show! * 60 * 60 * 1000, + }; + this._entityId = processConfigEntities(config.entities).map( + (entity) => entity.entity + ); } protected updated(changedProperties: PropertyValues) { @@ -139,33 +94,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { ) { applyThemesOnElement(this, this.hass.themes, this._config.theme); } - - if ( - configChanged && - (oldConfig?.entities !== this._config.entities || - oldConfig?.hours_to_show !== this._config!.hours_to_show) - ) { - this._logbookEntries = undefined; - this._lastLogbookDate = undefined; - - if (!this._configEntities) { - return; - } - - this._throttleGetLogbookEntries(); - return; - } - - if ( - oldHass && - this._configEntities!.some( - (entity) => - oldHass.states[entity.entity] !== this.hass!.states[entity.entity] - ) - ) { - // wait for commit of data (we only account for the default setting of 1 sec) - setTimeout(this._throttleGetLogbookEntries, 1000); - } } protected render(): TemplateResult { @@ -189,116 +117,19 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { class=${classMap({ "no-header": !this._config!.title })} >
- ${this._error - ? html` -
- ${`${this.hass.localize( - "ui.components.logbook.retrieval_error" - )}: ${this._error}`} -
- ` - : !this._logbookEntries - ? html` - - ` - : this._logbookEntries.length - ? html` - - ` - : html` -
- ${this.hass.localize( - "ui.components.logbook.entries_not_found" - )} -
- `} +
`; } - private async _getLogBookData() { - if ( - !this.hass || - !this._config || - !isComponentLoaded(this.hass, "logbook") - ) { - return; - } - - const hoursToShowDate = new Date( - new Date().getTime() - this._config!.hours_to_show! * 60 * 60 * 1000 - ); - const lastDate = this._lastLogbookDate || hoursToShowDate; - const now = new Date(); - let newEntries: LogbookEntry[]; - - try { - [newEntries] = await Promise.all([ - getLogbookData( - this.hass, - lastDate.toISOString(), - now.toISOString(), - this._configEntities!.map((entity) => entity.entity).toString() - ), - this._fetchUserPromise, - ]); - } catch (err: any) { - this._error = err.message; - return; - } - - const logbookEntries = this._logbookEntries - ? [...newEntries, ...this._logbookEntries] - : newEntries; - - this._logbookEntries = logbookEntries.filter( - (logEntry) => new Date(logEntry.when * 1000) > hoursToShowDate - ); - - this._lastLogbookDate = now; - } - - private async _fetchUserNames() { - const userIdToName = {}; - - // Start loading users - const userProm = this.hass.user?.is_admin && fetchUsers(this.hass); - - // Process persons - Object.values(this.hass!.states).forEach((entity) => { - if ( - entity.attributes.user_id && - computeStateDomain(entity) === "person" - ) { - this._userIdToName[entity.attributes.user_id] = - entity.attributes.friendly_name; - } - }); - - // Process users - if (userProm) { - const users = await userProm; - for (const user of users) { - if (!(user.id in userIdToName)) { - userIdToName[user.id] = user.name; - } - } - } - - this._userIdToName = userIdToName; - } - static get styles(): CSSResultGroup { return [ css` @@ -317,21 +148,10 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { padding-top: 16px; } - .no-entries { - text-align: center; - padding: 16px; - color: var(--secondary-text-color); - } - ha-logbook { height: 385px; display: block; } - - ha-circular-progress { - display: flex; - justify-content: center; - } `, ]; } From c37e1f0c9d482d29755b7486fb0be4f7c6aad5f1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 May 2022 11:02:23 -0700 Subject: [PATCH 036/112] Add logbook to device info page (#12714) --- .../config/devices/ha-config-device-page.ts | 34 ++++++++++++++ src/panels/logbook/ha-logbook.ts | 44 ++++++++++++------- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts index 91863e1af1..72aec6c243 100644 --- a/src/panels/config/devices/ha-config-device-page.ts +++ b/src/panels/config/devices/ha-config-device-page.ts @@ -63,6 +63,7 @@ import { loadDeviceRegistryDetailDialog, showDeviceRegistryDetailDialog, } from "./device-registry-detail/show-dialog-device-registry-detail"; +import "../../logbook/ha-logbook"; export interface EntityRegistryStateEntry extends EntityRegistryEntry { stateName?: string | null; @@ -99,6 +100,8 @@ export class HaConfigDevicePage extends LitElement { @state() private _deleteButtons?: (TemplateResult | string)[]; + private _logbookTime = { recent: 86400 }; + private _device = memoizeOne( ( deviceId: string, @@ -131,6 +134,11 @@ export class HaConfigDevicePage extends LitElement { ) ); + private _entityIds = memoizeOne( + (entries: EntityRegistryStateEntry[]): string[] => + entries.map((entry) => entry.entity_id) + ); + private _entitiesByCategory = memoizeOne( (entities: EntityRegistryEntry[]) => { const result = groupBy(entities, (entry) => @@ -574,6 +582,25 @@ export class HaConfigDevicePage extends LitElement { ` : "" )} + ${ + isComponentLoaded(this.hass, "logbook") + ? html` + +

+ ${this.hass.localize("panel.logbook")} +

+ +
+ ` + : "" + }
${ @@ -1228,6 +1255,13 @@ export class HaConfigDevicePage extends LitElement { .items { padding-bottom: 16px; } + + ha-logbook { + height: 400px; + } + :host([narrow]) ha-logbook { + height: 235px; + } `, ]; } diff --git a/src/panels/logbook/ha-logbook.ts b/src/panels/logbook/ha-logbook.ts index c797249032..8d3da6286a 100644 --- a/src/panels/logbook/ha-logbook.ts +++ b/src/panels/logbook/ha-logbook.ts @@ -173,25 +173,34 @@ export class HaLogbook extends LitElement { endTime = new Date(); } - this._updateUsers(); - if (this.hass.user?.is_admin) { - this._updateTraceContexts(); - } + const entityIdFilter = this.entityId + ? ensureArray(this.entityId) + : undefined; let newEntries: LogbookEntry[]; - try { - newEntries = await getLogbookData( - this.hass, - startTime.toISOString(), - endTime.toISOString(), - this.entityId ? ensureArray(this.entityId).toString() : undefined - ); - } catch (err: any) { - if (renderId === this._renderId) { - this._error = err.message; + if (entityIdFilter?.length === 0) { + // filtering by 0 entities, means we never can have any results + newEntries = []; + } else { + this._updateUsers(); + if (this.hass.user?.is_admin) { + this._updateTraceContexts(); + } + + try { + newEntries = await getLogbookData( + this.hass, + startTime.toISOString(), + endTime.toISOString(), + entityIdFilter ? entityIdFilter.toString() : undefined + ); + } catch (err: any) { + if (renderId === this._renderId) { + this._error = err.message; + } + return; } - return; } // New render happening. @@ -243,8 +252,11 @@ export class HaLogbook extends LitElement { static get styles() { return [ css` - :host([virtualize]) { + :host { display: block; + } + + :host([virtualize]) { height: 100%; } From 5fb15042118684bdc76e4d98c2f0d6a2128feac0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 May 2022 12:20:49 -0700 Subject: [PATCH 037/112] Add logbook to area info page (#12715) --- .../config/areas/ha-config-area-page.ts | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/panels/config/areas/ha-config-area-page.ts b/src/panels/config/areas/ha-config-area-page.ts index 0f3e37a45d..ea31eb726d 100644 --- a/src/panels/config/areas/ha-config-area-page.ts +++ b/src/panels/config/areas/ha-config-area-page.ts @@ -16,6 +16,7 @@ import { afterNextRender } from "../../../common/util/render-status"; import "../../../components/ha-card"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-next"; +import "../../logbook/ha-logbook"; import { AreaRegistryEntry, deleteAreaRegistryEntry, @@ -72,6 +73,8 @@ class HaConfigAreaPage extends LitElement { @state() private _related?: RelatedResult; + private _logbookTime = { recent: 86400 }; + private _area = memoizeOne( ( areaId: string, @@ -115,6 +118,16 @@ class HaConfigAreaPage extends LitElement { } ); + private _allEntities = memoizeOne( + (memberships: { + entities: EntityRegistryEntry[]; + indirectEntities: EntityRegistryEntry[]; + }) => + memberships.entities + .map((entry) => entry.entity_id) + .concat(memberships.indirectEntities.map((entry) => entry.entity_id)) + ); + protected firstUpdated(changedProps) { super.firstUpdated(changedProps); loadAreaRegistryDetailDialog(); @@ -139,11 +152,12 @@ class HaConfigAreaPage extends LitElement { `; } - const { devices, entities } = this._memberships( + const memberships = this._memberships( this.areaId, this.devices, this.entities ); + const { devices, entities } = memberships; // Pre-compute the entity and device names, so we can sort by them if (devices) { @@ -359,8 +373,6 @@ class HaConfigAreaPage extends LitElement { ` : ""} -
-
${isComponentLoaded(this.hass, "scene") ? html` +
+ ${isComponentLoaded(this.hass, "logbook") + ? html` + + + + ` + : ""} +
`; @@ -699,6 +730,13 @@ class HaConfigAreaPage extends LitElement { opacity: 0.5; border-radius: 50%; } + ha-logbook { + height: 800px; + } + :host([narrow]) ha-logbook { + height: 400px; + overflow: auto; + } `, ]; } From d32f84f28d1e1d66609034b9cd7fef3c6abcc8c0 Mon Sep 17 00:00:00 2001 From: Patrick ZAJDA Date: Wed, 18 May 2022 18:17:31 +0200 Subject: [PATCH 038/112] Add missing labels in energy dashboard settings (#12722) Signed-off-by: Patrick ZAJDA --- .../components/ha-energy-battery-settings.ts | 6 ++++++ .../energy/components/ha-energy-gas-settings.ts | 6 ++++++ .../energy/components/ha-energy-grid-settings.ts | 15 +++++++++++++++ .../energy/components/ha-energy-solar-settings.ts | 6 ++++++ src/translations/en.json | 11 +++++++++++ 5 files changed, 44 insertions(+) diff --git a/src/panels/config/energy/components/ha-energy-battery-settings.ts b/src/panels/config/energy/components/ha-energy-battery-settings.ts index 121ec20697..6879a34c96 100644 --- a/src/panels/config/energy/components/ha-energy-battery-settings.ts +++ b/src/panels/config/energy/components/ha-energy-battery-settings.ts @@ -107,10 +107,16 @@ export class EnergyBatterySettings extends LitElement { >
diff --git a/src/panels/config/energy/components/ha-energy-gas-settings.ts b/src/panels/config/energy/components/ha-energy-gas-settings.ts index 20d18b6fa7..f771582229 100644 --- a/src/panels/config/energy/components/ha-energy-gas-settings.ts +++ b/src/panels/config/energy/components/ha-energy-gas-settings.ts @@ -94,10 +94,16 @@ export class EnergyGasSettings extends LitElement { : source.stat_energy_from} diff --git a/src/panels/config/energy/components/ha-energy-grid-settings.ts b/src/panels/config/energy/components/ha-energy-grid-settings.ts index e903805a4f..e5d5247f71 100644 --- a/src/panels/config/energy/components/ha-energy-grid-settings.ts +++ b/src/panels/config/energy/components/ha-energy-grid-settings.ts @@ -132,10 +132,16 @@ export class EnergyGridSettings extends LitElement { : flow.stat_energy_from} @@ -171,10 +177,16 @@ export class EnergyGridSettings extends LitElement { : flow.stat_energy_to} @@ -212,6 +224,9 @@ export class EnergyGridSettings extends LitElement { diff --git a/src/panels/config/energy/components/ha-energy-solar-settings.ts b/src/panels/config/energy/components/ha-energy-solar-settings.ts index 87582644b5..e8d0093299 100644 --- a/src/panels/config/energy/components/ha-energy-solar-settings.ts +++ b/src/panels/config/energy/components/ha-energy-solar-settings.ts @@ -104,12 +104,18 @@ export class EnergySolarSettings extends LitElement { ${this.info ? html` ` : ""} diff --git a/src/translations/en.json b/src/translations/en.json index 33358a964b..f51e117884 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1308,10 +1308,15 @@ "sub": "Configure the amount of energy that you consume from the grid and, if you produce energy, give back to the grid. This allows Home Assistant to track your whole home energy usage.", "learn_more": "More information on how to get started.", "grid_consumption": "Grid consumption", + "edit_consumption": "Edit consumption", + "delete_consumption": "Delete consumption", "add_consumption": "Add consumption", "return_to_grid": "Return to grid", + "edit_return": "Edit return", + "delete_return": "Delete return", "add_return": "Add return", "grid_carbon_footprint": "Grid carbon footprint", + "remove_co2_signal": "Remove CO2 signal integration", "add_co2_signal": "Add CO2 signal integration", "flow_dialog": { "from": { @@ -1349,6 +1354,8 @@ "sub": "Let Home Assistant monitor your solar panels and give you insight on their performance.", "learn_more": "More information on how to get started.", "solar_production": "Solar production", + "edit_solar_production": "Edit solar production", + "delete_solar_production": "Delete solar production", "add_solar_production": "Add solar production", "stat_production": "Your solar energy production", "stat_return_to_grid": "Solar energy returned to the grid", @@ -1368,6 +1375,8 @@ "sub": "If you have a battery system, you can configure it to monitor how much energy was stored and used from your battery.", "learn_more": "More information on how to get started.", "battery_systems": "Battery systems", + "edit_battery_system": "Edit battery system", + "delete_battery_system": "Delete battery system", "add_battery_system": "Add battery system", "dialog": { "header": "Configure battery system", @@ -1380,6 +1389,8 @@ "sub": "Let Home Assistant monitor your gas usage.", "learn_more": "More information on how to get started.", "gas_consumption": "Gas consumption", + "edit_gas_source": "Edit gas source", + "delete_gas_source": "Delete gas source", "add_gas_source": "Add gas source", "dialog": { "header": "Configure gas consumption", From 4cfb6713cb122b30350d3f787b036bc555ca8958 Mon Sep 17 00:00:00 2001 From: Steve Repsher Date: Wed, 18 May 2022 12:18:22 -0400 Subject: [PATCH 039/112] Delete focus targets for replaced dialogs (#12724) --- src/dialogs/make-dialog-manager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dialogs/make-dialog-manager.ts b/src/dialogs/make-dialog-manager.ts index c3fdb750f0..3d90a421f6 100644 --- a/src/dialogs/make-dialog-manager.ts +++ b/src/dialogs/make-dialog-manager.ts @@ -86,6 +86,7 @@ export const showDialog = async ( if (mainWindow.history.state?.replaced) { LOADED[dialogTag].closedFocusTargets = LOADED[mainWindow.history.state.dialog].closedFocusTargets; + delete LOADED[mainWindow.history.state.dialog].closedFocusTargets; } else { LOADED[dialogTag].closedFocusTargets = ancestorsWithProperty( deepActiveElement(), From f807618f758a8315b8a9c330b2206803db045422 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 May 2022 12:20:38 -0500 Subject: [PATCH 040/112] Convert history calls to use new websocket endpoint (#12662) --- src/common/entity/compute_state_display.ts | 119 +++++++-------- src/common/entity/compute_state_name.ts | 12 +- src/common/entity/supports-feature.ts | 9 +- src/common/number/format_number.ts | 7 +- src/data/cached-history.ts | 10 +- src/data/history.ts | 163 +++++++++++++++------ src/data/update.ts | 19 ++- src/panels/history/ha-panel-history.ts | 4 +- 8 files changed, 220 insertions(+), 123 deletions(-) diff --git a/src/common/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index d796a80e64..bc7e1de564 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -2,67 +2,74 @@ import { HassEntity } from "home-assistant-js-websocket"; import { UNAVAILABLE, UNKNOWN } from "../../data/entity"; import { FrontendLocaleData } from "../../data/translation"; import { - updateIsInstalling, - UpdateEntity, UPDATE_SUPPORT_PROGRESS, + updateIsInstallingFromAttributes, } from "../../data/update"; import { formatDate } from "../datetime/format_date"; import { formatDateTime } from "../datetime/format_date_time"; import { formatTime } from "../datetime/format_time"; -import { formatNumber, isNumericState } from "../number/format_number"; +import { formatNumber, isNumericFromAttributes } from "../number/format_number"; import { LocalizeFunc } from "../translations/localize"; -import { computeStateDomain } from "./compute_state_domain"; -import { supportsFeature } from "./supports-feature"; +import { supportsFeatureFromAttributes } from "./supports-feature"; import { formatDuration, UNIT_TO_SECOND_CONVERT } from "../datetime/duration"; +import { computeDomain } from "./compute_domain"; export const computeStateDisplay = ( localize: LocalizeFunc, stateObj: HassEntity, locale: FrontendLocaleData, state?: string -): string => { - const compareState = state !== undefined ? state : stateObj.state; +): string => + computeStateDisplayFromEntityAttributes( + localize, + locale, + stateObj.entity_id, + stateObj.attributes, + state !== undefined ? state : stateObj.state + ); - if (compareState === UNKNOWN || compareState === UNAVAILABLE) { - return localize(`state.default.${compareState}`); +export const computeStateDisplayFromEntityAttributes = ( + localize: LocalizeFunc, + locale: FrontendLocaleData, + entityId: string, + attributes: any, + state: string +): string => { + if (state === UNKNOWN || state === UNAVAILABLE) { + return localize(`state.default.${state}`); } // Entities with a `unit_of_measurement` or `state_class` are numeric values and should use `formatNumber` - if (isNumericState(stateObj)) { + if (isNumericFromAttributes(attributes)) { // state is duration if ( - stateObj.attributes.device_class === "duration" && - stateObj.attributes.unit_of_measurement && - UNIT_TO_SECOND_CONVERT[stateObj.attributes.unit_of_measurement] + attributes.device_class === "duration" && + attributes.unit_of_measurement && + UNIT_TO_SECOND_CONVERT[attributes.unit_of_measurement] ) { try { - return formatDuration( - compareState, - stateObj.attributes.unit_of_measurement - ); + return formatDuration(state, attributes.unit_of_measurement); } catch (_err) { // fallback to default } } - if (stateObj.attributes.device_class === "monetary") { + if (attributes.device_class === "monetary") { try { - return formatNumber(compareState, locale, { + return formatNumber(state, locale, { style: "currency", - currency: stateObj.attributes.unit_of_measurement, + currency: attributes.unit_of_measurement, minimumFractionDigits: 2, }); } catch (_err) { // fallback to default } } - return `${formatNumber(compareState, locale)}${ - stateObj.attributes.unit_of_measurement - ? " " + stateObj.attributes.unit_of_measurement - : "" + return `${formatNumber(state, locale)}${ + attributes.unit_of_measurement ? " " + attributes.unit_of_measurement : "" }`; } - const domain = computeStateDomain(stateObj); + const domain = computeDomain(entityId); if (domain === "input_datetime") { if (state !== undefined) { @@ -97,36 +104,32 @@ export const computeStateDisplay = ( } else { // If not trying to display an explicit state, create `Date` object from `stateObj`'s attributes then format. let date: Date; - if (stateObj.attributes.has_date && stateObj.attributes.has_time) { + if (attributes.has_date && attributes.has_time) { date = new Date( - stateObj.attributes.year, - stateObj.attributes.month - 1, - stateObj.attributes.day, - stateObj.attributes.hour, - stateObj.attributes.minute + attributes.year, + attributes.month - 1, + attributes.day, + attributes.hour, + attributes.minute ); return formatDateTime(date, locale); } - if (stateObj.attributes.has_date) { - date = new Date( - stateObj.attributes.year, - stateObj.attributes.month - 1, - stateObj.attributes.day - ); + if (attributes.has_date) { + date = new Date(attributes.year, attributes.month - 1, attributes.day); return formatDate(date, locale); } - if (stateObj.attributes.has_time) { + if (attributes.has_time) { date = new Date(); - date.setHours(stateObj.attributes.hour, stateObj.attributes.minute); + date.setHours(attributes.hour, attributes.minute); return formatTime(date, locale); } - return stateObj.state; + return state; } } if (domain === "humidifier") { - if (compareState === "on" && stateObj.attributes.humidity) { - return `${stateObj.attributes.humidity} %`; + if (state === "on" && attributes.humidity) { + return `${attributes.humidity} %`; } } @@ -136,7 +139,7 @@ export const computeStateDisplay = ( domain === "number" || domain === "input_number" ) { - return formatNumber(compareState, locale); + return formatNumber(state, locale); } // state of button is a timestamp @@ -144,12 +147,12 @@ export const computeStateDisplay = ( domain === "button" || domain === "input_button" || domain === "scene" || - (domain === "sensor" && stateObj.attributes.device_class === "timestamp") + (domain === "sensor" && attributes.device_class === "timestamp") ) { try { - return formatDateTime(new Date(compareState), locale); + return formatDateTime(new Date(state), locale); } catch (_err) { - return compareState; + return state; } } @@ -160,30 +163,28 @@ export const computeStateDisplay = ( // When the latest version is skipped, show the latest version // When update is not available, show "Up-to-date" // When update is not available and there is no latest_version show "Unavailable" - return compareState === "on" - ? updateIsInstalling(stateObj as UpdateEntity) - ? supportsFeature(stateObj, UPDATE_SUPPORT_PROGRESS) + return state === "on" + ? updateIsInstallingFromAttributes(attributes) + ? supportsFeatureFromAttributes(attributes, UPDATE_SUPPORT_PROGRESS) ? localize("ui.card.update.installing_with_progress", { - progress: stateObj.attributes.in_progress, + progress: attributes.in_progress, }) : localize("ui.card.update.installing") - : stateObj.attributes.latest_version - : stateObj.attributes.skipped_version === - stateObj.attributes.latest_version - ? stateObj.attributes.latest_version ?? - localize("state.default.unavailable") + : attributes.latest_version + : attributes.skipped_version === attributes.latest_version + ? attributes.latest_version ?? localize("state.default.unavailable") : localize("ui.card.update.up_to_date"); } return ( // Return device class translation - (stateObj.attributes.device_class && + (attributes.device_class && localize( - `component.${domain}.state.${stateObj.attributes.device_class}.${compareState}` + `component.${domain}.state.${attributes.device_class}.${state}` )) || // Return default translation - localize(`component.${domain}.state._.${compareState}`) || + localize(`component.${domain}.state._.${state}`) || // We don't know! Return the raw state. - compareState + state ); }; diff --git a/src/common/entity/compute_state_name.ts b/src/common/entity/compute_state_name.ts index 34892ae5a1..2311830a9c 100644 --- a/src/common/entity/compute_state_name.ts +++ b/src/common/entity/compute_state_name.ts @@ -1,7 +1,13 @@ import { HassEntity } from "home-assistant-js-websocket"; import { computeObjectId } from "./compute_object_id"; +export const computeStateNameFromEntityAttributes = ( + entityId: string, + attributes: { [key: string]: any } +): string => + attributes.friendly_name === undefined + ? computeObjectId(entityId).replace(/_/g, " ") + : attributes.friendly_name || ""; + export const computeStateName = (stateObj: HassEntity): string => - stateObj.attributes.friendly_name === undefined - ? computeObjectId(stateObj.entity_id).replace(/_/g, " ") - : stateObj.attributes.friendly_name || ""; + computeStateNameFromEntityAttributes(stateObj.entity_id, stateObj.attributes); diff --git a/src/common/entity/supports-feature.ts b/src/common/entity/supports-feature.ts index 1b1c79b518..18bf3cd891 100644 --- a/src/common/entity/supports-feature.ts +++ b/src/common/entity/supports-feature.ts @@ -3,6 +3,13 @@ import { HassEntity } from "home-assistant-js-websocket"; export const supportsFeature = ( stateObj: HassEntity, feature: number +): boolean => supportsFeatureFromAttributes(stateObj.attributes, feature); + +export const supportsFeatureFromAttributes = ( + attributes: { + [key: string]: any; + }, + feature: number ): boolean => // eslint-disable-next-line no-bitwise - (stateObj.attributes.supported_features! & feature) !== 0; + (attributes.supported_features! & feature) !== 0; diff --git a/src/common/number/format_number.ts b/src/common/number/format_number.ts index 7cecd1c514..9c4d9e24ce 100644 --- a/src/common/number/format_number.ts +++ b/src/common/number/format_number.ts @@ -7,8 +7,11 @@ import { round } from "./round"; * @param stateObj The entity state object */ export const isNumericState = (stateObj: HassEntity): boolean => - !!stateObj.attributes.unit_of_measurement || - !!stateObj.attributes.state_class; + isNumericFromAttributes(stateObj.attributes); + +export const isNumericFromAttributes = (attributes: { + [key: string]: any; +}): boolean => !!attributes.unit_of_measurement || !!attributes.state_class; export const numberFormatToLocale = ( localeOptions: FrontendLocaleData diff --git a/src/data/cached-history.ts b/src/data/cached-history.ts index a75f9fd2f0..595c30af7b 100644 --- a/src/data/cached-history.ts +++ b/src/data/cached-history.ts @@ -1,13 +1,13 @@ -import { HassEntity } from "home-assistant-js-websocket"; import { LocalizeFunc } from "../common/translations/localize"; import { HomeAssistant } from "../types"; import { computeHistory, - fetchRecent, + HistoryStates, HistoryResult, LineChartUnit, TimelineEntity, entityIdHistoryNeedsAttributes, + fetchRecentWS, } from "./history"; export interface CacheConfig { @@ -55,7 +55,7 @@ export const getRecent = ( } const noAttributes = !entityIdHistoryNeedsAttributes(hass, entityId); - const prom = fetchRecent( + const prom = fetchRecentWS( hass, entityId, startTime, @@ -134,12 +134,12 @@ export const getRecentWithCache = ( const noAttributes = !entityIdHistoryNeedsAttributes(hass, entityId); const genProm = async () => { - let fetchedHistory: HassEntity[][]; + let fetchedHistory: HistoryStates; try { const results = await Promise.all([ curCacheProm, - fetchRecent( + fetchRecentWS( hass, entityId, toFetchStartTime, diff --git a/src/data/history.ts b/src/data/history.ts index a631c3423a..3ac791c790 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -1,8 +1,7 @@ import { HassEntity } from "home-assistant-js-websocket"; import { computeDomain } from "../common/entity/compute_domain"; -import { computeStateDisplay } from "../common/entity/compute_state_display"; -import { computeStateDomain } from "../common/entity/compute_state_domain"; -import { computeStateName } from "../common/entity/compute_state_name"; +import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display"; +import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name"; import { LocalizeFunc } from "../common/translations/localize"; import { HomeAssistant } from "../types"; import { FrontendLocaleData } from "./translation"; @@ -27,7 +26,7 @@ const LINE_ATTRIBUTES_TO_KEEP = [ export interface LineChartState { state: string; - last_changed: string; + last_changed: number; attributes?: Record; } @@ -47,7 +46,7 @@ export interface LineChartUnit { export interface TimelineState { state_localize: string; state: string; - last_changed: string; + last_changed: number; } export interface TimelineEntity { @@ -141,6 +140,21 @@ export interface StatisticsValidationResults { [statisticId: string]: StatisticsValidationResult[]; } +export interface HistoryStates { + [entityId: string]: EntityHistoryState[]; +} + +interface EntityHistoryState { + /** state */ + s: string; + /** attributes */ + a: { [key: string]: any }; + /** last_changed; if set, also applies to lu */ + lc: number; + /** last_updated */ + lu: number; +} + export const entityIdHistoryNeedsAttributes = ( hass: HomeAssistant, entityId: string @@ -181,6 +195,27 @@ export const fetchRecent = ( return hass.callApi("GET", url); }; +export const fetchRecentWS = ( + hass: HomeAssistant, + entityId: string, + startTime: Date, + endTime: Date, + skipInitialState = false, + significantChangesOnly?: boolean, + minimalResponse = true, + noAttributes?: boolean +) => + hass.callWS({ + type: "history/history_during_period", + start_time: startTime.toISOString(), + end_time: endTime.toISOString(), + significant_changes_only: significantChangesOnly || false, + include_start_time_state: !skipInitialState, + minimal_response: minimalResponse, + no_attributes: noAttributes || false, + entity_ids: [entityId], + }); + export const fetchDate = ( hass: HomeAssistant, startTime: Date, @@ -198,6 +233,27 @@ export const fetchDate = ( }` ); +export const fetchDateWS = ( + hass: HomeAssistant, + startTime: Date, + endTime: Date, + entityId?: string +) => { + const params = { + type: "history/history_during_period", + start_time: startTime.toISOString(), + end_time: endTime.toISOString(), + minimal_response: true, + no_attributes: !!( + entityId && !entityIdHistoryNeedsAttributes(hass, entityId) + ), + }; + if (entityId) { + return hass.callWS({ ...params, entity_ids: [entityId] }); + } + return hass.callWS(params); +}; + const equalState = (obj1: LineChartState, obj2: LineChartState) => obj1.state === obj2.state && // Only compare attributes if both states have an attributes object. @@ -212,46 +268,47 @@ const equalState = (obj1: LineChartState, obj2: LineChartState) => const processTimelineEntity = ( localize: LocalizeFunc, language: FrontendLocaleData, - states: HassEntity[] + entityId: string, + states: EntityHistoryState[] ): TimelineEntity => { const data: TimelineState[] = []; - const last_element = states.length - 1; - + const last: EntityHistoryState = states[states.length - 1]; for (const state of states) { - if (data.length > 0 && state.state === data[data.length - 1].state) { + if (data.length > 0 && state.s === data[data.length - 1].state) { continue; } - - // Copy the data from the last element as its the newest - // and is only needed to localize the data - if (!state.entity_id) { - state.attributes = states[last_element].attributes; - state.entity_id = states[last_element].entity_id; - } - data.push({ - state_localize: computeStateDisplay(localize, state, language), - state: state.state, - last_changed: state.last_changed, + state_localize: computeStateDisplayFromEntityAttributes( + localize, + language, + entityId, + state.a || last.a, + state.s + ), + state: state.s, + // lc (last_changed) may be omitted if its the same + // as lu (last_updated). + last_changed: (state.lc ? state.lc : state.lu) * 1000, }); } return { - name: computeStateName(states[0]), - entity_id: states[0].entity_id, + name: computeStateNameFromEntityAttributes(entityId, states[0].a), + entity_id: entityId, data, }; }; const processLineChartEntities = ( unit, - entities: HassEntity[][] + entities: HistoryStates ): LineChartUnit => { const data: LineChartEntity[] = []; - for (const states of entities) { - const last: HassEntity = states[states.length - 1]; - const domain = computeStateDomain(last); + Object.keys(entities).forEach((entityId) => { + const states = entities[entityId]; + const last: EntityHistoryState = states[states.length - 1]; + const domain = computeDomain(entityId); const processedStates: LineChartState[] = []; for (const state of states) { @@ -259,18 +316,24 @@ const processLineChartEntities = ( if (DOMAINS_USE_LAST_UPDATED.includes(domain)) { processedState = { - state: state.state, - last_changed: state.last_updated, + state: state.s, + last_changed: state.lu * 1000, attributes: {}, }; for (const attr of LINE_ATTRIBUTES_TO_KEEP) { - if (attr in state.attributes) { - processedState.attributes![attr] = state.attributes[attr]; + if (attr in state.a) { + processedState.attributes![attr] = state.a[attr]; } } } else { - processedState = state; + processedState = { + state: state.s, + // lc (last_changed) may be omitted if its the same + // as lu (last_updated). + last_changed: (state.lc ? state.lc : state.lu) * 1000, + attributes: {}, + }; } if ( @@ -289,52 +352,53 @@ const processLineChartEntities = ( data.push({ domain, - name: computeStateName(last), - entity_id: last.entity_id, + name: computeStateNameFromEntityAttributes(entityId, last.a), + entity_id: entityId, states: processedStates, }); - } + }); return { unit, - identifier: entities.map((states) => states[0].entity_id).join(""), + identifier: Object.keys(entities).join(""), data, }; }; const stateUsesUnits = (state: HassEntity) => - "unit_of_measurement" in state.attributes || - "state_class" in state.attributes; + attributesHaveUnits(state.attributes); + +const attributesHaveUnits = (attributes: { [key: string]: any }) => + "unit_of_measurement" in attributes || "state_class" in attributes; export const computeHistory = ( hass: HomeAssistant, - stateHistory: HassEntity[][], + stateHistory: HistoryStates, localize: LocalizeFunc ): HistoryResult => { - const lineChartDevices: { [unit: string]: HassEntity[][] } = {}; + const lineChartDevices: { [unit: string]: HistoryStates } = {}; const timelineDevices: TimelineEntity[] = []; if (!stateHistory) { return { line: [], timeline: [] }; } - - stateHistory.forEach((stateInfo) => { + Object.keys(stateHistory).forEach((entityId) => { + const stateInfo = stateHistory[entityId]; if (stateInfo.length === 0) { return; } - const entityId = stateInfo[0].entity_id; const currentState = entityId in hass.states ? hass.states[entityId] : undefined; const stateWithUnitorStateClass = !currentState && - stateInfo.find((state) => state.attributes && stateUsesUnits(state)); + stateInfo.find((state) => state.a && attributesHaveUnits(state.a)); let unit: string | undefined; if (currentState && stateUsesUnits(currentState)) { unit = currentState.attributes.unit_of_measurement || " "; } else if (stateWithUnitorStateClass) { - unit = stateWithUnitorStateClass.attributes.unit_of_measurement || " "; + unit = stateWithUnitorStateClass.a.unit_of_measurement || " "; } else { unit = { climate: hass.config.unit_system.temperature, @@ -348,12 +412,15 @@ export const computeHistory = ( if (!unit) { timelineDevices.push( - processTimelineEntity(localize, hass.locale, stateInfo) + processTimelineEntity(localize, hass.locale, entityId, stateInfo) ); - } else if (unit in lineChartDevices) { - lineChartDevices[unit].push(stateInfo); + } else if (unit in lineChartDevices && entityId in lineChartDevices[unit]) { + lineChartDevices[unit][entityId].push(...stateInfo); } else { - lineChartDevices[unit] = [stateInfo]; + if (!(unit in lineChartDevices)) { + lineChartDevices[unit] = {}; + } + lineChartDevices[unit][entityId] = stateInfo; } }); diff --git a/src/data/update.ts b/src/data/update.ts index f888823d21..802c1d0554 100644 --- a/src/data/update.ts +++ b/src/data/update.ts @@ -7,7 +7,10 @@ import type { import { BINARY_STATE_ON } from "../common/const"; import { computeDomain } from "../common/entity/compute_domain"; import { computeStateDomain } from "../common/entity/compute_state_domain"; -import { supportsFeature } from "../common/entity/supports-feature"; +import { + supportsFeature, + supportsFeatureFromAttributes, +} from "../common/entity/supports-feature"; import { caseInsensitiveStringCompare } from "../common/string/compare"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { HomeAssistant } from "../types"; @@ -35,8 +38,13 @@ export interface UpdateEntity extends HassEntityBase { } export const updateUsesProgress = (entity: UpdateEntity): boolean => - supportsFeature(entity, UPDATE_SUPPORT_PROGRESS) && - typeof entity.attributes.in_progress === "number"; + updateUsesProgressFromAttributes(entity.attributes); + +export const updateUsesProgressFromAttributes = (attributes: { + [key: string]: any; +}): boolean => + supportsFeatureFromAttributes(attributes, UPDATE_SUPPORT_PROGRESS) && + typeof attributes.in_progress === "number"; export const updateCanInstall = ( entity: UpdateEntity, @@ -49,6 +57,11 @@ export const updateCanInstall = ( export const updateIsInstalling = (entity: UpdateEntity): boolean => updateUsesProgress(entity) || !!entity.attributes.in_progress; +export const updateIsInstallingFromAttributes = (attributes: { + [key: string]: any; +}): boolean => + updateUsesProgressFromAttributes(attributes) || !!attributes.in_progress; + export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) => hass.callWS({ type: "update/release_notes", diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index f47011d22a..c152af1c72 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -25,7 +25,7 @@ import "../../components/ha-date-range-picker"; import type { DateRangePickerRanges } from "../../components/ha-date-range-picker"; import "../../components/ha-icon-button"; import "../../components/ha-menu-button"; -import { computeHistory, fetchDate } from "../../data/history"; +import { computeHistory, fetchDateWS } from "../../data/history"; import "../../layouts/ha-app-layout"; import { haStyle } from "../../resources/styles"; import { HomeAssistant } from "../../types"; @@ -177,7 +177,7 @@ class HaPanelHistory extends LitElement { private async _getHistory() { this._isLoading = true; - const dateHistory = await fetchDate( + const dateHistory = await fetchDateWS( this.hass, this._startDate, this._endDate, From 7d1c77a38fc78b8e6cac1ac301b5a538eacc4bc7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 18 May 2022 11:18:43 -0700 Subject: [PATCH 041/112] Add support for OAuth2 callback via My (#12718) --- src/panels/my/ha-panel-my.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index 8c35aa4420..fd5fa4f260 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -123,6 +123,15 @@ export const getMyRedirects = (hasSupervisor: boolean): Redirects => ({ component: "lovelace", redirect: "/config/lovelace/resources", }, + oauth2_authorize_callback: { + redirect: "/auth/external/callback", + navigate_outside_spa: true, + params: { + error: "string?", + code: "string?", + state: "string", + }, + }, people: { component: "person", redirect: "/config/person", @@ -214,11 +223,13 @@ const getRedirect = ( hasSupervisor: boolean ): Redirect | undefined => getMyRedirects(hasSupervisor)?.[path]; -export type ParamType = "url" | "string"; +export type ParamType = "url" | "string" | "string?"; export type Redirects = { [key: string]: Redirect }; export interface Redirect { redirect: string; + // Set to True to use browser redirect instead of frontend navigation + navigate_outside_spa?: boolean; component?: string; params?: { [key: string]: ParamType; @@ -277,7 +288,11 @@ class HaPanelMy extends LitElement { return; } - navigate(url, { replace: true }); + if (this._redirect.navigate_outside_spa) { + location.assign(url); + } else { + navigate(url, { replace: true }); + } } protected render() { @@ -345,17 +360,20 @@ class HaPanelMy extends LitElement { return ""; } const resultParams = {}; - Object.entries(this._redirect!.params || {}).forEach(([key, type]) => { + for (const [key, type] of Object.entries(this._redirect!.params || {})) { + if (!params[key] && type.endsWith("?")) { + continue; + } if (!params[key] || !this._checkParamType(type, params[key])) { throw Error(); } resultParams[key] = params[key]; - }); + } return `?${createSearchParam(resultParams)}`; } private _checkParamType(type: ParamType, value: string) { - if (type === "string") { + if (type === "string" || type === "string?") { return true; } if (type === "url") { From af6b0d32664f677011ee6b7837f44d30ffaa2e82 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 May 2022 13:37:47 -0500 Subject: [PATCH 042/112] Support requesting translations for multiple integrations in one request (#12704) * Support requesting translations for multiple integrations in one request - Requires https://github.com/home-assistant/core/pull/71979 * onboarding as well * integrations -> integration * fix cache * short return if they are all loaded * reduce * reduce * reduce --- src/data/translation.ts | 2 +- src/onboarding/onboarding-integrations.ts | 4 +++- .../integrations/ha-config-integrations.ts | 11 +++++----- src/state/translations-mixin.ts | 20 +++++++++++++------ 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/data/translation.ts b/src/data/translation.ts index aee999172a..7374f40639 100644 --- a/src/data/translation.ts +++ b/src/data/translation.ts @@ -52,7 +52,7 @@ export const getHassTranslations = async ( hass: HomeAssistant, language: string, category: TranslationCategory, - integration?: string, + integration?: string | string[], config_flow?: boolean ): Promise> => { const result = await hass.callWS<{ resources: Record }>({ diff --git a/src/onboarding/onboarding-integrations.ts b/src/onboarding/onboarding-integrations.ts index 9fc15b0c07..1c21b94008 100644 --- a/src/onboarding/onboarding-integrations.ts +++ b/src/onboarding/onboarding-integrations.ts @@ -49,12 +49,14 @@ class OnboardingIntegrations extends LitElement { this.hass.loadBackendTranslation("title", undefined, true); this._unsubEvents = subscribeConfigFlowInProgress(this.hass, (flows) => { this._discovered = flows; + const integrations: Set = new Set(); for (const flow of flows) { // To render title placeholders if (flow.context.title_placeholders) { - this.hass.loadBackendTranslation("config", flow.handler); + integrations.add(flow.handler); } } + this.hass.loadBackendTranslation("config", Array.from(integrations)); }); } diff --git a/src/panels/config/integrations/ha-config-integrations.ts b/src/panels/config/integrations/ha-config-integrations.ts index c6dbcb1787..60df2ccec4 100644 --- a/src/panels/config/integrations/ha-config-integrations.ts +++ b/src/panels/config/integrations/ha-config-integrations.ts @@ -156,17 +156,18 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { this._deviceRegistryEntries = entries; }), subscribeConfigFlowInProgress(this.hass, async (flowsInProgress) => { - const translationsPromisses: Promise[] = []; + const integrations: Set = new Set(); flowsInProgress.forEach((flow) => { // To render title placeholders if (flow.context.title_placeholders) { - translationsPromisses.push( - this.hass.loadBackendTranslation("config", flow.handler) - ); + integrations.add(flow.handler); } this._fetchManifest(flow.handler); }); - await Promise.all(translationsPromisses); + await this.hass.loadBackendTranslation( + "config", + Array.from(integrations) + ); await nextRender(); this._configEntriesInProgress = flowsInProgress.map((flow) => ({ ...flow, diff --git a/src/state/translations-mixin.ts b/src/state/translations-mixin.ts index 8205817911..d02144ffd0 100644 --- a/src/state/translations-mixin.ts +++ b/src/state/translations-mixin.ts @@ -242,12 +242,22 @@ export default >(superClass: T) => }; } + let integrationsToLoad: string[] = []; + // Check if already loaded if (!force) { - if (integration) { + if (integration && Array.isArray(integration)) { + integrationsToLoad = integration.filter( + (i) => !alreadyLoaded.integrations.includes(i) + ); + if (!integrationsToLoad.length) { + return this.hass!.localize; + } + } else if (integration) { if (alreadyLoaded.integrations.includes(integration)) { return this.hass!.localize; } + integrationsToLoad = [integration]; } else if ( configFlow ? alreadyLoaded.configFlow : alreadyLoaded.setup ) { @@ -256,10 +266,8 @@ export default >(superClass: T) => } // Add to cache - if (integration) { - if (!alreadyLoaded.integrations.includes(integration)) { - alreadyLoaded.integrations.push(integration); - } + if (integrationsToLoad.length) { + alreadyLoaded.integrations.push(...integrationsToLoad); } else { alreadyLoaded.setup = true; if (configFlow) { @@ -271,7 +279,7 @@ export default >(superClass: T) => this.hass!, language, category, - integration, + integrationsToLoad.length ? integrationsToLoad : undefined, configFlow ); From f4f51e1de58a7a2bb0a0621310575f6c631caa20 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 May 2022 14:01:09 -0500 Subject: [PATCH 043/112] Show the integration brand icon when there is no entity in logbook (#12713) --- src/common/entity/domain_icon.ts | 18 ++++++++++-- src/panels/logbook/ha-logbook-renderer.ts | 34 +++++++++++++++++------ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/common/entity/domain_icon.ts b/src/common/entity/domain_icon.ts index d849ae3bfd..51e69a333b 100644 --- a/src/common/entity/domain_icon.ts +++ b/src/common/entity/domain_icon.ts @@ -46,6 +46,20 @@ export const domainIcon = ( stateObj?: HassEntity, state?: string ): string => { + const icon = domainIconWithoutDefault(domain, stateObj, state); + if (icon) { + return icon; + } + // eslint-disable-next-line + console.warn(`Unable to find icon for domain ${domain}`); + return DEFAULT_DOMAIN_ICON; +}; + +export const domainIconWithoutDefault = ( + domain: string, + stateObj?: HassEntity, + state?: string +): string | undefined => { const compareState = state !== undefined ? state : stateObj?.state; switch (domain) { @@ -150,7 +164,5 @@ export const domainIcon = ( return FIXED_DOMAIN_ICONS[domain]; } - // eslint-disable-next-line - console.warn(`Unable to find icon for domain ${domain}`); - return DEFAULT_DOMAIN_ICON; + return undefined; }; diff --git a/src/panels/logbook/ha-logbook-renderer.ts b/src/panels/logbook/ha-logbook-renderer.ts index 4514499dac..d89be02f28 100644 --- a/src/panels/logbook/ha-logbook-renderer.ts +++ b/src/panels/logbook/ha-logbook-renderer.ts @@ -15,7 +15,8 @@ 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 { domainIcon } from "../../common/entity/domain_icon"; +import { domainIconWithoutDefault } from "../../common/entity/domain_icon"; +import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { computeRTL, emitRTLDirection } from "../../common/util/compute_rtl"; import "../../components/entity/state-badge"; import "../../components/ha-circular-progress"; @@ -28,6 +29,7 @@ import { buttonLinkStyle, } from "../../resources/styles"; import { HomeAssistant } from "../../types"; +import { brandsUrl } from "../../util/brands-url"; const EVENT_LOCALIZE_MAP = { script_started: "from_script", @@ -138,6 +140,26 @@ class HaLogbookRenderer extends LitElement { ? computeDomain(item.entity_id) : // Domain is there if there is no entity ID. item.domain!; + const overrideIcon = + item.icon || + (item.domain && !stateObj + ? domainIconWithoutDefault(item.domain!) + : undefined); + const overrideImage = !DOMAINS_WITH_DYNAMIC_PICTURE.has(domain) + ? stateObj?.attributes.entity_picture_local || + stateObj?.attributes.entity_picture || + (!stateObj && + !overrideIcon && + item.domain && + isComponentLoaded(this.hass, item.domain) + ? brandsUrl({ + domain: item.domain!, + type: "icon", + useFallback: true, + darkOptimized: this.hass.themes?.darkMode, + }) + : undefined) + : undefined; return html`
@@ -161,14 +183,8 @@ class HaLogbookRenderer extends LitElement { html` From 2796c3570a51b516f5ba5af09e4b2f8338591c47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 May 2022 14:09:09 -0500 Subject: [PATCH 044/112] Support requesting multiple integration manifests in a single request (#12706) * Support requesting multiple integration manifests in a single request * only fetch if there are some to actually fetch * handle empty * not truthy, wrong language * Do not copy params Co-authored-by: Paulus Schoutsen --- src/data/integration.ts | 14 ++++++- .../integrations/ha-config-integrations.ts | 41 ++++++++++--------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/data/integration.ts b/src/data/integration.ts index f69a763a7c..1f99f5478d 100644 --- a/src/data/integration.ts +++ b/src/data/integration.ts @@ -42,8 +42,18 @@ export const domainToName = ( manifest?: IntegrationManifest ) => localize(`component.${domain}.title`) || manifest?.name || domain; -export const fetchIntegrationManifests = (hass: HomeAssistant) => - hass.callWS({ type: "manifest/list" }); +export const fetchIntegrationManifests = ( + hass: HomeAssistant, + integrations?: string[] +) => { + const params: any = { + type: "manifest/list", + }; + if (integrations) { + params.integrations = integrations; + } + return hass.callWS(params); +}; export const fetchIntegrationManifest = ( hass: HomeAssistant, diff --git a/src/panels/config/integrations/ha-config-integrations.ts b/src/panels/config/integrations/ha-config-integrations.ts index 60df2ccec4..6ae2d3d81f 100644 --- a/src/panels/config/integrations/ha-config-integrations.ts +++ b/src/panels/config/integrations/ha-config-integrations.ts @@ -46,7 +46,6 @@ import { } from "../../../data/entity_registry"; import { domainToName, - fetchIntegrationManifest, fetchIntegrationManifests, IntegrationManifest, } from "../../../data/integration"; @@ -157,17 +156,19 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { }), subscribeConfigFlowInProgress(this.hass, async (flowsInProgress) => { const integrations: Set = new Set(); + const manifests: Set = new Set(); flowsInProgress.forEach((flow) => { // To render title placeholders if (flow.context.title_placeholders) { integrations.add(flow.handler); } - this._fetchManifest(flow.handler); + manifests.add(flow.handler); }); await this.hass.loadBackendTranslation( "config", Array.from(integrations) ); + this._fetchIntegrationManifests(manifests); await nextRender(); this._configEntriesInProgress = flowsInProgress.map((flow) => ({ ...flow, @@ -566,8 +567,8 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { await scanUSBDevices(this.hass); } - private async _fetchManifests() { - const fetched = await fetchIntegrationManifests(this.hass); + private async _fetchManifests(integrations?: string[]) { + const fetched = await fetchIntegrationManifests(this.hass, integrations); // Make a copy so we can keep track of previously loaded manifests // for discovered flows (which are not part of these results) const manifests = { ...this._manifests }; @@ -575,23 +576,25 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { this._manifests = manifests; } - private async _fetchManifest(domain: string) { - if (domain in this._manifests) { - return; - } - if (this._extraFetchedManifests) { - if (this._extraFetchedManifests.has(domain)) { - return; + private async _fetchIntegrationManifests(integrations: Set) { + const manifestsToFetch: string[] = []; + for (const integration of integrations) { + if (integration in this._manifests) { + continue; } - } else { - this._extraFetchedManifests = new Set(); + if (this._extraFetchedManifests) { + if (this._extraFetchedManifests.has(integration)) { + continue; + } + } else { + this._extraFetchedManifests = new Set(); + } + this._extraFetchedManifests.add(integration); + manifestsToFetch.push(integration); + } + if (manifestsToFetch.length) { + await this._fetchManifests(manifestsToFetch); } - this._extraFetchedManifests.add(domain); - const manifest = await fetchIntegrationManifest(this.hass, domain); - this._manifests = { - ...this._manifests, - [domain]: manifest, - }; } private _handleEntryRemoved(ev: HASSDomEvent) { From 82035d587a86e499d1a51fbd3f6baac37ed816f6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 18 May 2022 12:09:25 -0700 Subject: [PATCH 045/112] Import all date-fns from modules (#12717) --- demo/src/stubs/energy.ts | 2 +- demo/src/stubs/history.ts | 2 +- src/components/chart/chart-date-adapter.ts | 2 +- src/data/energy.ts | 2 +- src/dialogs/more-info/ha-more-info-history.ts | 2 +- src/dialogs/more-info/ha-more-info-logbook.ts | 2 +- src/panels/developer-tools/state/developer-tools-state.js | 2 +- src/panels/history/ha-panel-history.ts | 2 +- src/panels/logbook/ha-panel-logbook.ts | 2 +- .../lovelace/cards/energy/hui-energy-devices-graph-card.ts | 2 +- src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts | 2 +- src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts | 2 +- src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts | 2 +- src/panels/lovelace/components/hui-energy-period-selector.ts | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/demo/src/stubs/energy.ts b/demo/src/stubs/energy.ts index 06544bcc37..00e7ef7c3f 100644 --- a/demo/src/stubs/energy.ts +++ b/demo/src/stubs/energy.ts @@ -1,4 +1,4 @@ -import { format, startOfToday, startOfTomorrow } from "date-fns"; +import { format, startOfToday, startOfTomorrow } from "date-fns/esm"; import { EnergySolarForecasts } from "../../../src/data/energy"; import { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; diff --git a/demo/src/stubs/history.ts b/demo/src/stubs/history.ts index 911f2c248c..d843c942ac 100644 --- a/demo/src/stubs/history.ts +++ b/demo/src/stubs/history.ts @@ -4,7 +4,7 @@ import { addMonths, differenceInHours, endOfDay, -} from "date-fns"; +} from "date-fns/esm"; import { HassEntity } from "home-assistant-js-websocket"; import { StatisticValue } from "../../../src/data/history"; import { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; diff --git a/src/components/chart/chart-date-adapter.ts b/src/components/chart/chart-date-adapter.ts index 97c78c3da3..3819a22e88 100644 --- a/src/components/chart/chart-date-adapter.ts +++ b/src/components/chart/chart-date-adapter.ts @@ -34,7 +34,7 @@ import { endOfMonth, endOfQuarter, endOfYear, -} from "date-fns"; +} from "date-fns/esm"; import { formatDate, formatDateMonth, diff --git a/src/data/energy.ts b/src/data/energy.ts index d9f4f1c0c2..cf49d095c2 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -5,7 +5,7 @@ import { endOfYesterday, startOfToday, startOfYesterday, -} from "date-fns"; +} from "date-fns/esm"; import { Collection, getCollection } from "home-assistant-js-websocket"; import { groupBy } from "../common/util/group-by"; import { subscribeOne } from "../common/util/subscribe-one"; diff --git a/src/dialogs/more-info/ha-more-info-history.ts b/src/dialogs/more-info/ha-more-info-history.ts index 9cbab7e1ca..89e6f7ef43 100644 --- a/src/dialogs/more-info/ha-more-info-history.ts +++ b/src/dialogs/more-info/ha-more-info-history.ts @@ -1,4 +1,4 @@ -import { startOfYesterday } from "date-fns"; +import { startOfYesterday } from "date-fns/esm"; import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; diff --git a/src/dialogs/more-info/ha-more-info-logbook.ts b/src/dialogs/more-info/ha-more-info-logbook.ts index 15712055c0..9757d17a8a 100644 --- a/src/dialogs/more-info/ha-more-info-logbook.ts +++ b/src/dialogs/more-info/ha-more-info-logbook.ts @@ -1,4 +1,4 @@ -import { startOfYesterday } from "date-fns"; +import { startOfYesterday } from "date-fns/esm"; import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; diff --git a/src/panels/developer-tools/state/developer-tools-state.js b/src/panels/developer-tools/state/developer-tools-state.js index a6f89dac31..3f29f1d1c7 100644 --- a/src/panels/developer-tools/state/developer-tools-state.js +++ b/src/panels/developer-tools/state/developer-tools-state.js @@ -1,4 +1,4 @@ -import { addHours } from "date-fns"; +import { addHours } from "date-fns/esm"; import "@material/mwc-button"; import { mdiClipboardTextMultipleOutline, diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index c152af1c72..88e9dda30d 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -9,7 +9,7 @@ import { startOfToday, startOfWeek, startOfYesterday, -} from "date-fns"; +} from "date-fns/esm"; import { css, html, LitElement, PropertyValues } from "lit"; import { property, state } from "lit/decorators"; import { navigate } from "../../common/navigate"; diff --git a/src/panels/logbook/ha-panel-logbook.ts b/src/panels/logbook/ha-panel-logbook.ts index 864532d5f2..f3cce456ba 100644 --- a/src/panels/logbook/ha-panel-logbook.ts +++ b/src/panels/logbook/ha-panel-logbook.ts @@ -9,7 +9,7 @@ import { startOfToday, startOfWeek, startOfYesterday, -} from "date-fns"; +} from "date-fns/esm"; import { css, html, LitElement, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; import { computeStateDomain } from "../../common/entity/compute_state_domain"; diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts index f65454631b..97fc452517 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts @@ -6,7 +6,7 @@ import { ScatterDataPoint, } from "chart.js"; import { getRelativePosition } from "chart.js/helpers"; -import { addHours, differenceInDays } from "date-fns"; +import { addHours, differenceInDays } from "date-fns/esm"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators"; diff --git a/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts index 8953ebfd6d..113f586fc8 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts @@ -16,7 +16,7 @@ import { endOfToday, isToday, startOfToday, -} from "date-fns"; +} from "date-fns/esm"; import { HomeAssistant } from "../../../../types"; import { LovelaceCard } from "../../types"; import { EnergyGasGraphCardConfig } from "../types"; diff --git a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts index 6e3abbe7d7..5c6c2f537c 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts @@ -10,7 +10,7 @@ import { endOfToday, isToday, startOfToday, -} from "date-fns"; +} from "date-fns/esm"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; diff --git a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts index 8bd48b0b2c..8ba70e0269 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts @@ -5,7 +5,7 @@ import { endOfToday, isToday, startOfToday, -} from "date-fns"; +} from "date-fns/esm"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; diff --git a/src/panels/lovelace/components/hui-energy-period-selector.ts b/src/panels/lovelace/components/hui-energy-period-selector.ts index 8fa2ac19f1..f6283af3f2 100644 --- a/src/panels/lovelace/components/hui-energy-period-selector.ts +++ b/src/panels/lovelace/components/hui-energy-period-selector.ts @@ -17,7 +17,7 @@ import { startOfToday, startOfWeek, startOfYear, -} from "date-fns"; +} from "date-fns/esm"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; From 8216b522c24185b00b76aec4bd6defc1fecbc828 Mon Sep 17 00:00:00 2001 From: RoboMagus <68224306+RoboMagus@users.noreply.github.com> Date: Wed, 18 May 2022 21:09:31 +0200 Subject: [PATCH 046/112] Fix 'loading_log' string (#12712) --- src/translations/en.json | 2 +- translations/frontend/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/translations/en.json b/src/translations/en.json index f51e117884..ea34b4708c 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1601,7 +1601,7 @@ "failed_get_logs": "Failed to get {provider} logs, {error}", "no_issues_search": "No issues found for search term ''{term}''", "load_logs": "Load Full Logs", - "loading_log": "Loading error log…", + "loading_log": "Loading full log…", "no_errors": "No errors have been reported", "no_issues": "There are no new issues!", "clear": "Clear", diff --git a/translations/frontend/en.json b/translations/frontend/en.json index 1880e6e94a..16d2933745 100644 --- a/translations/frontend/en.json +++ b/translations/frontend/en.json @@ -2678,7 +2678,7 @@ "warning": "WARNING" }, "load_full_log": "Load Full Home Assistant Log", - "loading_log": "Loading error log…", + "loading_log": "Loading full log…", "multiple_messages": "message first occurred at {time} and shows up {counter} times", "no_errors": "No errors have been reported", "no_issues": "There are no new issues!", From c41e100c1cce2f5092fc92e68407668aaebe0f33 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 18 May 2022 12:10:42 -0700 Subject: [PATCH 047/112] Bumped version to 20220518.0 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index e5bb95e409..dcffba2294 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = home-assistant-frontend -version = 20220516.0 +version = 20220518.0 author = The Home Assistant Authors author_email = hello@home-assistant.io license = Apache-2.0 From 6c48ace41ed4a2cd44e739c00a7896443b0c4032 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 May 2022 14:36:08 -0500 Subject: [PATCH 048/112] Fix python to js timestamp conversions in logbook traces (#12677) - The websocket version needs the time converted from where python stores the decimal --- src/components/trace/ha-trace-path-details.ts | 4 ++-- src/components/trace/hat-trace-timeline.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/trace/ha-trace-path-details.ts b/src/components/trace/ha-trace-path-details.ts index dcf97366bd..8264b1d0b5 100644 --- a/src/components/trace/ha-trace-path-details.ts +++ b/src/components/trace/ha-trace-path-details.ts @@ -194,7 +194,7 @@ export class HaTracePathDetails extends LitElement { // it's the last entry. Find all logbook entries after start. const startTime = new Date(startTrace[0].timestamp); const idx = this.logbookEntries.findIndex( - (entry) => new Date(entry.when) >= startTime + (entry) => new Date(entry.when * 1000) >= startTime ); if (idx === -1) { entries = []; @@ -210,7 +210,7 @@ export class HaTracePathDetails extends LitElement { entries = []; for (const entry of this.logbookEntries || []) { - const entryDate = new Date(entry.when); + const entryDate = new Date(entry.when * 1000); if (entryDate >= startTime) { if (entryDate < endTime) { entries.push(entry); diff --git a/src/components/trace/hat-trace-timeline.ts b/src/components/trace/hat-trace-timeline.ts index e7ab310280..3a47a9f819 100644 --- a/src/components/trace/hat-trace-timeline.ts +++ b/src/components/trace/hat-trace-timeline.ts @@ -116,7 +116,7 @@ class LogbookRenderer { maybeRenderItem() { const logbookEntry = this.curItem; this.curIndex++; - const entryDate = new Date(logbookEntry.when); + const entryDate = new Date(logbookEntry.when * 1000); if (this.pendingItems.length === 0) { this.pendingItems.push([entryDate, logbookEntry]); @@ -248,7 +248,7 @@ class ActionRenderer { // Render all logbook items that are in front of this item. while ( this.logbookRenderer.hasNext && - new Date(this.logbookRenderer.curItem.when) < timestamp + new Date(this.logbookRenderer.curItem.when * 1000) < timestamp ) { this.logbookRenderer.maybeRenderItem(); } From 448609533fa98d856af3d6c17baf66ae1aae21a5 Mon Sep 17 00:00:00 2001 From: Michael Irigoyen Date: Thu, 19 May 2022 09:21:00 -0500 Subject: [PATCH 049/112] Update Material Design Icons to v6.7.96 (#12111) --- package.json | 4 ++-- yarn.lock | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 25cbfbe761..d6491c7c2b 100644 --- a/package.json +++ b/package.json @@ -72,8 +72,8 @@ "@material/mwc-textfield": "0.25.3", "@material/mwc-top-app-bar-fixed": "^0.25.3", "@material/top-app-bar": "14.0.0-canary.261f2db59.0", - "@mdi/js": "6.6.95", - "@mdi/svg": "6.6.95", + "@mdi/js": "6.7.96", + "@mdi/svg": "6.7.96", "@polymer/app-layout": "^3.1.0", "@polymer/iron-flex-layout": "^3.0.1", "@polymer/iron-icon": "^3.0.1", diff --git a/yarn.lock b/yarn.lock index af02cfdc2a..9474fa4838 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2975,17 +2975,17 @@ __metadata: languageName: node linkType: hard -"@mdi/js@npm:6.6.95": - version: 6.6.95 - resolution: "@mdi/js@npm:6.6.95" - checksum: 4cf8c48156f0e9ff67e4394cd428158bd164b1a6b7ca1aa70fc6a6aee91cfede9eba56720eb7d13fa57315ac636e9519a62dedd3cd2a9708aa11f2e3624ddbff +"@mdi/js@npm:6.7.96": + version: 6.7.96 + resolution: "@mdi/js@npm:6.7.96" + checksum: 8c8f6acb8fd3f856a92ffe2405e258ee5aa84cf541fda1c0a564c9c8bbf935cf2b6a6100cf97d41e9ada1ccb59e4b138d4c712e075f759d7595e21ef1cff84b5 languageName: node linkType: hard -"@mdi/svg@npm:6.6.95": - version: 6.6.95 - resolution: "@mdi/svg@npm:6.6.95" - checksum: 59b79db945847a3d981351418e0e7a457b831e09846fa751d44e80df8fb4cd19ef12bc889538ed2945d2638e522aa7ea5b1f97997e19dd68345f5d7bf5cad5e6 +"@mdi/svg@npm:6.7.96": + version: 6.7.96 + resolution: "@mdi/svg@npm:6.7.96" + checksum: 959332009b8833d0347e2dfac86028362a6d11996db850025b7da8c493d7fd341a14d8716ef775dd2ed0492158c77236bcf09adfc4ae77b31044a0a8a26fc74b languageName: node linkType: hard @@ -9048,8 +9048,8 @@ fsevents@^1.2.7: "@material/mwc-textfield": 0.25.3 "@material/mwc-top-app-bar-fixed": ^0.25.3 "@material/top-app-bar": 14.0.0-canary.261f2db59.0 - "@mdi/js": 6.6.95 - "@mdi/svg": 6.6.95 + "@mdi/js": 6.7.96 + "@mdi/svg": 6.7.96 "@open-wc/dev-server-hmr": ^0.0.2 "@polymer/app-layout": ^3.1.0 "@polymer/iron-flex-layout": ^3.0.1 From 6f19ea1d8480a5ea6446bd09719fb6de26091ce3 Mon Sep 17 00:00:00 2001 From: Yosi Levy <37745463+yosilevy@users.noreply.github.com> Date: Thu, 19 May 2022 17:25:30 +0300 Subject: [PATCH 050/112] Various RTL fixes (#12721) --- src/components/ha-date-range-picker.ts | 6 ++++++ src/components/ha-expansion-panel.ts | 3 +++ src/panels/calendar/ha-full-calendar.ts | 3 +++ src/panels/calendar/ha-panel-calendar.ts | 3 +++ .../developer-tools/template/developer-tools-template.ts | 2 +- src/panels/history/ha-panel-history.ts | 6 ++++++ src/panels/logbook/ha-panel-logbook.ts | 6 ++++++ src/panels/media-browser/ha-bar-media-player.ts | 1 + 8 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/components/ha-date-range-picker.ts b/src/components/ha-date-range-picker.ts index 3baef7c22e..40e5d0d1d7 100644 --- a/src/components/ha-date-range-picker.ts +++ b/src/components/ha-date-range-picker.ts @@ -140,6 +140,9 @@ export class HaDateRangePicker extends LitElement { return css` ha-svg-icon { margin-right: 8px; + margin-inline-end: 8px; + margin-inline-start: initial; + direction: var(--direction); } .date-range-inputs { @@ -166,6 +169,9 @@ export class HaDateRangePicker extends LitElement { ha-textfield:last-child { margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: initial; + direction: var(--direction); } @media only screen and (max-width: 800px) { diff --git a/src/components/ha-expansion-panel.ts b/src/components/ha-expansion-panel.ts index b3dc414f1e..a4f3817508 100644 --- a/src/components/ha-expansion-panel.ts +++ b/src/components/ha-expansion-panel.ts @@ -133,6 +133,9 @@ class HaExpansionPanel extends LitElement { .summary-icon { transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); margin-left: auto; + margin-inline-start: auto; + margin-inline-end: initial; + direction: var(--direction); } .summary-icon.expanded { diff --git a/src/panels/calendar/ha-full-calendar.ts b/src/panels/calendar/ha-full-calendar.ts index d279ba1f28..8b2b2ad4b5 100644 --- a/src/panels/calendar/ha-full-calendar.ts +++ b/src/panels/calendar/ha-full-calendar.ts @@ -336,6 +336,9 @@ export class HAFullCalendar extends LitElement { .today { margin-right: 20px; + margin-inline-end: 20px; + margin-inline-start: initial; + direction: var(--direction); } .prev, diff --git a/src/panels/calendar/ha-panel-calendar.ts b/src/panels/calendar/ha-panel-calendar.ts index ac3862380c..e757705acd 100644 --- a/src/panels/calendar/ha-panel-calendar.ts +++ b/src/panels/calendar/ha-panel-calendar.ts @@ -194,10 +194,13 @@ class PanelCalendar extends LitElement { .calendar-list { padding-right: 16px; + padding-inline-end: 16px; + padding-inline-start: initial; min-width: 170px; flex: 0 0 15%; overflow: hidden; --mdc-theme-text-primary-on-background: var(--primary-text-color); + direction: var(--direction); } .calendar-list > div { diff --git a/src/panels/developer-tools/template/developer-tools-template.ts b/src/panels/developer-tools/template/developer-tools-template.ts index a0c91ec175..8f88903446 100644 --- a/src/panels/developer-tools/template/developer-tools-template.ts +++ b/src/panels/developer-tools/template/developer-tools-template.ts @@ -248,7 +248,6 @@ class HaPanelDevTemplate extends LitElement { .content { padding: 16px; - direction: ltr; } .edit-pane { @@ -280,6 +279,7 @@ class HaPanelDevTemplate extends LitElement { white-space: pre-wrap; background-color: var(--secondary-background-color); padding: 8px; + direction: ltr; } .all_listeners { diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index 88e9dda30d..a1c4554990 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -259,11 +259,17 @@ class HaPanelHistory extends LitElement { ha-date-range-picker { margin-right: 16px; + margin-inline-end: 16px; + margin-inline-start: initial; max-width: 100%; + direction: var(--direction); } :host([narrow]) ha-date-range-picker { margin-right: 0; + margin-inline-end: 0; + margin-inline-start: initial; + direction: var(--direction); } ha-circular-progress { diff --git a/src/panels/logbook/ha-panel-logbook.ts b/src/panels/logbook/ha-panel-logbook.ts index f3cce456ba..7a64a8dbe8 100644 --- a/src/panels/logbook/ha-panel-logbook.ts +++ b/src/panels/logbook/ha-panel-logbook.ts @@ -252,11 +252,17 @@ export class HaPanelLogbook extends LitElement { ha-date-range-picker { margin-right: 16px; + margin-inline-end: 16px; + margin-inline-start: initial; max-width: 100%; + direction: var(--direction); } :host([narrow]) ha-date-range-picker { margin-right: 0; + margin-inline-end: 0; + margin-inline-start: initial; + direction: var(--direction); } .filters { diff --git a/src/panels/media-browser/ha-bar-media-player.ts b/src/panels/media-browser/ha-bar-media-player.ts index bc2ea2169d..f4aab0b5ea 100644 --- a/src/panels/media-browser/ha-bar-media-player.ts +++ b/src/panels/media-browser/ha-bar-media-player.ts @@ -595,6 +595,7 @@ export class BarMediaPlayer extends LitElement { align-items: center; justify-content: center; flex-direction: column; + direction: ltr; } .progress { From f6411dce6684c07abcef76abde65be51be087036 Mon Sep 17 00:00:00 2001 From: Yosi Levy <37745463+yosilevy@users.noreply.github.com> Date: Thu, 19 May 2022 17:28:56 +0300 Subject: [PATCH 051/112] Select + target picker Rtl fixes (#12711) --- src/components/ha-select.ts | 14 +++++++++++--- src/components/ha-target-picker.ts | 11 ++++++----- .../lovelace/components/hui-action-editor.ts | 3 +++ src/state/translations-mixin.ts | 4 ++++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/components/ha-select.ts b/src/components/ha-select.ts index f432bcb280..8ed23be297 100644 --- a/src/components/ha-select.ts +++ b/src/components/ha-select.ts @@ -47,11 +47,19 @@ export class HaSelect extends SelectBase { .mdc-select__anchor { width: var(--ha-select-min-width, 200px); } - .mdc-floating-label { - inset-inline-start: 16px !important; - inset-inline-end: initial !important; + .mdc-select--filled .mdc-floating-label { + inset-inline-start: 12px; + inset-inline-end: initial; direction: var(--direction); } + .mdc-select .mdc-select__anchor { + padding-inline-start: 12px; + padding-inline-end: 0px; + direction: var(--direction); + } + .mdc-select__anchor .mdc-floating-label--float-above { + transform-origin: var(--float-start); + } `, ]; } diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index adeba08722..1453afa07b 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -569,6 +569,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { height: 16px; --mdc-icon-size: 14px; color: var(--secondary-text-color); + margin-inline-start: 4px !important; + margin-inline-end: -4px !important; + direction: var(--direction); } .mdc-chip__icon--leading { display: flex; @@ -578,6 +581,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { border-radius: 50%; padding: 6px; margin-left: -14px !important; + margin-inline-start: -14px !important; + margin-inline-end: 4px !important; + direction: var(--direction); } .expand-btn { margin-right: 0; @@ -616,11 +622,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { opacity: var(--light-disabled-opacity); pointer-events: none; } - .mdc-chip__icon { - margin-inline-start: -14px !important; - margin-inline-end: 4px !important; - direction: var(--direction); - } `; } } diff --git a/src/panels/lovelace/components/hui-action-editor.ts b/src/panels/lovelace/components/hui-action-editor.ts index f57a51f51d..3ec925e58f 100644 --- a/src/panels/lovelace/components/hui-action-editor.ts +++ b/src/panels/lovelace/components/hui-action-editor.ts @@ -198,6 +198,9 @@ export class HuiActionEditor extends LitElement { position: absolute; right: 40px; top: 16px; + inset-inline-start: initial; + inset-inline-end: 40px; + direction: var(--direction); } ha-select, ha-textfield { diff --git a/src/state/translations-mixin.ts b/src/state/translations-mixin.ts index d02144ffd0..f399e06695 100644 --- a/src/state/translations-mixin.ts +++ b/src/state/translations-mixin.ts @@ -191,6 +191,10 @@ export default >(superClass: T) => this.style.direction = direction; document.dir = direction; this.style.setProperty("--direction", direction); + this.style.setProperty( + "--float-start", + direction === "ltr" ? "left" : "right" + ); this.style.setProperty( "--float-end", direction === "ltr" ? "right" : "left" From 788f76ab9c8604f9fb7c44b74f6b535ca2f56f3e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 19 May 2022 07:51:33 -0700 Subject: [PATCH 052/112] Add error handling for application credentials removal (#12686) --- .../ha-config-application-credentials.ts | 28 +++++++++++++++---- src/translations/en.json | 3 +- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/panels/config/application_credentials/ha-config-application-credentials.ts b/src/panels/config/application_credentials/ha-config-application-credentials.ts index fa55f07681..3e8e2f036d 100644 --- a/src/panels/config/application_credentials/ha-config-application-credentials.ts +++ b/src/panels/config/application_credentials/ha-config-application-credentials.ts @@ -19,7 +19,10 @@ import { fetchApplicationCredentials, } from "../../../data/application_credential"; import { domainToName } from "../../../data/integration"; -import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-tabs-subpage-data-table"; import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table"; import { HomeAssistant, Route } from "../../../types"; @@ -171,11 +174,24 @@ export class HaConfigApplicationCredentials extends LitElement { confirmText: this.hass.localize("ui.common.remove"), dismissText: this.hass.localize("ui.common.cancel"), confirm: async () => { - await Promise.all( - this._selected.map(async (applicationCredential) => { - await deleteApplicationCredential(this.hass, applicationCredential); - }) - ); + try { + await Promise.all( + this._selected.map(async (applicationCredential) => { + await deleteApplicationCredential( + this.hass, + applicationCredential + ); + }) + ); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.application_credentials.picker.remove_selected.error_title" + ), + text: err.message, + }); + return; + } this._dataTable.clearSelection(); this._fetchApplicationCredentials(); }, diff --git a/src/translations/en.json b/src/translations/en.json index ea34b4708c..f7aa3e75ee 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2895,7 +2895,8 @@ "remove_selected": { "button": "Remove selected", "confirm_title": "Do you want to remove {number} {number, plural,\n one {credential}\n other {credentialss}\n}?", - "confirm_text": "Application Credentials in use by an integration may not be removed." + "confirm_text": "Application Credentials in use by an integration may not be removed.", + "error_title": "Removing Application Credential failed" }, "selected": "{number} selected" } From 32a991989ff2ff7fccffcf002cff785580e85070 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 19 May 2022 11:05:31 -0400 Subject: [PATCH 053/112] Update zwave_js data collection URL (#12666) --- .../integration-panels/zwave_js/zwave_js-config-dashboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts index 9b5a5c8e37..7b038e96b6 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts @@ -288,7 +288,7 @@ class ZWaveJSConfigDashboard extends LitElement { data collected, can be found in the Z-Wave JS data collection documentation.

From 5b7b0ea32657eae0c276b1cb8bf47d4e19e0de5d Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 19 May 2022 13:23:16 -0400 Subject: [PATCH 054/112] Use device_id instead of config entry id and node id for zwave_js (#12658) * Use device_id instead of config entry id and node id for zwave_js * Add additional cleanup from #12642 * Revert removal of multiple config entries check * Update src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts Co-authored-by: Paulus Schoutsen * Update src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- src/data/zwave_js.ts | 53 +++++++------------ .../zwave_js/ha-device-actions-zwave_js.ts | 53 +++++++++---------- .../zwave_js/ha-device-info-zwave_js.ts | 48 ++++++----------- .../config/devices/ha-config-device-page.ts | 6 +-- .../zwave_js/dialog-zwave_js-heal-node.ts | 10 +--- .../dialog-zwave_js-reinterview-node.ts | 15 ++---- .../dialog-zwave_js-remove-failed-node.ts | 14 ++--- .../show-dialog-zwave_js-heal-node.ts | 1 - .../show-dialog-zwave_js-reinterview-node.ts | 3 +- ...show-dialog-zwave_js-remove-failed-node.ts | 3 +- .../zwave_js/zwave_js-config-dashboard.ts | 19 +------ .../zwave_js/zwave_js-node-config.ts | 27 ++-------- 12 files changed, 78 insertions(+), 174 deletions(-) diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts index f11b5db8db..c6a84e75bd 100644 --- a/src/data/zwave_js.ts +++ b/src/data/zwave_js.ts @@ -145,7 +145,7 @@ export interface ZWaveJSController { supports_timers: boolean; is_heal_network_active: boolean; inclusion_state: InclusionState; - nodes: number[]; + nodes: ZWaveJSNodeStatus[]; } export interface ZWaveJSNodeStatus { @@ -200,8 +200,7 @@ export interface ZWaveJSNodeConfigParamMetadata { export interface ZWaveJSSetConfigParamData { type: string; - entry_id: string; - node_id: number; + device_id: string; property: number; property_key?: number; value: string | number; @@ -427,49 +426,41 @@ export const unprovisionZwaveSmartStartNode = ( export const fetchZwaveNodeStatus = ( hass: HomeAssistant, - entry_id: string, - node_id: number + device_id: string ): Promise => hass.callWS({ type: "zwave_js/node_status", - entry_id, - node_id, + device_id, }); export const fetchZwaveNodeMetadata = ( hass: HomeAssistant, - entry_id: string, - node_id: number + device_id: string ): Promise => hass.callWS({ type: "zwave_js/node_metadata", - entry_id, - node_id, + device_id, }); export const fetchZwaveNodeConfigParameters = ( hass: HomeAssistant, - entry_id: string, - node_id: number + device_id: string ): Promise => hass.callWS({ type: "zwave_js/get_config_parameters", - entry_id, - node_id, + device_id, }); export const setZwaveNodeConfigParameter = ( hass: HomeAssistant, - entry_id: string, - node_id: number, + device_id: string, property: number, value: number, property_key?: number ): Promise => { const data: ZWaveJSSetConfigParamData = { type: "zwave_js/set_config_parameter", - entry_id, - node_id, + device_id, property, value, property_key, @@ -479,42 +470,36 @@ export const setZwaveNodeConfigParameter = ( export const reinterviewZwaveNode = ( hass: HomeAssistant, - entry_id: string, - node_id: number, + device_id: string, callbackFunction: (message: ZWaveJSRefreshNodeStatusMessage) => void ): Promise => hass.connection.subscribeMessage( (message: any) => callbackFunction(message), { type: "zwave_js/refresh_node_info", - entry_id, - node_id, + device_id, } ); export const healZwaveNode = ( hass: HomeAssistant, - entry_id: string, - node_id: number + device_id: string ): Promise => hass.callWS({ type: "zwave_js/heal_node", - entry_id, - node_id, + device_id, }); export const removeFailedZwaveNode = ( hass: HomeAssistant, - entry_id: string, - node_id: number, + device_id: string, callbackFunction: (message: any) => void ): Promise => hass.connection.subscribeMessage( (message: any) => callbackFunction(message), { type: "zwave_js/remove_failed_node", - entry_id, - node_id, + device_id, } ); @@ -538,16 +523,14 @@ export const stopHealZwaveNetwork = ( export const subscribeZwaveNodeReady = ( hass: HomeAssistant, - entry_id: string, - node_id: number, + device_id: string, callbackFunction: (message) => void ): Promise => hass.connection.subscribeMessage( (message: any) => callbackFunction(message), { type: "zwave_js/node_ready", - entry_id, - node_id, + device_id, } ); diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts index 5d9a4205e6..b0a707f5b7 100644 --- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts @@ -11,8 +11,6 @@ import { customElement, property, state } from "lit/decorators"; import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; import { fetchZwaveNodeStatus, - getZwaveJsIdentifiersFromDevice, - ZWaveJSNodeIdentifiers, ZWaveJSNodeStatus, } from "../../../../../../data/zwave_js"; import { haStyle } from "../../../../../../resources/styles"; @@ -20,6 +18,7 @@ import { HomeAssistant } from "../../../../../../types"; import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node"; import { showZWaveJSHealNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-heal-node"; import { showZWaveJSRemoveFailedNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node"; +import { getConfigEntries } from "../../../../../../data/config_entries"; @customElement("ha-device-actions-zwave_js") export class HaDeviceActionsZWaveJS extends LitElement { @@ -29,34 +28,37 @@ export class HaDeviceActionsZWaveJS extends LitElement { @state() private _entryId?: string; - @state() private _nodeId?: number; - @state() private _node?: ZWaveJSNodeStatus; - protected updated(changedProperties: PropertyValues) { + public willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); if (changedProperties.has("device")) { - const identifiers: ZWaveJSNodeIdentifiers | undefined = - getZwaveJsIdentifiersFromDevice(this.device); - if (!identifiers) { - return; - } - this._nodeId = identifiers.node_id; - this._entryId = this.device.config_entries[0]; - this._fetchNodeDetails(); } } protected async _fetchNodeDetails() { - if (!this._nodeId || !this._entryId) { + if (!this.device) { return; } - this._node = await fetchZwaveNodeStatus( - this.hass, - this._entryId, - this._nodeId + this._node = undefined; + + const configEntries = await getConfigEntries(this.hass, { + domain: "zwave_js", + }); + + const configEntry = configEntries.find((entry) => + this.device.config_entries.includes(entry.entry_id) ); + + if (!configEntry) { + return; + } + + this._entryId = configEntry.entry_id; + + this._node = await fetchZwaveNodeStatus(this.hass, this.device.id); } protected render(): TemplateResult { @@ -96,33 +98,30 @@ export class HaDeviceActionsZWaveJS extends LitElement { } private async _reinterviewClicked() { - if (!this._nodeId || !this._entryId) { + if (!this.device) { return; } showZWaveJSReinterviewNodeDialog(this, { - entry_id: this._entryId, - node_id: this._nodeId, + device_id: this.device.id, }); } private async _healNodeClicked() { - if (!this._nodeId || !this._entryId) { + if (!this.device) { return; } showZWaveJSHealNodeDialog(this, { - entry_id: this._entryId, - node_id: this._nodeId, + entry_id: this._entryId!, device: this.device, }); } private async _removeFailedNode() { - if (!this._nodeId || !this._entryId) { + if (!this.device) { return; } showZWaveJSRemoveFailedNodeDialog(this, { - entry_id: this._entryId, - node_id: this._nodeId, + device_id: this.device.id, }); } diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts index 78d56a0b1b..02147196f1 100644 --- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts @@ -14,10 +14,8 @@ import { } from "../../../../../../data/config_entries"; import { fetchZwaveNodeStatus, - getZwaveJsIdentifiersFromDevice, nodeStatus, ZWaveJSNodeStatus, - ZWaveJSNodeIdentifiers, SecurityClass, } from "../../../../../../data/zwave_js"; import { haStyle } from "../../../../../../resources/styles"; @@ -29,57 +27,41 @@ export class HaDeviceInfoZWaveJS extends LitElement { @property({ attribute: false }) public device!: DeviceRegistryEntry; - @state() private _entryId?: string; - @state() private _configEntry?: ConfigEntry; @state() private _multipleConfigEntries = false; - @state() private _nodeId?: number; - @state() private _node?: ZWaveJSNodeStatus; - protected updated(changedProperties: PropertyValues) { + public willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); if (changedProperties.has("device")) { - const identifiers: ZWaveJSNodeIdentifiers | undefined = - getZwaveJsIdentifiersFromDevice(this.device); - if (!identifiers) { - return; - } - this._nodeId = identifiers.node_id; - this._entryId = this.device.config_entries[0]; - this._fetchNodeDetails(); } } protected async _fetchNodeDetails() { - if (!this._nodeId || !this._entryId) { + if (!this.device) { return; } const configEntries = await getConfigEntries(this.hass, { domain: "zwave_js", }); - let zwaveJsConfEntries = 0; - for (const entry of configEntries) { - if (zwaveJsConfEntries) { - this._multipleConfigEntries = true; - } - if (entry.entry_id === this._entryId) { - this._configEntry = entry; - } - if (this._configEntry && this._multipleConfigEntries) { - break; - } - zwaveJsConfEntries++; + + this._multipleConfigEntries = configEntries.length > 1; + + const configEntry = configEntries.find((entry) => + this.device.config_entries.includes(entry.entry_id) + ); + + if (!configEntry) { + return; } - this._node = await fetchZwaveNodeStatus( - this.hass, - this._entryId, - this._nodeId - ); + this._configEntry = configEntry; + + this._node = await fetchZwaveNodeStatus(this.hass, this.device.id); } protected render(): TemplateResult { diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts index 72aec6c243..7f019170ab 100644 --- a/src/panels/config/devices/ha-config-device-page.ts +++ b/src/panels/config/devices/ha-config-device-page.ts @@ -922,13 +922,12 @@ export class HaConfigDevicePage extends LitElement { } private _renderIntegrationInfo( - device, + device: DeviceRegistryEntry, integrations: ConfigEntry[], deviceInfo: TemplateResult[], deviceActions: (string | TemplateResult)[] - ): TemplateResult[] { + ) { const domains = integrations.map((int) => int.domain); - const templates: TemplateResult[] = []; if (domains.includes("mqtt")) { import( "./device-detail/integration-elements/mqtt/ha-device-actions-mqtt" @@ -976,7 +975,6 @@ export class HaConfigDevicePage extends LitElement { > `); } - return templates; } private async _showSettings() { diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-heal-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-heal-node.ts index 6db1483fb7..a418183a24 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-heal-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-heal-node.ts @@ -24,8 +24,6 @@ class DialogZWaveJSHealNode extends LitElement { @state() private entry_id?: string; - @state() private node_id?: number; - @state() private device?: DeviceRegistryEntry; @state() private _status?: string; @@ -35,14 +33,12 @@ class DialogZWaveJSHealNode extends LitElement { public showDialog(params: ZWaveJSHealNodeDialogParams): void { this.entry_id = params.entry_id; this.device = params.device; - this.node_id = params.node_id; this._fetchData(); } public closeDialog(): void { this.entry_id = undefined; this._status = undefined; - this.node_id = undefined; this.device = undefined; this._error = undefined; @@ -221,11 +217,7 @@ class DialogZWaveJSHealNode extends LitElement { } this._status = "started"; try { - this._status = (await healZwaveNode( - this.hass, - this.entry_id!, - this.node_id! - )) + this._status = (await healZwaveNode(this.hass, this.device!.id)) ? "finished" : "failed"; } catch (err: any) { diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-reinterview-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-reinterview-node.ts index fb4e12785c..5b3bda96b5 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-reinterview-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-reinterview-node.ts @@ -15,9 +15,7 @@ import { ZWaveJSReinterviewNodeDialogParams } from "./show-dialog-zwave_js-reint class DialogZWaveJSReinterviewNode extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @state() private entry_id?: string; - - @state() private node_id?: number; + @state() private device_id?: string; @state() private _status?: string; @@ -29,12 +27,11 @@ class DialogZWaveJSReinterviewNode extends LitElement { params: ZWaveJSReinterviewNodeDialogParams ): Promise { this._stages = undefined; - this.entry_id = params.entry_id; - this.node_id = params.node_id; + this.device_id = params.device_id; } protected render(): TemplateResult { - if (!this.entry_id) { + if (!this.device_id) { return html``; } @@ -159,8 +156,7 @@ class DialogZWaveJSReinterviewNode extends LitElement { } this._subscribed = reinterviewZwaveNode( this.hass, - this.entry_id!, - this.node_id!, + this.device_id!, this._handleMessage.bind(this) ); } @@ -194,8 +190,7 @@ class DialogZWaveJSReinterviewNode extends LitElement { } public closeDialog(): void { - this.entry_id = undefined; - this.node_id = undefined; + this.device_id = undefined; this._status = undefined; this._stages = undefined; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-failed-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-failed-node.ts index 32faabced2..f328c97088 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-failed-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-remove-failed-node.ts @@ -18,9 +18,7 @@ import { ZWaveJSRemoveFailedNodeDialogParams } from "./show-dialog-zwave_js-remo class DialogZWaveJSRemoveFailedNode extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @state() private entry_id?: string; - - @state() private node_id?: number; + @state() private device_id?: string; @state() private _status = ""; @@ -38,13 +36,12 @@ class DialogZWaveJSRemoveFailedNode extends LitElement { public async showDialog( params: ZWaveJSRemoveFailedNodeDialogParams ): Promise { - this.entry_id = params.entry_id; - this.node_id = params.node_id; + this.device_id = params.device_id; } public closeDialog(): void { this._unsubscribe(); - this.entry_id = undefined; + this.device_id = undefined; this._status = ""; fireEvent(this, "dialog-closed", { dialog: this.localName }); @@ -56,7 +53,7 @@ class DialogZWaveJSRemoveFailedNode extends LitElement { } protected render(): TemplateResult { - if (!this.entry_id || !this.node_id) { + if (!this.device_id) { return html``; } @@ -166,8 +163,7 @@ class DialogZWaveJSRemoveFailedNode extends LitElement { this._status = "started"; this._subscribed = removeFailedZwaveNode( this.hass, - this.entry_id!, - this.node_id!, + this.device_id!, (message: any) => this._handleMessage(message) ).catch((error) => { this._status = "failed"; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-heal-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-heal-node.ts index 646b9f1b3e..39bb062709 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-heal-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-heal-node.ts @@ -3,7 +3,6 @@ import { DeviceRegistryEntry } from "../../../../../data/device_registry"; export interface ZWaveJSHealNodeDialogParams { entry_id: string; - node_id: number; device: DeviceRegistryEntry; } diff --git a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node.ts index 7755786750..169a892885 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node.ts @@ -1,8 +1,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event"; export interface ZWaveJSReinterviewNodeDialogParams { - entry_id: string; - node_id: number; + device_id: string; } export const loadReinterviewNodeDialog = () => diff --git a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node.ts index e64f0ee483..ae902f28ae 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node.ts @@ -1,8 +1,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event"; export interface ZWaveJSRemoveFailedNodeDialogParams { - entry_id: string; - node_id: number; + device_id: string; } export const loadRemoveFailedNodeDialog = () => diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts index 7b038e96b6..b1d012391b 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts @@ -17,7 +17,6 @@ import "../../../../../components/ha-svg-icon"; import { fetchZwaveDataCollectionStatus, fetchZwaveNetworkStatus, - fetchZwaveNodeStatus, fetchZwaveProvisioningEntries, InclusionState, setZwaveDataCollectionPreference, @@ -25,7 +24,6 @@ import { stopZwaveInclusion, ZWaveJSClient, ZWaveJSNetwork, - ZWaveJSNodeStatus, ZwaveJSProvisioningEntry, } from "../../../../../data/zwave_js"; import { @@ -60,8 +58,6 @@ class ZWaveJSConfigDashboard extends LitElement { @state() private _network?: ZWaveJSNetwork; - @state() private _nodes?: ZWaveJSNodeStatus[]; - @state() private _provisioningEntries?: ZwaveJSProvisioningEntry[]; @state() private _status?: ZWaveJSClient["state"]; @@ -84,9 +80,8 @@ class ZWaveJSConfigDashboard extends LitElement { if (ERROR_STATES.includes(this._configEntry.state)) { return this._renderErrorScreen(); } - const notReadyDevices = - this._nodes?.filter((node) => !node.ready).length ?? 0; + this._network?.controller.nodes.filter((node) => !node.ready).length ?? 0; return html` - fetchZwaveNodeStatus(this.hass, this.configEntryId!, nodeId) - ); - this._nodes = await Promise.all(nodeStatePromisses); } private async _addNodeClicked() { diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts index e5d0142239..53fe468ec1 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-node-config.ts @@ -61,19 +61,6 @@ const getDevice = memoizeOne( entries?.find((device) => device.id === deviceId) ); -const getNodeId = memoizeOne( - (device: DeviceRegistryEntry): number | undefined => { - const identifier = device.identifiers.find( - (ident) => ident[0] === "zwave_js" - ); - if (!identifier) { - return undefined; - } - - return parseInt(identifier[1].split("-")[1]); - } -); - @customElement("zwave_js-node-config") class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @@ -382,12 +369,10 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) { } private async _updateConfigParameter(target, value) { - const nodeId = getNodeId(this._device!); try { const result = await setZwaveNodeConfigParameter( this.hass, - this.configEntryId!, - nodeId!, + this._device!.id, target.property, value, target.propertyKey ? target.propertyKey : undefined @@ -429,15 +414,9 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) { return; } - const nodeId = getNodeId(device); - if (!nodeId) { - this._error = "device_not_found"; - return; - } - [this._nodeMetadata, this._config] = await Promise.all([ - fetchZwaveNodeMetadata(this.hass, this.configEntryId, nodeId!), - fetchZwaveNodeConfigParameters(this.hass, this.configEntryId, nodeId!), + fetchZwaveNodeMetadata(this.hass, device.id), + fetchZwaveNodeConfigParameters(this.hass, device.id), ]); } From 1f105b6c156485e0bde110dc60b586a863a1191b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 19 May 2022 22:56:11 -0500 Subject: [PATCH 055/112] Get attributes from first state when using minimal responses (#12732) --- src/data/history.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/data/history.ts b/src/data/history.ts index 3ac791c790..81f5276149 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -272,7 +272,7 @@ const processTimelineEntity = ( states: EntityHistoryState[] ): TimelineEntity => { const data: TimelineState[] = []; - const last: EntityHistoryState = states[states.length - 1]; + const first: EntityHistoryState = states[0]; for (const state of states) { if (data.length > 0 && state.s === data[data.length - 1].state) { continue; @@ -282,7 +282,7 @@ const processTimelineEntity = ( localize, language, entityId, - state.a || last.a, + state.a || first.a, state.s ), state: state.s, @@ -307,7 +307,7 @@ const processLineChartEntities = ( Object.keys(entities).forEach((entityId) => { const states = entities[entityId]; - const last: EntityHistoryState = states[states.length - 1]; + const first: EntityHistoryState = states[0]; const domain = computeDomain(entityId); const processedStates: LineChartState[] = []; @@ -352,7 +352,7 @@ const processLineChartEntities = ( data.push({ domain, - name: computeStateNameFromEntityAttributes(entityId, last.a), + name: computeStateNameFromEntityAttributes(entityId, first.a), entity_id: entityId, states: processedStates, }); From bfeb90780f9daecb50f26ab58d00ac2e7942ef78 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 19 May 2022 21:09:33 -0700 Subject: [PATCH 056/112] Pass device ID to logbook if available (#12728) --- src/data/logbook.ts | 80 ++++++---- src/dialogs/more-info/ha-more-info-logbook.ts | 5 +- .../config/areas/ha-config-area-page.ts | 56 +++++-- .../config/areas/ha-config-areas-dashboard.ts | 145 +++++++++++------- src/panels/config/areas/ha-config-areas.ts | 82 +--------- .../config/devices/ha-config-device-page.ts | 5 +- src/panels/logbook/ha-logbook.ts | 102 ++++++++---- src/panels/logbook/ha-panel-logbook.ts | 39 +++-- src/panels/lovelace/cards/hui-logbook-card.ts | 2 +- 9 files changed, 291 insertions(+), 225 deletions(-) diff --git a/src/data/logbook.ts b/src/data/logbook.ts index 9e5f2ac4a6..e5e37779ee 100644 --- a/src/data/logbook.ts +++ b/src/data/logbook.ts @@ -56,17 +56,28 @@ export const getLogbookData = async ( hass: HomeAssistant, startDate: string, endDate: string, - entityId?: string + entityIds?: string[], + deviceIds?: string[] ): Promise => { const localize = await hass.loadBackendTranslation("device_class"); return addLogbookMessage( hass, localize, - await getLogbookDataCache(hass, startDate, endDate, entityId) + // bypass cache if we have a device ID + deviceIds?.length + ? await getLogbookDataFromServer( + hass, + startDate, + endDate, + entityIds, + undefined, + deviceIds + ) + : await getLogbookDataCache(hass, startDate, endDate, entityIds) ); }; -export const addLogbookMessage = ( +const addLogbookMessage = ( hass: HomeAssistant, localize: LocalizeFunc, logbookData: LogbookEntry[] @@ -86,60 +97,73 @@ export const addLogbookMessage = ( return logbookData; }; -export const getLogbookDataCache = async ( +const getLogbookDataCache = async ( hass: HomeAssistant, startDate: string, endDate: string, - entityId?: string + entityId?: string[] ) => { const ALL_ENTITIES = "*"; - if (!entityId) { - entityId = ALL_ENTITIES; - } - + const entityIdKey = entityId ? entityId.toString() : ALL_ENTITIES; const cacheKey = `${startDate}${endDate}`; if (!DATA_CACHE[cacheKey]) { DATA_CACHE[cacheKey] = {}; } - if (entityId in DATA_CACHE[cacheKey]) { - return DATA_CACHE[cacheKey][entityId]; + if (entityIdKey in DATA_CACHE[cacheKey]) { + return DATA_CACHE[cacheKey][entityIdKey]; } - if (entityId !== ALL_ENTITIES && DATA_CACHE[cacheKey][ALL_ENTITIES]) { + if (entityId && DATA_CACHE[cacheKey][ALL_ENTITIES]) { const entities = await DATA_CACHE[cacheKey][ALL_ENTITIES]; - return entities.filter((entity) => entity.entity_id === entityId); + return entities.filter( + (entity) => entity.entity_id && entityId.includes(entity.entity_id) + ); } - DATA_CACHE[cacheKey][entityId] = getLogbookDataFromServer( + DATA_CACHE[cacheKey][entityIdKey] = getLogbookDataFromServer( hass, startDate, endDate, - entityId !== ALL_ENTITIES ? entityId : undefined - ).then((entries) => entries.reverse()); - return DATA_CACHE[cacheKey][entityId]; + entityId + ); + return DATA_CACHE[cacheKey][entityIdKey]; }; -export const getLogbookDataFromServer = ( +const getLogbookDataFromServer = ( hass: HomeAssistant, startDate: string, endDate?: string, - entityId?: string, - contextId?: string -) => { - let params: any = { + entityIds?: string[], + contextId?: string, + deviceIds?: string[] +): Promise => { + // If all specified filters are empty lists, we can return an empty list. + if ( + (entityIds || deviceIds) && + (!entityIds || entityIds.length === 0) && + (!deviceIds || deviceIds.length === 0) + ) { + return Promise.resolve([]); + } + + const params: any = { type: "logbook/get_events", start_time: startDate, }; if (endDate) { - params = { ...params, end_time: endDate }; + params.end_time = endDate; } - if (entityId) { - params = { ...params, entity_ids: entityId.split(",") }; - } else if (contextId) { - params = { ...params, context_id: contextId }; + if (entityIds?.length) { + params.entity_ids = entityIds; + } + if (deviceIds?.length) { + params.device_ids = deviceIds; + } + if (contextId) { + params.context_id = contextId; } return hass.callWS(params); }; @@ -148,7 +172,7 @@ export const clearLogbookCache = (startDate: string, endDate: string) => { DATA_CACHE[`${startDate}${endDate}`] = {}; }; -export const getLogbookMessage = ( +const getLogbookMessage = ( hass: HomeAssistant, localize: LocalizeFunc, state: string, diff --git a/src/dialogs/more-info/ha-more-info-logbook.ts b/src/dialogs/more-info/ha-more-info-logbook.ts index 9757d17a8a..9a30d7929a 100644 --- a/src/dialogs/more-info/ha-more-info-logbook.ts +++ b/src/dialogs/more-info/ha-more-info-logbook.ts @@ -1,6 +1,7 @@ import { startOfYesterday } from "date-fns/esm"; import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; +import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { fireEvent } from "../../common/dom/fire_event"; import "../../panels/logbook/ha-logbook"; @@ -16,6 +17,8 @@ export class MoreInfoLogbook extends LitElement { private _time = { recent: 86400 }; + private _entityIdAsList = memoizeOne((entityId: string) => [entityId]); + protected render(): TemplateResult { if (!isComponentLoaded(this.hass, "logbook") || !this.entityId) { return html``; @@ -38,7 +41,7 @@ export class MoreInfoLogbook extends LitElement { = { name: string; @@ -52,17 +59,11 @@ declare type NameAndEntity = { }; @customElement("ha-config-area-page") -class HaConfigAreaPage extends LitElement { +class HaConfigAreaPage extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property() public areaId!: string; - @property() public areas!: AreaRegistryEntry[]; - - @property() public devices!: DeviceRegistryEntry[]; - - @property() public entities!: EntityRegistryEntry[]; - @property({ type: Boolean, reflect: true }) public narrow!: boolean; @property() public isWide!: boolean; @@ -71,6 +72,12 @@ class HaConfigAreaPage extends LitElement { @property() public route!: Route; + @state() public _areas!: AreaRegistryEntry[]; + + @state() public _devices!: DeviceRegistryEntry[]; + + @state() public _entities!: EntityRegistryEntry[]; + @state() private _related?: RelatedResult; private _logbookTime = { recent: 86400 }; @@ -89,7 +96,7 @@ class HaConfigAreaPage extends LitElement { registryDevices: DeviceRegistryEntry[], registryEntities: EntityRegistryEntry[] ) => { - const devices = new Map(); + const devices = new Map(); for (const device of registryDevices) { if (device.area_id === areaId) { @@ -105,7 +112,7 @@ class HaConfigAreaPage extends LitElement { if (entity.area_id === areaId) { entities.push(entity); } - } else if (devices.has(entity.device_id)) { + } else if (entity.device_id && devices.has(entity.device_id)) { indirectEntities.push(entity); } } @@ -118,6 +125,10 @@ class HaConfigAreaPage extends LitElement { } ); + private _allDeviceIds = memoizeOne((devices: DeviceRegistryEntry[]) => + devices.map((device) => device.id) + ); + private _allEntities = memoizeOne( (memberships: { entities: EntityRegistryEntry[]; @@ -140,8 +151,26 @@ class HaConfigAreaPage extends LitElement { } } + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeAreaRegistry(this.hass.connection, (areas) => { + this._areas = areas; + }), + subscribeDeviceRegistry(this.hass.connection, (entries) => { + this._devices = entries; + }), + subscribeEntityRegistry(this.hass.connection, (entries) => { + this._entities = entries; + }), + ]; + } + protected render(): TemplateResult { - const area = this._area(this.areaId, this.areas); + if (!this._areas || !this._devices || !this._entities) { + return html``; + } + + const area = this._area(this.areaId, this._areas); if (!area) { return html` @@ -154,8 +183,8 @@ class HaConfigAreaPage extends LitElement { const memberships = this._memberships( this.areaId, - this.devices, - this.entities + this._devices, + this._entities ); const { devices, entities } = memberships; @@ -465,6 +494,7 @@ class HaConfigAreaPage extends LitElement { .hass=${this.hass} .time=${this._logbookTime} .entityIds=${this._allEntities(memberships)} + .deviceIds=${this._allDeviceIds(memberships.devices)} virtualize narrow no-icon diff --git a/src/panels/config/areas/ha-config-areas-dashboard.ts b/src/panels/config/areas/ha-config-areas-dashboard.ts index a82b567eee..d4e6c1703d 100644 --- a/src/panels/config/areas/ha-config-areas-dashboard.ts +++ b/src/panels/config/areas/ha-config-areas-dashboard.ts @@ -1,6 +1,7 @@ import { mdiHelpCircle, mdiPlus } from "@mdi/js"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators"; +import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; import "../../../components/ha-fab"; @@ -9,12 +10,20 @@ import "../../../components/ha-svg-icon"; import { AreaRegistryEntry, createAreaRegistryEntry, + subscribeAreaRegistry, } from "../../../data/area_registry"; -import type { DeviceRegistryEntry } from "../../../data/device_registry"; -import type { EntityRegistryEntry } from "../../../data/entity_registry"; +import { + DeviceRegistryEntry, + subscribeDeviceRegistry, +} from "../../../data/device_registry"; +import { + EntityRegistryEntry, + subscribeEntityRegistry, +} from "../../../data/entity_registry"; import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-tabs-subpage"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { HomeAssistant, Route } from "../../../types"; import "../ha-config-section"; import { configSections } from "../ha-panel-config"; @@ -24,7 +33,7 @@ import { } from "./show-dialog-area-registry-detail"; @customElement("ha-config-areas-dashboard") -export class HaConfigAreasDashboard extends LitElement { +export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property() public isWide?: boolean; @@ -33,13 +42,13 @@ export class HaConfigAreasDashboard extends LitElement { @property() public route!: Route; - @property() public areas!: AreaRegistryEntry[]; + @state() private _areas!: AreaRegistryEntry[]; - @property() public devices!: DeviceRegistryEntry[]; + @state() private _devices!: DeviceRegistryEntry[]; - @property() public entities!: EntityRegistryEntry[]; + @state() private _entities!: EntityRegistryEntry[]; - private _areas = memoizeOne( + private _processAreas = memoizeOne( ( areas: AreaRegistryEntry[], devices: DeviceRegistryEntry[], @@ -75,6 +84,20 @@ export class HaConfigAreasDashboard extends LitElement { }) ); + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeAreaRegistry(this.hass.connection, (areas) => { + this._areas = areas; + }), + subscribeDeviceRegistry(this.hass.connection, (entries) => { + this._devices = entries; + }), + subscribeEntityRegistry(this.hass.connection, (entries) => { + this._entities = entries; + }), + ]; + } + protected render(): TemplateResult { return html` { - this._configEntries = configEntries.sort((conf1, conf2) => - stringCompare(conf1.title, conf2.title) - ); - }); - if (this._unsubs) { - return; - } - this._unsubs = [ - subscribeAreaRegistry(this.hass.connection, (areas) => { - this._areas = areas; - }), - subscribeDeviceRegistry(this.hass.connection, (entries) => { - this._deviceRegistryEntries = entries; - }), - subscribeEntityRegistry(this.hass.connection, (entries) => { - this._entityRegistryEntries = entries; - }), - ]; - } } declare global { diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts index 7f019170ab..8e989de7c3 100644 --- a/src/panels/config/devices/ha-config-device-page.ts +++ b/src/panels/config/devices/ha-config-device-page.ts @@ -134,6 +134,8 @@ export class HaConfigDevicePage extends LitElement { ) ); + private _deviceIdInList = memoizeOne((deviceId: string) => [deviceId]); + private _entityIds = memoizeOne( (entries: EntityRegistryStateEntry[]): string[] => entries.map((entry) => entry.entity_id) @@ -592,7 +594,8 @@ export class HaConfigDevicePage extends LitElement { curValue.includes(val)) + ) { + changed = true; + break; + } + } + + if (changed) { this.refresh(true); return; } + if (this._filterAlwaysEmptyResults) { + return; + } + // We only need to fetch again if we track recent entries for an entity if ( !("recent" in this.time) || !changedProps.has("hass") || - !this.entityId + !this.entityIds ) { return; } @@ -146,7 +172,7 @@ export class HaLogbook extends LitElement { // Refresh data if we know the entity has changed. if ( !oldHass || - ensureArray(this.entityId).some( + ensureArray(this.entityIds).some( (entityId) => this.hass.states[entityId] !== oldHass?.states[entityId] ) ) { @@ -155,9 +181,34 @@ export class HaLogbook extends LitElement { } } + private get _filterAlwaysEmptyResults(): boolean { + const entityIds = ensureArray(this.entityIds); + const deviceIds = ensureArray(this.deviceIds); + + // If all specified filters are empty lists, we can return an empty list. + return ( + (entityIds || deviceIds) && + (!entityIds || entityIds.length === 0) && + (!deviceIds || deviceIds.length === 0) + ); + } + private async _getLogBookData() { this._renderId += 1; const renderId = this._renderId; + this._error = undefined; + + if (this._filterAlwaysEmptyResults) { + this._logbookEntries = []; + this._lastLogbookDate = undefined; + return; + } + + this._updateUsers(); + if (this.hass.user?.is_admin) { + this._updateTraceContexts(); + } + let startTime: Date; let endTime: Date; let appendData = false; @@ -173,34 +224,21 @@ export class HaLogbook extends LitElement { endTime = new Date(); } - const entityIdFilter = this.entityId - ? ensureArray(this.entityId) - : undefined; - let newEntries: LogbookEntry[]; - if (entityIdFilter?.length === 0) { - // filtering by 0 entities, means we never can have any results - newEntries = []; - } else { - this._updateUsers(); - if (this.hass.user?.is_admin) { - this._updateTraceContexts(); - } - - try { - newEntries = await getLogbookData( - this.hass, - startTime.toISOString(), - endTime.toISOString(), - entityIdFilter ? entityIdFilter.toString() : undefined - ); - } catch (err: any) { - if (renderId === this._renderId) { - this._error = err.message; - } - return; + try { + newEntries = await getLogbookData( + this.hass, + startTime.toISOString(), + endTime.toISOString(), + ensureArray(this.entityIds), + ensureArray(this.deviceIds) + ); + } catch (err: any) { + if (renderId === this._renderId) { + this._error = err.message; } + return; } // New render happening. @@ -208,6 +246,10 @@ export class HaLogbook extends LitElement { return; } + // Put newest ones on top. Reverse works in-place so + // make a copy first. + newEntries = [...newEntries].reverse(); + this._logbookEntries = appendData && this._logbookEntries ? newEntries.concat(...this._logbookEntries) diff --git a/src/panels/logbook/ha-panel-logbook.ts b/src/panels/logbook/ha-panel-logbook.ts index 7a64a8dbe8..65ba508e7f 100644 --- a/src/panels/logbook/ha-panel-logbook.ts +++ b/src/panels/logbook/ha-panel-logbook.ts @@ -38,7 +38,7 @@ export class HaPanelLogbook extends LitElement { @state() _time: { range: [Date, Date] }; - @state() _entityId = ""; + @state() _entityIds?: string[]; @property({ reflect: true, type: Boolean }) rtl = false; @@ -85,7 +85,7 @@ export class HaPanelLogbook extends LitElement { @@ -157,15 +157,30 @@ export class HaPanelLogbook extends LitElement { this.rtl = computeRTL(this.hass); } } - - this._applyURLParams(); } private _applyURLParams() { const searchParams = new URLSearchParams(location.search); if (searchParams.has("entity_id")) { - this._entityId = searchParams.get("entity_id") ?? ""; + const entityIdsRaw = searchParams.get("entity_id"); + + if (!entityIdsRaw) { + this._entityIds = undefined; + } else { + const entityIds = entityIdsRaw.split(",").sort(); + + // Check if different + if ( + !this._entityIds || + entityIds.length !== this._entityIds.length || + this._entityIds.every((val, idx) => val === entityIds[idx]) + ) { + this._entityIds = entityIds; + } + } + } else { + this._entityIds = undefined; } const startDateStr = searchParams.get("start_date"); @@ -199,19 +214,19 @@ export class HaPanelLogbook extends LitElement { endDate.setDate(endDate.getDate() + 1); endDate.setMilliseconds(endDate.getMilliseconds() - 1); } - this._time = { range: [startDate, endDate] }; this._updatePath({ - start_date: this._time.range[0].toISOString(), - end_date: this._time.range[1].toISOString(), + start_date: startDate.toISOString(), + end_date: endDate.toISOString(), }); } private _entityPicked(ev) { - this._entityId = ev.target.value; - this._updatePath({ entity_id: this._entityId }); + this._updatePath({ + entity_id: ev.target.value || undefined, + }); } - private _updatePath(update: Record) { + private _updatePath(update: Record) { const params = extractSearchParamsObject(); for (const [key, value] of Object.entries(update)) { if (value === undefined) { diff --git a/src/panels/lovelace/cards/hui-logbook-card.ts b/src/panels/lovelace/cards/hui-logbook-card.ts index 963644fdf8..a89cb4f048 100644 --- a/src/panels/lovelace/cards/hui-logbook-card.ts +++ b/src/panels/lovelace/cards/hui-logbook-card.ts @@ -120,7 +120,7 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { Date: Thu, 19 May 2022 23:12:17 -0500 Subject: [PATCH 057/112] Compute the icon based on the logbook state and not the current state (#12725) Co-authored-by: Bram Kragten --- src/panels/logbook/ha-logbook-renderer.ts | 63 +++++++++++++---------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/src/panels/logbook/ha-logbook-renderer.ts b/src/panels/logbook/ha-logbook-renderer.ts index d89be02f28..e2baefb672 100644 --- a/src/panels/logbook/ha-logbook-renderer.ts +++ b/src/panels/logbook/ha-logbook-renderer.ts @@ -7,6 +7,7 @@ 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 { DOMAINS_WITH_DYNAMIC_PICTURE } from "../../common/const"; @@ -15,7 +16,6 @@ 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 { domainIconWithoutDefault } from "../../common/entity/domain_icon"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { computeRTL, emitRTLDirection } from "../../common/util/compute_rtl"; import "../../components/entity/state-badge"; @@ -131,7 +131,7 @@ class HaLogbookRenderer extends LitElement { const seenEntityIds: string[] = []; const previous = this.entries[index - 1]; - const stateObj = item.entity_id + const currentStateObj = item.entity_id ? this.hass.states[item.entity_id] : undefined; const item_username = @@ -140,26 +140,37 @@ class HaLogbookRenderer extends LitElement { ? computeDomain(item.entity_id) : // Domain is there if there is no entity ID. item.domain!; - const overrideIcon = - item.icon || - (item.domain && !stateObj - ? domainIconWithoutDefault(item.domain!) - : undefined); - const overrideImage = !DOMAINS_WITH_DYNAMIC_PICTURE.has(domain) - ? stateObj?.attributes.entity_picture_local || - stateObj?.attributes.entity_picture || - (!stateObj && - !overrideIcon && - item.domain && - isComponentLoaded(this.hass, item.domain) - ? brandsUrl({ - domain: item.domain!, - type: "icon", - useFallback: true, - darkOptimized: this.hass.themes?.darkMode, - }) - : undefined) - : undefined; + const historicStateObj = item.entity_id ? ({ + entity_id: item.entity_id, + state: item.state, + attributes: { + // Rebuild the historical state by copying static attributes only + device_class: currentStateObj?.attributes.device_class, + source_type: currentStateObj?.attributes.source_type, + has_date: currentStateObj?.attributes.has_date, + has_time: currentStateObj?.attributes.has_time, + // We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering, + // as they would present a false state in the log (played media right now vs actual historic data). + entity_picture_local: DOMAINS_WITH_DYNAMIC_PICTURE.has(domain) + ? undefined + : currentStateObj?.attributes.entity_picture_local, + entity_picture: DOMAINS_WITH_DYNAMIC_PICTURE.has(domain) + ? undefined + : currentStateObj?.attributes.entity_picture, + }, + }) : undefined; + const overrideImage = + !historicStateObj && + !item.icon && + domain && + isComponentLoaded(this.hass, domain) + ? brandsUrl({ + domain: domain!, + type: "icon", + useFallback: true, + darkOptimized: this.hass.themes?.darkMode, + }) + : undefined; return html`
@@ -178,14 +189,12 @@ class HaLogbookRenderer extends LitElement {
${!this.noIcon - ? // We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering, - // as they would present a false state in the log (played media right now vs actual historic data). - html` + ? html` ` From 6ab19d66d5b5c031dfd2677345ef0247d5ef0c23 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 20 May 2022 06:20:18 +0200 Subject: [PATCH 058/112] Add option to compare energy graphs with previous period (#12723) Co-authored-by: Paulus Schoutsen --- src/data/energy.ts | 64 ++++- src/panels/energy/ha-panel-energy.ts | 13 +- .../energy/strategies/energy-strategy.ts | 5 + .../cards/energy/hui-energy-compare-card.ts | 106 ++++++++ .../energy/hui-energy-date-selection-card.ts | 12 +- .../cards/energy/hui-energy-gas-graph-card.ts | 160 ++++++++---- .../energy/hui-energy-solar-graph-card.ts | 181 +++++++++---- .../energy/hui-energy-usage-graph-card.ts | 239 ++++++++++++------ src/panels/lovelace/cards/types.ts | 4 + .../components/hui-energy-period-selector.ts | 45 ++++ .../create-element/create-card-element.ts | 1 + 11 files changed, 648 insertions(+), 182 deletions(-) create mode 100644 src/panels/lovelace/cards/energy/hui-energy-compare-card.ts diff --git a/src/data/energy.ts b/src/data/energy.ts index cf49d095c2..54c2577ce5 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -1,5 +1,8 @@ import { + addDays, addHours, + addMilliseconds, + addMonths, differenceInDays, endOfToday, endOfYesterday, @@ -14,9 +17,9 @@ import { ConfigEntry, getConfigEntries } from "./config_entries"; import { subscribeEntityRegistry } from "./entity_registry"; import { fetchStatistics, + getStatisticMetadata, Statistics, StatisticsMetaData, - getStatisticMetadata, } from "./history"; const energyCollectionKeys: (string | undefined)[] = []; @@ -232,19 +235,24 @@ export const energySourcesByType = (prefs: EnergyPreferences) => export interface EnergyData { start: Date; end?: Date; + startCompare?: Date; + endCompare?: Date; prefs: EnergyPreferences; info: EnergyInfo; stats: Statistics; + statsCompare: Statistics; co2SignalConfigEntry?: ConfigEntry; co2SignalEntity?: string; fossilEnergyConsumption?: FossilEnergyConsumption; + fossilEnergyConsumptionCompare?: FossilEnergyConsumption; } const getEnergyData = async ( hass: HomeAssistant, prefs: EnergyPreferences, start: Date, - end?: Date + end?: Date, + compare?: boolean ): Promise => { const [configEntries, entityRegistryEntries, info] = await Promise.all([ getConfigEntries(hass, { domain: "co2signal" }), @@ -350,6 +358,8 @@ const getEnergyData = async ( } const dayDifference = differenceInDays(end || new Date(), start); + const period = + dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"; // Subtract 1 hour from start to get starting point data const startMinHour = addHours(start, -1); @@ -359,10 +369,34 @@ const getEnergyData = async ( startMinHour, end, statIDs, - dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour" + period ); + let statsCompare; + let startCompare; + let endCompare; + if (compare) { + if (dayDifference > 27 && dayDifference < 32) { + // When comparing a month, we want to start at the begining of the month + startCompare = addMonths(start, -1); + } else { + startCompare = addDays(start, (dayDifference + 1) * -1); + } + + const compareStartMinHour = addHours(startCompare, -1); + endCompare = addMilliseconds(start, -1); + + statsCompare = await fetchStatistics( + hass!, + compareStartMinHour, + endCompare, + statIDs, + period + ); + } + let fossilEnergyConsumption: FossilEnergyConsumption | undefined; + let fossilEnergyConsumptionCompare: FossilEnergyConsumption | undefined; if (co2SignalEntity !== undefined) { fossilEnergyConsumption = await getFossilEnergyConsumption( @@ -373,6 +407,16 @@ const getEnergyData = async ( end, dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour" ); + if (compare) { + fossilEnergyConsumptionCompare = await getFossilEnergyConsumption( + hass!, + startCompare, + consumptionStatIDs, + co2SignalEntity, + endCompare, + dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour" + ); + } } Object.values(stats).forEach((stat) => { @@ -388,15 +432,19 @@ const getEnergyData = async ( } }); - const data = { + const data: EnergyData = { start, end, + startCompare, + endCompare, info, prefs, stats, + statsCompare, co2SignalConfigEntry, co2SignalEntity, fossilEnergyConsumption, + fossilEnergyConsumptionCompare, }; return data; @@ -405,9 +453,11 @@ const getEnergyData = async ( export interface EnergyCollection extends Collection { start: Date; end?: Date; + compare?: boolean; prefs?: EnergyPreferences; clearPrefs(): void; setPeriod(newStart: Date, newEnd?: Date): void; + setCompare(compare: boolean): void; _refreshTimeout?: number; _updatePeriodTimeout?: number; _active: number; @@ -478,7 +528,8 @@ export const getEnergyDataCollection = ( hass, collection.prefs, collection.start, - collection.end + collection.end, + collection.compare ); } ) as EnergyCollection; @@ -534,6 +585,9 @@ export const getEnergyDataCollection = ( collection._updatePeriodTimeout = undefined; } }; + collection.setCompare = (compare: boolean) => { + collection.compare = compare; + }; return collection; }; diff --git a/src/panels/energy/ha-panel-energy.ts b/src/panels/energy/ha-panel-energy.ts index fe5c64e361..af114ad97b 100644 --- a/src/panels/energy/ha-panel-energy.ts +++ b/src/panels/energy/ha-panel-energy.ts @@ -1,7 +1,5 @@ import "@polymer/app-layout/app-header/app-header"; import "@polymer/app-layout/app-toolbar/app-toolbar"; -import "@material/mwc-tab"; -import "@material/mwc-tab-bar"; import { css, CSSResultGroup, @@ -12,14 +10,13 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import "../../components/ha-menu-button"; -import "../../layouts/ha-app-layout"; - -import { haStyle } from "../../resources/styles"; -import "../lovelace/views/hui-view"; -import { HomeAssistant } from "../../types"; -import { Lovelace } from "../lovelace/types"; import { LovelaceConfig } from "../../data/lovelace"; +import "../../layouts/ha-app-layout"; +import { haStyle } from "../../resources/styles"; +import { HomeAssistant } from "../../types"; import "../lovelace/components/hui-energy-period-selector"; +import { Lovelace } from "../lovelace/types"; +import "../lovelace/views/hui-view"; const LOVELACE_CONFIG: LovelaceConfig = { views: [ diff --git a/src/panels/energy/strategies/energy-strategy.ts b/src/panels/energy/strategies/energy-strategy.ts index 4ba3125124..3283c7c8e1 100644 --- a/src/panels/energy/strategies/energy-strategy.ts +++ b/src/panels/energy/strategies/energy-strategy.ts @@ -60,6 +60,11 @@ export class EnergyStrategy { }); } + view.cards!.push({ + type: "energy-compare", + collection_key: "energy_dashboard", + }); + // Only include if we have a grid source. if (hasGrid) { view.cards!.push({ diff --git a/src/panels/lovelace/cards/energy/hui-energy-compare-card.ts b/src/panels/lovelace/cards/energy/hui-energy-compare-card.ts new file mode 100644 index 0000000000..0d49566b04 --- /dev/null +++ b/src/panels/lovelace/cards/energy/hui-energy-compare-card.ts @@ -0,0 +1,106 @@ +import { differenceInDays, endOfDay } from "date-fns"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { formatDate } from "../../../../common/datetime/format_date"; +import { EnergyData, getEnergyDataCollection } from "../../../../data/energy"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import { HomeAssistant } from "../../../../types"; +import { LovelaceCard } from "../../types"; +import { EnergyCardBaseConfig } from "../types"; + +@customElement("hui-energy-compare-card") +export class HuiEnergyCompareCard + extends SubscribeMixin(LitElement) + implements LovelaceCard +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: EnergyCardBaseConfig; + + @state() private _start?: Date; + + @state() private _end?: Date; + + @state() private _startCompare?: Date; + + @state() private _endCompare?: Date; + + public getCardSize(): Promise | number { + return 1; + } + + public setConfig(config: EnergyCardBaseConfig): void { + this._config = config; + } + + protected hassSubscribeRequiredHostProps = ["_config"]; + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + getEnergyDataCollection(this.hass, { + key: this._config!.collection_key, + }).subscribe((data) => this._update(data)), + ]; + } + + protected render(): TemplateResult { + if (!this._startCompare || !this._endCompare) { + return html``; + } + + const dayDifference = differenceInDays( + this._endCompare, + this._startCompare + ); + + return html` + + You are comparing the period + ${formatDate(this._start!, this.hass.locale)}${dayDifference > 0 + ? ` - + ${formatDate(this._end || endOfDay(new Date()), this.hass.locale)}` + : ""} + with period + ${formatDate(this._startCompare, this.hass.locale)}${dayDifference > + 0 + ? ` - + ${formatDate(this._endCompare, this.hass.locale)}` + : ""} + + `; + } + + private _update(data: EnergyData): void { + this._start = data.start; + this._end = data.end; + this._startCompare = data.startCompare; + this._endCompare = data.endCompare; + } + + private _stopCompare(): void { + const energyCollection = getEnergyDataCollection(this.hass, { + key: this._config!.collection_key, + }); + energyCollection.setCompare(false); + energyCollection.refresh(); + } + + static get styles(): CSSResultGroup { + return css` + mwc-button { + width: max-content; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-energy-compare-card": HuiEnergyCompareCard; + } +} diff --git a/src/panels/lovelace/cards/energy/hui-energy-date-selection-card.ts b/src/panels/lovelace/cards/energy/hui-energy-date-selection-card.ts index 5a2fdedbfd..d064e44d18 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-date-selection-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-date-selection-card.ts @@ -1,8 +1,8 @@ -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { HomeAssistant } from "../../../../types"; import { LovelaceCard } from "../../types"; -import { EnergyDevicesGraphCardConfig } from "../types"; +import { EnergyCardBaseConfig } from "../types"; import "../../components/hui-energy-period-selector"; @customElement("hui-energy-date-selection-card") @@ -12,13 +12,13 @@ export class HuiEnergyDateSelectionCard { @property({ attribute: false }) public hass!: HomeAssistant; - @state() private _config?: EnergyDevicesGraphCardConfig; + @state() private _config?: EnergyCardBaseConfig; public getCardSize(): Promise | number { return 1; } - public setConfig(config: EnergyDevicesGraphCardConfig): void { + public setConfig(config: EnergyCardBaseConfig): void { this._config = config; } @@ -34,10 +34,6 @@ export class HuiEnergyDateSelectionCard > `; } - - static get styles(): CSSResultGroup { - return css``; - } } declare global { diff --git a/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts index 113f586fc8..17714b42f9 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts @@ -1,9 +1,3 @@ -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { UnsubscribeFunc } from "home-assistant-js-websocket"; -import memoizeOne from "memoize-one"; -import { classMap } from "lit/directives/class-map"; -import "../../../../components/ha-card"; import { ChartData, ChartDataset, @@ -13,13 +7,16 @@ import { import { addHours, differenceInDays, + differenceInHours, endOfToday, isToday, startOfToday, -} from "date-fns/esm"; -import { HomeAssistant } from "../../../../types"; -import { LovelaceCard } from "../../types"; -import { EnergyGasGraphCardConfig } from "../types"; +} from "date-fns"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; import { hex2rgb, lab2rgb, @@ -27,21 +24,27 @@ import { rgb2lab, } from "../../../../common/color/convert-color"; import { labBrighten, labDarken } from "../../../../common/color/lab"; -import { - EnergyData, - getEnergyDataCollection, - getEnergyGasUnit, - GasSourceTypeEnergyPreference, -} from "../../../../data/energy"; +import { formatDateShort } from "../../../../common/datetime/format_date"; +import { formatTime } from "../../../../common/datetime/format_time"; import { computeStateName } from "../../../../common/entity/compute_state_name"; -import "../../../../components/chart/ha-chart-base"; import { formatNumber, numberFormatToLocale, } from "../../../../common/number/format_number"; -import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import "../../../../components/chart/ha-chart-base"; +import "../../../../components/ha-card"; +import { + EnergyData, + GasSourceTypeEnergyPreference, + getEnergyDataCollection, + getEnergyGasUnit, +} from "../../../../data/energy"; +import { Statistics } from "../../../../data/history"; import { FrontendLocaleData } from "../../../../data/translation"; -import { formatTime } from "../../../../common/datetime/format_time"; +import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; +import { HomeAssistant } from "../../../../types"; +import { LovelaceCard } from "../../types"; +import { EnergyGasGraphCardConfig } from "../types"; @customElement("hui-energy-gas-graph-card") export class HuiEnergyGasGraphCard @@ -60,6 +63,10 @@ export class HuiEnergyGasGraphCard @state() private _end = endOfToday(); + @state() private _compareStart?: Date; + + @state() private _compareEnd?: Date; + @state() private _unit?: string; protected hassSubscribeRequiredHostProps = ["_config"]; @@ -101,7 +108,9 @@ export class HuiEnergyGasGraphCard this._start, this._end, this.hass.locale, - this._unit + this._unit, + this._compareStart, + this._compareEnd )} chart-type="bar" > @@ -124,10 +133,24 @@ export class HuiEnergyGasGraphCard start: Date, end: Date, locale: FrontendLocaleData, - unit?: string + unit?: string, + compareStart?: Date, + compareEnd?: Date ): ChartOptions => { const dayDifference = differenceInDays(end, start); - return { + const compare = compareStart !== undefined && compareEnd !== undefined; + if (compare) { + const difference = differenceInHours(end, start); + const differenceCompare = differenceInHours(compareEnd!, compareStart!); + // If the compare period doesn't match the main period, adjust them to match + if (differenceCompare > difference) { + end = addHours(end, differenceCompare - difference); + } else if (difference > differenceCompare) { + compareEnd = addHours(compareEnd!, difference - differenceCompare); + } + } + + const options: ChartOptions = { parsing: false, animation: false, scales: { @@ -193,7 +216,9 @@ export class HuiEnergyGasGraphCard return datasets[0].label; } const date = new Date(datasets[0].parsed.x); - return `${formatTime(date, locale)} – ${formatTime( + return `${ + compare ? `${formatDateShort(date, locale)}: ` : "" + }${formatTime(date, locale)} – ${formatTime( addHours(date, 1), locale )}`; @@ -227,6 +252,15 @@ export class HuiEnergyGasGraphCard // @ts-expect-error locale: numberFormatToLocale(locale), }; + if (compare) { + options.scales!.xAxisCompare = { + ...(options.scales!.x as Record), + suggestedMin: compareStart!.getTime(), + suggestedMax: compareEnd!.getTime(), + display: false, + }; + } + return options; } ); @@ -238,15 +272,58 @@ export class HuiEnergyGasGraphCard this._unit = getEnergyGasUnit(this.hass, energyData.prefs) || "m³"; - const datasets: ChartDataset<"bar">[] = []; + const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = []; const computedStyles = getComputedStyle(this); const gasColor = computedStyles .getPropertyValue("--energy-gas-color") .trim(); + datasets.push( + ...this._processDataSet(energyData.stats, gasSources, gasColor) + ); + + if (energyData.statsCompare) { + // Add empty dataset to align the bars + datasets.push({ + order: 0, + data: [], + }); + datasets.push({ + order: 999, + data: [], + xAxisID: "xAxisCompare", + }); + + datasets.push( + ...this._processDataSet( + energyData.statsCompare, + gasSources, + gasColor, + true + ) + ); + } + + this._start = energyData.start; + this._end = energyData.end || endOfToday(); + + this._compareStart = energyData.startCompare; + this._compareEnd = energyData.endCompare; + + this._chartData = { + datasets, + }; + } + + private _processDataSet( + statistics: Statistics, + gasSources: GasSourceTypeEnergyPreference[], + gasColor: string, + compare = false + ) { + const data: ChartDataset<"bar", ScatterDataPoint[]>[] = []; gasSources.forEach((source, idx) => { - const data: ChartDataset<"bar" | "line">[] = []; const entity = this.hass.states[source.stat_energy_from]; const modifiedColor = @@ -265,8 +342,8 @@ export class HuiEnergyGasGraphCard const gasConsumptionData: ScatterDataPoint[] = []; // Process gas consumption data. - if (source.stat_energy_from in energyData.stats) { - const stats = energyData.stats[source.stat_energy_from]; + if (source.stat_energy_from in statistics) { + const stats = statistics[source.stat_energy_from]; for (const point of stats) { if (point.sum === null) { @@ -290,26 +367,17 @@ export class HuiEnergyGasGraphCard } } - if (gasConsumptionData.length) { - data.push({ - label: entity ? computeStateName(entity) : source.stat_energy_from, - borderColor, - backgroundColor: borderColor + "7F", - data: gasConsumptionData, - stack: "gas", - }); - } - - // Concat two arrays - Array.prototype.push.apply(datasets, data); + data.push({ + label: entity ? computeStateName(entity) : source.stat_energy_from, + borderColor: compare ? borderColor + "7F" : borderColor, + backgroundColor: compare ? borderColor + "32" : borderColor + "7F", + data: gasConsumptionData, + order: 1, + stack: "gas", + xAxisID: compare ? "xAxisCompare" : undefined, + }); }); - - this._start = energyData.start; - this._end = energyData.end || endOfToday(); - - this._chartData = { - datasets, - }; + return data; } static get styles(): CSSResultGroup { diff --git a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts index 5c6c2f537c..f3389b657d 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts @@ -7,6 +7,7 @@ import { import { addHours, differenceInDays, + differenceInHours, endOfToday, isToday, startOfToday, @@ -23,6 +24,7 @@ import { rgb2lab, } from "../../../../common/color/convert-color"; import { labBrighten, labDarken } from "../../../../common/color/lab"; +import { formatDateShort } from "../../../../common/datetime/format_date"; import { formatTime } from "../../../../common/datetime/format_time"; import { computeStateName } from "../../../../common/entity/compute_state_name"; import { @@ -38,6 +40,7 @@ import { getEnergySolarForecasts, SolarSourceTypeEnergyPreference, } from "../../../../data/energy"; +import { Statistics } from "../../../../data/history"; import { FrontendLocaleData } from "../../../../data/translation"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { HomeAssistant } from "../../../../types"; @@ -61,6 +64,10 @@ export class HuiEnergySolarGraphCard @state() private _end = endOfToday(); + @state() private _compareStart?: Date; + + @state() private _compareEnd?: Date; + protected hassSubscribeRequiredHostProps = ["_config"]; public hassSubscribe(): UnsubscribeFunc[] { @@ -99,7 +106,9 @@ export class HuiEnergySolarGraphCard .options=${this._createOptions( this._start, this._end, - this.hass.locale + this.hass.locale, + this._compareStart, + this._compareEnd )} chart-type="bar" > @@ -118,9 +127,27 @@ export class HuiEnergySolarGraphCard } private _createOptions = memoizeOne( - (start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => { + ( + start: Date, + end: Date, + locale: FrontendLocaleData, + compareStart?: Date, + compareEnd?: Date + ): ChartOptions => { const dayDifference = differenceInDays(end, start); - return { + const compare = compareStart !== undefined && compareEnd !== undefined; + if (compare) { + const difference = differenceInHours(end, start); + const differenceCompare = differenceInHours(compareEnd!, compareStart!); + // If the compare period doesn't match the main period, adjust them to match + if (differenceCompare > difference) { + end = addHours(end, differenceCompare - difference); + } else if (difference > differenceCompare) { + compareEnd = addHours(compareEnd!, difference - differenceCompare); + } + } + + const options: ChartOptions = { parsing: false, animation: false, scales: { @@ -163,7 +190,6 @@ export class HuiEnergySolarGraphCard ? "day" : "hour", }, - offset: true, }, y: { stacked: true, @@ -186,7 +212,9 @@ export class HuiEnergySolarGraphCard return datasets[0].label; } const date = new Date(datasets[0].parsed.x); - return `${formatTime(date, locale)} – ${formatTime( + return `${ + compare ? `${formatDateShort(date, locale)}: ` : "" + }${formatTime(date, locale)} – ${formatTime( addHours(date, 1), locale )}`; @@ -224,6 +252,15 @@ export class HuiEnergySolarGraphCard // @ts-expect-error locale: numberFormatToLocale(locale), }; + if (compare) { + options.scales!.xAxisCompare = { + ...(options.scales!.x as Record), + suggestedMin: compareStart!.getTime(), + suggestedMax: compareEnd!.getTime(), + display: false, + }; + } + return options; } ); @@ -244,20 +281,71 @@ export class HuiEnergySolarGraphCard } } - const datasets: ChartDataset<"bar">[] = []; + const datasets: ChartDataset<"bar" | "line">[] = []; const computedStyles = getComputedStyle(this); const solarColor = computedStyles .getPropertyValue("--energy-solar-color") .trim(); - const dayDifference = differenceInDays( - energyData.end || new Date(), - energyData.start + datasets.push( + ...this._processDataSet(energyData.stats, solarSources, solarColor) ); + if (energyData.statsCompare) { + // Add empty dataset to align the bars + datasets.push({ + order: 0, + data: [], + }); + datasets.push({ + order: 999, + data: [], + xAxisID: "xAxisCompare", + }); + + datasets.push( + ...this._processDataSet( + energyData.statsCompare, + solarSources, + solarColor, + true + ) + ); + } + + if (forecasts) { + datasets.push( + ...this._processForecast( + forecasts, + solarSources, + computedStyles.getPropertyValue("--primary-text-color"), + energyData.start, + energyData.end + ) + ); + } + + this._start = energyData.start; + this._end = energyData.end || endOfToday(); + + this._compareStart = energyData.startCompare; + this._compareEnd = energyData.endCompare; + + this._chartData = { + datasets, + }; + } + + private _processDataSet( + statistics: Statistics, + solarSources: SolarSourceTypeEnergyPreference[], + solarColor: string, + compare = false + ) { + const data: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + solarSources.forEach((source, idx) => { - const data: ChartDataset<"bar" | "line">[] = []; const entity = this.hass.states[source.stat_energy_from]; const modifiedColor = @@ -276,8 +364,8 @@ export class HuiEnergySolarGraphCard const solarProductionData: ScatterDataPoint[] = []; // Process solar production data. - if (source.stat_energy_from in energyData.stats) { - const stats = energyData.stats[source.stat_energy_from]; + if (source.stat_energy_from in statistics) { + const stats = statistics[source.stat_energy_from]; for (const point of stats) { if (point.sum === null) { @@ -301,23 +389,41 @@ export class HuiEnergySolarGraphCard } } - if (solarProductionData.length) { - data.push({ - label: this.hass.localize( - "ui.panel.lovelace.cards.energy.energy_solar_graph.production", - { - name: entity ? computeStateName(entity) : source.stat_energy_from, - } - ), - borderColor, - backgroundColor: borderColor + "7F", - data: solarProductionData, - stack: "solar", - }); - } + data.push({ + label: this.hass.localize( + "ui.panel.lovelace.cards.energy.energy_solar_graph.production", + { + name: entity ? computeStateName(entity) : source.stat_energy_from, + } + ), + borderColor: compare ? borderColor + "7F" : borderColor, + backgroundColor: compare ? borderColor + "32" : borderColor + "7F", + data: solarProductionData, + order: 1, + stack: "solar", + xAxisID: compare ? "xAxisCompare" : undefined, + }); + }); + + return data; + } + + private _processForecast( + forecasts: EnergySolarForecasts, + solarSources: SolarSourceTypeEnergyPreference[], + borderColor: string, + start: Date, + end?: Date + ) { + const data: ChartDataset<"line">[] = []; + + const dayDifference = differenceInDays(end || new Date(), start); + + // Process solar forecast data. + solarSources.forEach((source) => { + if (source.config_entry_solar_forecast) { + const entity = this.hass.states[source.stat_energy_from]; - // Process solar forecast data. - if (forecasts && source.config_entry_solar_forecast) { const forecastsData: Record | undefined = {}; source.config_entry_solar_forecast.forEach((configEntryId) => { if (!forecasts![configEntryId]) { @@ -326,10 +432,7 @@ export class HuiEnergySolarGraphCard Object.entries(forecasts![configEntryId].wh_hours).forEach( ([date, value]) => { const dateObj = new Date(date); - if ( - dateObj < energyData.start || - (energyData.end && dateObj > energyData.end) - ) { + if (dateObj < start || (end && dateObj > end)) { return; } if (dayDifference > 35) { @@ -372,9 +475,7 @@ export class HuiEnergySolarGraphCard ), fill: false, stepped: false, - borderColor: computedStyles.getPropertyValue( - "--primary-text-color" - ), + borderColor, borderDash: [7, 5], pointRadius: 0, data: solarForecastData, @@ -382,17 +483,9 @@ export class HuiEnergySolarGraphCard } } } - - // Concat two arrays - Array.prototype.push.apply(datasets, data); }); - this._start = energyData.start; - this._end = energyData.end || endOfToday(); - - this._chartData = { - datasets, - }; + return data; } static get styles(): CSSResultGroup { diff --git a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts index 8ba70e0269..eea121b0f9 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts @@ -1,7 +1,13 @@ -import { ChartData, ChartDataset, ChartOptions } from "chart.js"; +import { + ChartData, + ChartDataset, + ChartOptions, + ScatterDataPoint, +} from "chart.js"; import { addHours, differenceInDays, + differenceInHours, endOfToday, isToday, startOfToday, @@ -18,6 +24,7 @@ import { rgb2lab, } from "../../../../common/color/convert-color"; import { labBrighten, labDarken } from "../../../../common/color/lab"; +import { formatDateShort } from "../../../../common/datetime/format_date"; import { formatTime } from "../../../../common/datetime/format_time"; import { computeStateName } from "../../../../common/entity/compute_state_name"; import { @@ -27,6 +34,7 @@ import { import "../../../../components/chart/ha-chart-base"; import "../../../../components/ha-card"; import { EnergyData, getEnergyDataCollection } from "../../../../data/energy"; +import { Statistics } from "../../../../data/history"; import { FrontendLocaleData } from "../../../../data/translation"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { HomeAssistant } from "../../../../types"; @@ -50,6 +58,10 @@ export class HuiEnergyUsageGraphCard @state() private _end = endOfToday(); + @state() private _compareStart?: Date; + + @state() private _compareEnd?: Date; + protected hassSubscribeRequiredHostProps = ["_config"]; public hassSubscribe(): UnsubscribeFunc[] { @@ -88,7 +100,9 @@ export class HuiEnergyUsageGraphCard .options=${this._createOptions( this._start, this._end, - this.hass.locale + this.hass.locale, + this._compareStart, + this._compareEnd )} chart-type="bar" > @@ -107,9 +121,27 @@ export class HuiEnergyUsageGraphCard } private _createOptions = memoizeOne( - (start: Date, end: Date, locale: FrontendLocaleData): ChartOptions => { + ( + start: Date, + end: Date, + locale: FrontendLocaleData, + compareStart?: Date, + compareEnd?: Date + ): ChartOptions => { const dayDifference = differenceInDays(end, start); - return { + const compare = compareStart !== undefined && compareEnd !== undefined; + if (compare) { + const difference = differenceInHours(end, start); + const differenceCompare = differenceInHours(compareEnd!, compareStart!); + // If the compare period doesn't match the main period, adjust them to match + if (differenceCompare > difference) { + end = addHours(end, differenceCompare - difference); + } else if (difference > differenceCompare) { + compareEnd = addHours(compareEnd!, difference - differenceCompare); + } + } + + const options: ChartOptions = { parsing: false, animation: false, scales: { @@ -152,7 +184,6 @@ export class HuiEnergyUsageGraphCard ? "day" : "hour", }, - offset: true, }, y: { stacked: true, @@ -179,7 +210,9 @@ export class HuiEnergyUsageGraphCard return datasets[0].label; } const date = new Date(datasets[0].parsed.x); - return `${formatTime(date, locale)} – ${formatTime( + return `${ + compare ? `${formatDateShort(date, locale)}: ` : "" + }${formatTime(date, locale)} – ${formatTime( addHours(date, 1), locale )}`; @@ -240,13 +273,22 @@ export class HuiEnergyUsageGraphCard // @ts-expect-error locale: numberFormatToLocale(locale), }; + if (compare) { + options.scales!.xAxisCompare = { + ...(options.scales!.x as Record), + suggestedMin: compareStart!.getTime(), + suggestedMax: compareEnd!.getTime(), + display: false, + }; + } + return options; } ); private async _getStatistics(energyData: EnergyData): Promise { - const datasets: ChartDataset<"bar">[] = []; + const datasets: ChartDataset<"bar", ScatterDataPoint[]>[] = []; - const statistics: { + const statIds: { to_grid?: string[]; from_grid?: string[]; solar?: string[]; @@ -256,21 +298,21 @@ export class HuiEnergyUsageGraphCard for (const source of energyData.prefs.energy_sources) { if (source.type === "solar") { - if (statistics.solar) { - statistics.solar.push(source.stat_energy_from); + if (statIds.solar) { + statIds.solar.push(source.stat_energy_from); } else { - statistics.solar = [source.stat_energy_from]; + statIds.solar = [source.stat_energy_from]; } continue; } if (source.type === "battery") { - if (statistics.to_battery) { - statistics.to_battery.push(source.stat_energy_to); - statistics.from_battery!.push(source.stat_energy_from); + if (statIds.to_battery) { + statIds.to_battery.push(source.stat_energy_to); + statIds.from_battery!.push(source.stat_energy_from); } else { - statistics.to_battery = [source.stat_energy_to]; - statistics.from_battery = [source.stat_energy_from]; + statIds.to_battery = [source.stat_energy_to]; + statIds.from_battery = [source.stat_energy_from]; } continue; } @@ -281,41 +323,21 @@ export class HuiEnergyUsageGraphCard // grid source for (const flowFrom of source.flow_from) { - if (statistics.from_grid) { - statistics.from_grid.push(flowFrom.stat_energy_from); + if (statIds.from_grid) { + statIds.from_grid.push(flowFrom.stat_energy_from); } else { - statistics.from_grid = [flowFrom.stat_energy_from]; + statIds.from_grid = [flowFrom.stat_energy_from]; } } for (const flowTo of source.flow_to) { - if (statistics.to_grid) { - statistics.to_grid.push(flowTo.stat_energy_to); + if (statIds.to_grid) { + statIds.to_grid.push(flowTo.stat_energy_to); } else { - statistics.to_grid = [flowTo.stat_energy_to]; + statIds.to_grid = [flowTo.stat_energy_to]; } } } - this._start = energyData.start; - this._end = energyData.end || endOfToday(); - - const combinedData: { - to_grid?: { [statId: string]: { [start: string]: number } }; - to_battery?: { [statId: string]: { [start: string]: number } }; - from_grid?: { [statId: string]: { [start: string]: number } }; - used_grid?: { [statId: string]: { [start: string]: number } }; - used_solar?: { [statId: string]: { [start: string]: number } }; - used_battery?: { [statId: string]: { [start: string]: number } }; - } = {}; - - const summedData: { - to_grid?: { [start: string]: number }; - from_grid?: { [start: string]: number }; - to_battery?: { [start: string]: number }; - from_battery?: { [start: string]: number }; - solar?: { [start: string]: number }; - } = {}; - const computedStyles = getComputedStyle(this); const colors = { to_grid: computedStyles @@ -349,7 +371,88 @@ export class HuiEnergyUsageGraphCard ), }; - Object.entries(statistics).forEach(([key, statIds]) => { + this._start = energyData.start; + this._end = energyData.end || endOfToday(); + + this._compareStart = energyData.startCompare; + this._compareEnd = energyData.endCompare; + + datasets.push( + ...this._processDataSet(energyData.stats, statIds, colors, labels, false) + ); + + if (energyData.statsCompare) { + // Add empty dataset to align the bars + datasets.push({ + order: 0, + data: [], + }); + datasets.push({ + order: 999, + data: [], + xAxisID: "xAxisCompare", + }); + + datasets.push( + ...this._processDataSet( + energyData.statsCompare, + statIds, + colors, + labels, + true + ) + ); + } + + this._chartData = { + datasets, + }; + } + + private _processDataSet( + statistics: Statistics, + statIdsByCat: { + to_grid?: string[] | undefined; + from_grid?: string[] | undefined; + solar?: string[] | undefined; + to_battery?: string[] | undefined; + from_battery?: string[] | undefined; + }, + colors: { + to_grid: string; + to_battery: string; + from_grid: string; + used_grid: string; + used_solar: string; + used_battery: string; + }, + labels: { + used_grid: string; + used_solar: string; + used_battery: string; + }, + compare = false + ) { + const data: ChartDataset<"bar", ScatterDataPoint[]>[] = []; + + const combinedData: { + to_grid?: { [statId: string]: { [start: string]: number } }; + to_battery?: { [statId: string]: { [start: string]: number } }; + from_grid?: { [statId: string]: { [start: string]: number } }; + used_grid?: { [statId: string]: { [start: string]: number } }; + used_solar?: { [statId: string]: { [start: string]: number } }; + used_battery?: { [statId: string]: { [start: string]: number } }; + } = {}; + + const summedData: { + to_grid?: { [start: string]: number }; + from_grid?: { [start: string]: number }; + to_battery?: { [start: string]: number }; + from_battery?: { [start: string]: number }; + solar?: { [start: string]: number }; + } = {}; + + Object.entries(statIdsByCat).forEach(([key, statIds]) => { const sum = [ "solar", "to_grid", @@ -361,7 +464,7 @@ export class HuiEnergyUsageGraphCard const totalStats: { [start: string]: number } = {}; const sets: { [statId: string]: { [start: string]: number } } = {}; statIds!.forEach((id) => { - const stats = energyData.stats[id]; + const stats = statistics[id]; if (!stats) { return; } @@ -477,7 +580,6 @@ export class HuiEnergyUsageGraphCard Object.entries(combinedData).forEach(([type, sources]) => { Object.entries(sources).forEach(([statId, source], idx) => { - const data: ChartDataset<"bar">[] = []; const entity = this.hass.states[statId]; const modifiedColor = @@ -490,6 +592,20 @@ export class HuiEnergyUsageGraphCard ? rgb2hex(lab2rgb(modifiedColor)) : colors[type]; + const points: ScatterDataPoint[] = []; + // Process chart data. + for (const key of uniqueKeys) { + const value = source[key] || 0; + const date = new Date(key); + points.push({ + x: date.getTime(), + y: + value && ["to_grid", "to_battery"].includes(type) + ? -1 * value + : value, + }); + } + data.push({ label: type in labels @@ -499,38 +615,19 @@ export class HuiEnergyUsageGraphCard : statId, order: type === "used_solar" - ? 0 + ? 1 : type === "to_battery" ? Object.keys(combinedData).length - : idx + 1, - borderColor, - backgroundColor: borderColor + "7F", + : idx + 2, + borderColor: compare ? borderColor + "7F" : borderColor, + backgroundColor: compare ? borderColor + "32" : borderColor + "7F", stack: "stack", - data: [], + data: points, + xAxisID: compare ? "xAxisCompare" : undefined, }); - - // Process chart data. - for (const key of uniqueKeys) { - const value = source[key] || 0; - const date = new Date(key); - // @ts-expect-error - data[0].data.push({ - x: date.getTime(), - y: - value && ["to_grid", "to_battery"].includes(type) - ? -1 * value - : value, - }); - } - - // Concat two arrays - Array.prototype.push.apply(datasets, data); }); }); - - this._chartData = { - datasets, - }; + return data; } static get styles(): CSSResultGroup { diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index b4c2a74abc..f596046e63 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -97,6 +97,10 @@ export interface ButtonCardConfig extends LovelaceCardConfig { show_state?: boolean; } +export interface EnergyCardBaseConfig extends LovelaceCardConfig { + collection_key?: string; +} + export interface EnergySummaryCardConfig extends LovelaceCardConfig { type: "energy-summary"; title?: string; diff --git a/src/panels/lovelace/components/hui-energy-period-selector.ts b/src/panels/lovelace/components/hui-energy-period-selector.ts index f6283af3f2..cb906d8758 100644 --- a/src/panels/lovelace/components/hui-energy-period-selector.ts +++ b/src/panels/lovelace/components/hui-energy-period-selector.ts @@ -46,6 +46,8 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { @state() private _period?: "day" | "week" | "month" | "year"; + @state() private _compare? = false; + public connectedCallback() { super.connectedCallback(); toggleAttribute(this, "narrow", this.offsetWidth < 600); @@ -134,6 +136,14 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { dense @value-changed=${this._handleView} > + + Compare data +
`; @@ -216,6 +226,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { } private _updateDates(energyData: EnergyData): void { + this._compare = energyData.startCompare !== undefined; this._startDate = energyData.start; this._endDate = energyData.end || endOfToday(); const dayDifference = differenceInDays(this._endDate, this._startDate); @@ -231,6 +242,15 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { : undefined; } + private _toggleCompare() { + this._compare = !this._compare; + const energyCollection = getEnergyDataCollection(this.hass, { + key: "energy_dashboard", + }); + energyCollection.setCompare(this._compare); + energyCollection.refresh(); + } + static get styles(): CSSResultGroup { return css` .row { @@ -251,12 +271,37 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { } .period { display: flex; + flex-wrap: wrap; justify-content: flex-end; + align-items: flex-end; + } + mwc-button.active::before { + top: 0; + left: 0; + width: 100%; + height: 100%; + position: absolute; + background-color: currentColor; + opacity: 0; + pointer-events: none; + content: ""; + transition: opacity 15ms linear, background-color 15ms linear; + opacity: var(--mdc-icon-button-ripple-opacity, 0.12); + } + .compare { + position: relative; + margin-left: 8px; + width: max-content; + } + :host([narrow]) .compare { + margin-left: auto; + margin-top: 8px; } :host { --mdc-button-outline-color: currentColor; --primary-color: currentColor; --mdc-theme-primary: currentColor; + --mdc-theme-on-primary: currentColor; --mdc-button-disabled-outline-color: var(--disabled-text-color); --mdc-button-disabled-ink-color: var(--disabled-text-color); --mdc-icon-button-ripple-opacity: 0.2; diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index df2eb3b45d..03f22a9bdd 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -35,6 +35,7 @@ const LAZY_LOAD_TYPES = { calendar: () => import("../cards/hui-calendar-card"), conditional: () => import("../cards/hui-conditional-card"), "empty-state": () => import("../cards/hui-empty-state-card"), + "energy-compare": () => import("../cards/energy/hui-energy-compare-card"), "energy-carbon-consumed-gauge": () => import("../cards/energy/hui-energy-carbon-consumed-gauge-card"), "energy-date-selection": () => From 9a9eec40b295e85e10af5a1ca6ff0fc762ab083f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 19 May 2022 21:27:43 -0700 Subject: [PATCH 059/112] Add an application credentials display name (#12720) Co-authored-by: Bram Kragten Co-authored-by: Paulus Schoutsen --- src/data/application_credential.ts | 5 ++++- .../dialog-add-application-credential.ts | 18 +++++++++++++++++- .../ha-config-application-credentials.ts | 16 ++++++++++++---- src/translations/en.json | 2 ++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/data/application_credential.ts b/src/data/application_credential.ts index 0062301597..075ea5f721 100644 --- a/src/data/application_credential.ts +++ b/src/data/application_credential.ts @@ -9,6 +9,7 @@ export interface ApplicationCredential { domain: string; client_id: string; client_secret: string; + name: string; } export const fetchApplicationCredentialsConfig = async (hass: HomeAssistant) => @@ -25,13 +26,15 @@ export const createApplicationCredential = async ( hass: HomeAssistant, domain: string, clientId: string, - clientSecret: string + clientSecret: string, + name?: string ) => hass.callWS({ type: "application_credentials/create", domain, client_id: clientId, client_secret: clientSecret, + name, }); export const deleteApplicationCredential = async ( diff --git a/src/panels/config/application_credentials/dialog-add-application-credential.ts b/src/panels/config/application_credentials/dialog-add-application-credential.ts index f60ea765b1..eccf71e4fe 100644 --- a/src/panels/config/application_credentials/dialog-add-application-credential.ts +++ b/src/panels/config/application_credentials/dialog-add-application-credential.ts @@ -41,6 +41,8 @@ export class DialogAddApplicationCredential extends LitElement { @state() private _domain?: string; + @state() private _name?: string; + @state() private _clientId?: string; @state() private _clientSecret?: string; @@ -50,6 +52,7 @@ export class DialogAddApplicationCredential extends LitElement { public showDialog(params: AddApplicationCredentialDialogParams) { this._params = params; this._domain = ""; + this._name = ""; this._clientId = ""; this._clientSecret = ""; this._error = undefined; @@ -99,6 +102,18 @@ export class DialogAddApplicationCredential extends LitElement { required @value-changed=${this._handleDomainPicked} > + { const columns: DataTableColumnContainer = { + name: { + title: localize( + "ui.panel.config.application_credentials.picker.headers.name" + ), + width: "40%", + direction: "asc", + grows: true, + template: (_, entry: ApplicationCredential) => html`${entry.name}`, + }, clientId: { title: localize( "ui.panel.config.application_credentials.picker.headers.client_id" ), - width: "25%", + width: "30%", direction: "asc", - grows: true, + hidden: narrow, template: (_, entry: ApplicationCredential) => html`${entry.client_id}`, }, @@ -64,9 +73,8 @@ export class HaConfigApplicationCredentials extends LitElement { "ui.panel.config.application_credentials.picker.headers.application" ), sortable: true, - width: "20%", + width: "30%", direction: "asc", - hidden: narrow, template: (_, entry) => html`${domainToName(localize, entry.domain)}`, }, }; diff --git a/src/translations/en.json b/src/translations/en.json index f7aa3e75ee..3d9ec3ff9e 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2883,12 +2883,14 @@ "caption": "Add Application Credential", "create": "Create", "domain": "Integration", + "name": "Name", "client_id": "OAuth Client ID", "client_secret": "OAuth Client Secret" }, "picker": { "add_application_credential": "Add Application Credential", "headers": { + "name": "Name", "client_id": "OAuth Client ID", "application": "Integration" }, From fae1bcf0e014b3f88a01b04a3616498ccd389687 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 20 May 2022 11:25:19 -0700 Subject: [PATCH 060/112] Fixes logbook (#12740) --- src/data/logbook.ts | 1 + src/panels/logbook/ha-logbook.ts | 30 +++++++++++++------ src/panels/lovelace/cards/hui-logbook-card.ts | 2 +- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/data/logbook.ts b/src/data/logbook.ts index e5e37779ee..dffd273e5f 100644 --- a/src/data/logbook.ts +++ b/src/data/logbook.ts @@ -10,6 +10,7 @@ const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages"; export const CONTINUOUS_DOMAINS = ["proximity", "sensor"]; export interface LogbookEntry { + // Python timestamp. Do *1000 to get JS timestamp. when: number; name: string; message?: string; diff --git a/src/panels/logbook/ha-logbook.ts b/src/panels/logbook/ha-logbook.ts index 7b959bc912..c84ab62bfd 100644 --- a/src/panels/logbook/ha-logbook.ts +++ b/src/panels/logbook/ha-logbook.ts @@ -19,7 +19,12 @@ import "./ha-logbook-renderer"; export class HaLogbook extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property() public time!: { range: [Date, Date] } | { recent: number }; + @property() public time!: + | { range: [Date, Date] } + | { + // Seconds + recent: number; + }; @property() public entityIds?: string[]; @@ -211,17 +216,19 @@ export class HaLogbook extends LitElement { let startTime: Date; let endTime: Date; - let appendData = false; + let purgeBeforePythonTime: number | undefined; if ("range" in this.time) { [startTime, endTime] = this.time.range; - } else { - // Recent data - appendData = true; + } else if ("recent" in this.time) { + purgeBeforePythonTime = + new Date(new Date().getTime() - this.time.recent * 1000).getTime() / + 1000; startTime = - this._lastLogbookDate || - new Date(new Date().getTime() - 24 * 60 * 60 * 1000); + this._lastLogbookDate || new Date(purgeBeforePythonTime * 1000); endTime = new Date(); + } else { + throw new Error("Unexpected time specified"); } let newEntries: LogbookEntry[]; @@ -251,8 +258,13 @@ export class HaLogbook extends LitElement { newEntries = [...newEntries].reverse(); this._logbookEntries = - appendData && this._logbookEntries - ? newEntries.concat(...this._logbookEntries) + // If we have a purgeBeforeTime, it means we're in recent-mode and fetch batches + purgeBeforePythonTime && this._logbookEntries + ? newEntries.concat( + ...this._logbookEntries.filter( + (entry) => entry.when > purgeBeforePythonTime! + ) + ) : newEntries; this._lastLogbookDate = endTime; } diff --git a/src/panels/lovelace/cards/hui-logbook-card.ts b/src/panels/lovelace/cards/hui-logbook-card.ts index a89cb4f048..13aaf9a2ab 100644 --- a/src/panels/lovelace/cards/hui-logbook-card.ts +++ b/src/panels/lovelace/cards/hui-logbook-card.ts @@ -70,7 +70,7 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard { ...config, }; this._time = { - recent: this._config!.hours_to_show! * 60 * 60 * 1000, + recent: this._config!.hours_to_show! * 60 * 60, }; this._entityId = processConfigEntities(config.entities).map( (entity) => entity.entity From 8ac4a6d9005c1ec014681dda7cdd257fc6e1bd80 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 20 May 2022 17:28:06 -0700 Subject: [PATCH 061/112] Bumped version to 20220521.0 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index dcffba2294..ef71058525 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = home-assistant-frontend -version = 20220518.0 +version = 20220521.0 author = The Home Assistant Authors author_email = hello@home-assistant.io license = Apache-2.0 From 51c5ab33f040dd0a65f979f217d98b4e86219a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 23 May 2022 12:58:36 +0200 Subject: [PATCH 062/112] Stop closed event when selecting datadisk (#12749) --- src/panels/config/storage/dialog-move-datadisk.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/panels/config/storage/dialog-move-datadisk.ts b/src/panels/config/storage/dialog-move-datadisk.ts index ad9dec13f4..a005f29ebd 100644 --- a/src/panels/config/storage/dialog-move-datadisk.ts +++ b/src/panels/config/storage/dialog-move-datadisk.ts @@ -3,6 +3,7 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../../common/dom/fire_event"; +import { stopPropagation } from "../../../common/dom/stop_propagation"; import "../../../components/ha-circular-progress"; import "../../../components/ha-markdown"; import "../../../components/ha-select"; @@ -97,7 +98,7 @@ class MoveDatadiskDialog extends LitElement { "ui.panel.config.storage.datadisk.moving_desc" )}

` - : html` ${this._devices?.length + : html`${this._devices?.length ? html` ${this.hass.localize( "ui.panel.config.storage.datadisk.description", @@ -113,6 +114,7 @@ class MoveDatadiskDialog extends LitElement { "ui.panel.config.storage.datadisk.select_device" )} @selected=${this._select_device} + @closed=${stopPropagation} dialogInitialFocus > ${this._devices.map( From da106d278ce0a5d9d387a59221130831f1a36a5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 May 2022 12:58:50 -0500 Subject: [PATCH 063/112] Use logbook livestream when requesting a time window that includes the future (#12744) --- src/data/logbook.ts | 35 +++- src/panels/logbook/ha-logbook.ts | 229 +++++++++++++++++-------- src/panels/logbook/ha-panel-logbook.ts | 6 +- 3 files changed, 196 insertions(+), 74 deletions(-) diff --git a/src/data/logbook.ts b/src/data/logbook.ts index dffd273e5f..578be0dfbb 100644 --- a/src/data/logbook.ts +++ b/src/data/logbook.ts @@ -1,6 +1,6 @@ -import { HassEntity } from "home-assistant-js-websocket"; -import { BINARY_STATE_OFF, BINARY_STATE_ON } from "../common/const"; +import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import { computeDomain } from "../common/entity/compute_domain"; +import { BINARY_STATE_OFF, BINARY_STATE_ON } from "../common/const"; import { computeStateDisplay } from "../common/entity/compute_state_display"; import { LocalizeFunc } from "../common/translations/localize"; import { HomeAssistant } from "../types"; @@ -169,6 +169,37 @@ const getLogbookDataFromServer = ( return hass.callWS(params); }; +export const subscribeLogbook = ( + hass: HomeAssistant, + callbackFunction: (message: LogbookEntry[]) => void, + startDate: string, + entityIds?: string[], + deviceIds?: string[] +): Promise => { + // If all specified filters are empty lists, we can return an empty list. + if ( + (entityIds || deviceIds) && + (!entityIds || entityIds.length === 0) && + (!deviceIds || deviceIds.length === 0) + ) { + return Promise.reject("No entities or devices"); + } + const params: any = { + type: "logbook/event_stream", + start_time: startDate, + }; + if (entityIds?.length) { + params.entity_ids = entityIds; + } + if (deviceIds?.length) { + params.device_ids = deviceIds; + } + return hass.connection.subscribeMessage( + (message?) => callbackFunction(message), + params + ); +}; + export const clearLogbookCache = (startDate: string, endDate: string) => { DATA_CACHE[`${startDate}${endDate}`] = {}; }; diff --git a/src/panels/logbook/ha-logbook.ts b/src/panels/logbook/ha-logbook.ts index c84ab62bfd..ede038d3c8 100644 --- a/src/panels/logbook/ha-logbook.ts +++ b/src/panels/logbook/ha-logbook.ts @@ -1,3 +1,4 @@ +import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; @@ -9,12 +10,35 @@ import { clearLogbookCache, getLogbookData, LogbookEntry, + subscribeLogbook, } from "../../data/logbook"; import { loadTraceContexts, TraceContexts } from "../../data/trace"; import { fetchUsers } from "../../data/user"; import { HomeAssistant } from "../../types"; import "./ha-logbook-renderer"; +interface LogbookTimePeriod { + now: Date; + startTime: Date; + endTime: Date; + purgeBeforePythonTime: number | undefined; +} + +const findStartOfRecentTime = (now: Date, recentTime: number) => + new Date(now.getTime() - recentTime * 1000).getTime() / 1000; + +const idsChanged = (oldIds?: string[], newIds?: string[]) => { + if (oldIds === undefined && newIds === undefined) { + return false; + } + return ( + !oldIds || + !newIds || + oldIds.length !== newIds.length || + !oldIds.every((val) => newIds.includes(val)) + ); +}; + @customElement("ha-logbook") export class HaLogbook extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -49,19 +73,19 @@ export class HaLogbook extends LitElement { @state() private _logbookEntries?: LogbookEntry[]; - @state() private _traceContexts?: TraceContexts; + @state() private _traceContexts: TraceContexts = {}; @state() private _userIdToName = {}; @state() private _error?: string; - private _lastLogbookDate?: Date; - private _renderId = 1; + private _subscribed?: Promise; + private _throttleGetLogbookEntries = throttle( () => this._getLogBookData(), - 10000 + 1000 ); protected render(): TemplateResult { @@ -110,10 +134,11 @@ export class HaLogbook extends LitElement { } public async refresh(force = false) { - if (!force && this._logbookEntries === undefined) { + if (!force && (this._subscribed || this._logbookEntries === undefined)) { return; } + this._unsubscribe(); this._throttleGetLogbookEntries.cancel(); this._updateTraceContexts.cancel(); this._updateUsers.cancel(); @@ -125,7 +150,6 @@ export class HaLogbook extends LitElement { ); } - this._lastLogbookDate = undefined; this._logbookEntries = undefined; this._throttleGetLogbookEntries(); } @@ -143,12 +167,11 @@ export class HaLogbook extends LitElement { const oldValue = changedProps.get(key) as string[] | undefined; const curValue = this[key] as string[] | undefined; - if ( - !oldValue || - !curValue || - oldValue.length !== curValue.length || - !oldValue.every((val) => curValue.includes(val)) - ) { + // If they make the filter more specific we want + // to change the subscription since it will reduce + // the overhead on the backend as the event stream + // can be a firehose for all state events. + if (idsChanged(oldValue, curValue)) { changed = true; break; } @@ -156,33 +179,6 @@ export class HaLogbook extends LitElement { if (changed) { this.refresh(true); - return; - } - - if (this._filterAlwaysEmptyResults) { - return; - } - - // We only need to fetch again if we track recent entries for an entity - if ( - !("recent" in this.time) || - !changedProps.has("hass") || - !this.entityIds - ) { - return; - } - - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - - // Refresh data if we know the entity has changed. - if ( - !oldHass || - ensureArray(this.entityIds).some( - (entityId) => this.hass.states[entityId] !== oldHass?.states[entityId] - ) - ) { - // wait for commit of data (we only account for the default setting of 1 sec) - setTimeout(this._throttleGetLogbookEntries, 1000); } } @@ -198,14 +194,98 @@ export class HaLogbook extends LitElement { ); } + private _unsubscribe(): void { + if (this._subscribed) { + this._subscribed.then((unsub) => unsub()); + this._subscribed = undefined; + } + } + + public connectedCallback() { + super.connectedCallback(); + if (this.hasUpdated) { + this._subscribeLogbookPeriod(this._calculateLogbookPeriod()); + } + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this._unsubscribe(); + } + + private _unsubscribeAndEmptyEntries() { + this._unsubscribe(); + this._logbookEntries = []; + } + + private _calculateLogbookPeriod() { + const now = new Date(); + if ("range" in this.time) { + return { + now: now, + startTime: this.time.range[0], + endTime: this.time.range[1], + purgeBeforePythonTime: undefined, + }; + } + if ("recent" in this.time) { + const purgeBeforePythonTime = findStartOfRecentTime( + now, + this.time.recent + ); + return { + now: now, + startTime: new Date(purgeBeforePythonTime * 1000), + endTime: now, + purgeBeforePythonTime: findStartOfRecentTime(now, this.time.recent), + }; + } + throw new Error("Unexpected time specified"); + } + + private _subscribeLogbookPeriod(logbookPeriod: LogbookTimePeriod) { + if (logbookPeriod.endTime < logbookPeriod.now) { + return false; + } + if (this._subscribed) { + return true; + } + this._subscribed = subscribeLogbook( + this.hass, + (newEntries?) => { + if ("recent" in this.time) { + // start time is a sliding window purge old ones + this._processNewEntries( + newEntries, + findStartOfRecentTime(new Date(), this.time.recent) + ); + } else if ("range" in this.time) { + // start time is fixed, we can just append + this._processNewEntries(newEntries, undefined); + } + }, + logbookPeriod.startTime.toISOString(), + ensureArray(this.entityIds), + ensureArray(this.deviceIds) + ); + return true; + } + private async _getLogBookData() { this._renderId += 1; const renderId = this._renderId; this._error = undefined; if (this._filterAlwaysEmptyResults) { - this._logbookEntries = []; - this._lastLogbookDate = undefined; + this._unsubscribeAndEmptyEntries(); + return; + } + + const logbookPeriod = this._calculateLogbookPeriod(); + + if (logbookPeriod.startTime > logbookPeriod.now) { + // Time Travel not yet invented + this._unsubscribeAndEmptyEntries(); return; } @@ -214,30 +294,23 @@ export class HaLogbook extends LitElement { this._updateTraceContexts(); } - let startTime: Date; - let endTime: Date; - let purgeBeforePythonTime: number | undefined; - - if ("range" in this.time) { - [startTime, endTime] = this.time.range; - } else if ("recent" in this.time) { - purgeBeforePythonTime = - new Date(new Date().getTime() - this.time.recent * 1000).getTime() / - 1000; - startTime = - this._lastLogbookDate || new Date(purgeBeforePythonTime * 1000); - endTime = new Date(); - } else { - throw new Error("Unexpected time specified"); + if (this._subscribeLogbookPeriod(logbookPeriod)) { + // We can go live + return; } + // We are only fetching in the past + // with a time window that does not + // extend into the future + this._unsubscribe(); + let newEntries: LogbookEntry[]; try { newEntries = await getLogbookData( this.hass, - startTime.toISOString(), - endTime.toISOString(), + logbookPeriod.startTime.toISOString(), + logbookPeriod.endTime.toISOString(), ensureArray(this.entityIds), ensureArray(this.deviceIds) ); @@ -253,21 +326,39 @@ export class HaLogbook extends LitElement { return; } + this._logbookEntries = [...newEntries].reverse(); + } + + private _nonExpiredRecords = (purgeBeforePythonTime: number | undefined) => + !this._logbookEntries + ? [] + : purgeBeforePythonTime + ? this._logbookEntries.filter( + (entry) => entry.when > purgeBeforePythonTime! + ) + : this._logbookEntries; + + private _processNewEntries = ( + newEntries: LogbookEntry[], + purgeBeforePythonTime: number | undefined + ) => { // Put newest ones on top. Reverse works in-place so // make a copy first. newEntries = [...newEntries].reverse(); - + if (!this._logbookEntries) { + this._logbookEntries = newEntries; + return; + } + const nonExpiredRecords = this._nonExpiredRecords(purgeBeforePythonTime); this._logbookEntries = - // If we have a purgeBeforeTime, it means we're in recent-mode and fetch batches - purgeBeforePythonTime && this._logbookEntries - ? newEntries.concat( - ...this._logbookEntries.filter( - (entry) => entry.when > purgeBeforePythonTime! - ) - ) - : newEntries; - this._lastLogbookDate = endTime; - } + newEntries[0].when >= this._logbookEntries[0].when + ? // The new records are newer than the old records + // append the old records to the end of the new records + newEntries.concat(nonExpiredRecords) + : // The new records are older than the old records + // append the new records to the end of the old records + nonExpiredRecords.concat(newEntries); + }; private _updateTraceContexts = throttle(async () => { this._traceContexts = await loadTraceContexts(this.hass); diff --git a/src/panels/logbook/ha-panel-logbook.ts b/src/panels/logbook/ha-panel-logbook.ts index 65ba508e7f..bcdcb548f7 100644 --- a/src/panels/logbook/ha-panel-logbook.ts +++ b/src/panels/logbook/ha-panel-logbook.ts @@ -48,10 +48,10 @@ export class HaPanelLogbook extends LitElement { super(); const start = new Date(); - start.setHours(start.getHours() - 2, 0, 0, 0); + start.setHours(start.getHours() - 1, 0, 0, 0); const end = new Date(); - end.setHours(end.getHours() + 1, 0, 0, 0); + end.setHours(end.getHours() + 2, 0, 0, 0); this._time = { range: [start, end] }; } @@ -174,7 +174,7 @@ export class HaPanelLogbook extends LitElement { if ( !this._entityIds || entityIds.length !== this._entityIds.length || - this._entityIds.every((val, idx) => val === entityIds[idx]) + !this._entityIds.every((val, idx) => val === entityIds[idx]) ) { this._entityIds = entityIds; } From 0623e7dce40f722d68df9d0e75be03bfccbe1101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 23 May 2022 20:00:16 +0200 Subject: [PATCH 064/112] Fetch supervisor info directly (#12751) --- .../src/addon-view/hassio-addon-dashboard.ts | 65 ++++++++++--------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/hassio/src/addon-view/hassio-addon-dashboard.ts b/hassio/src/addon-view/hassio-addon-dashboard.ts index 22e5bb32c9..6f4580870f 100644 --- a/hassio/src/addon-view/hassio-addon-dashboard.ts +++ b/hassio/src/addon-view/hassio-addon-dashboard.ts @@ -17,7 +17,10 @@ import { HassioAddonDetails, } from "../../../src/data/hassio/addon"; import { extractApiErrorMessage } from "../../../src/data/hassio/common"; -import { setSupervisorOption } from "../../../src/data/hassio/supervisor"; +import { + fetchHassioSupervisorInfo, + setSupervisorOption, +} from "../../../src/data/hassio/supervisor"; import { Supervisor } from "../../../src/data/supervisor/supervisor"; import { showConfirmationDialog } from "../../../src/dialogs/generic/show-dialog-box"; import "../../../src/layouts/hass-error-screen"; @@ -169,38 +172,40 @@ class HassioAddonDashboard extends LitElement { if (this.route.path === "") { const requestedAddon = extractSearchParam("addon"); const requestedAddonRepository = extractSearchParam("repository_url"); - if ( - requestedAddonRepository && - !this.supervisor.supervisor.addons_repositories.find( - (repo) => repo === requestedAddonRepository - ) - ) { + if (requestedAddonRepository) { + const supervisorInfo = await fetchHassioSupervisorInfo(this.hass); if ( - !(await showConfirmationDialog(this, { - title: this.supervisor.localize("my.add_addon_repository_title"), - text: this.supervisor.localize( - "my.add_addon_repository_description", - { addon: requestedAddon, repository: requestedAddonRepository } - ), - confirmText: this.supervisor.localize("common.add"), - dismissText: this.supervisor.localize("common.cancel"), - })) + !supervisorInfo.addons_repositories.find( + (repo) => repo === requestedAddonRepository + ) ) { - this._error = this.supervisor.localize( - "my.error_repository_not_found" - ); - return; - } + if ( + !(await showConfirmationDialog(this, { + title: this.supervisor.localize("my.add_addon_repository_title"), + text: this.supervisor.localize( + "my.add_addon_repository_description", + { addon: requestedAddon, repository: requestedAddonRepository } + ), + confirmText: this.supervisor.localize("common.add"), + dismissText: this.supervisor.localize("common.cancel"), + })) + ) { + this._error = this.supervisor.localize( + "my.error_repository_not_found" + ); + return; + } - try { - await setSupervisorOption(this.hass, { - addons_repositories: [ - ...this.supervisor.supervisor.addons_repositories, - requestedAddonRepository, - ], - }); - } catch (err: any) { - this._error = extractApiErrorMessage(err); + try { + await setSupervisorOption(this.hass, { + addons_repositories: [ + ...supervisorInfo.addons_repositories, + requestedAddonRepository, + ], + }); + } catch (err: any) { + this._error = extractApiErrorMessage(err); + } } } From 1d5cc91a2da51056c15825e87e3a4d6af2ac4cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 23 May 2022 20:01:59 +0200 Subject: [PATCH 065/112] Remove kernel and agent versions from about page (#12750) --- src/panels/config/info/ha-config-info.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/panels/config/info/ha-config-info.ts b/src/panels/config/info/ha-config-info.ts index b046e5ff18..9412160247 100644 --- a/src/panels/config/info/ha-config-info.ts +++ b/src/panels/config/info/ha-config-info.ts @@ -4,9 +4,7 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import "../../../components/ha-logo-svg"; import { fetchHassioHassOsInfo, - fetchHassioHostInfo, HassioHassOSInfo, - HassioHostInfo, } from "../../../data/hassio/host"; import { fetchHassioInfo, HassioInfo } from "../../../data/hassio/supervisor"; import "../../../layouts/hass-subpage"; @@ -28,8 +26,6 @@ class HaConfigInfo extends LitElement { @property({ attribute: false }) public route!: Route; - @state() private _hostInfo?: HassioHostInfo; - @state() private _osInfo?: HassioHassOSInfo; @state() private _hassioInfo?: HassioInfo; @@ -71,12 +67,6 @@ class HaConfigInfo extends LitElement { ${this._osInfo?.version ? html`

Home Assistant OS ${this._osInfo.version}

` : ""} - ${this._hostInfo - ? html` -

Kernel version ${this._hostInfo.kernel}

-

Agent version ${this._hostInfo.agent_version}

- ` - : ""}

${this.hass.localize( "ui.panel.config.info.path_configuration", @@ -177,15 +167,13 @@ class HaConfigInfo extends LitElement { } private async _loadSupervisorInfo(): Promise { - const [hostInfo, osInfo, hassioInfo] = await Promise.all([ - fetchHassioHostInfo(this.hass), + const [osInfo, hassioInfo] = await Promise.all([ fetchHassioHassOsInfo(this.hass), fetchHassioInfo(this.hass), ]); this._hassioInfo = hassioInfo; this._osInfo = osInfo; - this._hostInfo = hostInfo; } static get styles(): CSSResultGroup { From 7db6e0b7791945e15e1da9d5be05872bcaa6585c Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 23 May 2022 20:30:57 +0200 Subject: [PATCH 066/112] Move `preload_stream` setting to entity settings (#12730) Co-authored-by: Zack --- .../more-info/controls/more-info-camera.ts | 95 +------------------ .../entities/entity-registry-settings.ts | 75 ++++++++++++++- src/translations/en.json | 4 +- 3 files changed, 79 insertions(+), 95 deletions(-) diff --git a/src/dialogs/more-info/controls/more-info-camera.ts b/src/dialogs/more-info/controls/more-info-camera.ts index 64d07826a2..27275280e9 100644 --- a/src/dialogs/more-info/controls/more-info-camera.ts +++ b/src/dialogs/more-info/controls/more-info-camera.ts @@ -1,35 +1,14 @@ -import { - css, - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, -} from "lit"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { property, state } from "lit/decorators"; -import { isComponentLoaded } from "../../../common/config/is_component_loaded"; -import { supportsFeature } from "../../../common/entity/supports-feature"; import "../../../components/ha-camera-stream"; -import type { HaCheckbox } from "../../../components/ha-checkbox"; -import "../../../components/ha-checkbox"; -import { - CameraEntity, - CameraPreferences, - CAMERA_SUPPORT_STREAM, - fetchCameraPrefs, - STREAM_TYPE_HLS, - updateCameraPrefs, -} from "../../../data/camera"; +import { CameraEntity } from "../../../data/camera"; import type { HomeAssistant } from "../../../types"; -import "../../../components/ha-formfield"; class MoreInfoCamera extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public stateObj?: CameraEntity; - @state() private _cameraPrefs?: CameraPreferences; - @state() private _attached = false; public connectedCallback() { @@ -54,83 +33,13 @@ class MoreInfoCamera extends LitElement { allow-exoplayer controls > - ${this._cameraPrefs - ? html` - - - - - ` - : undefined} `; } - protected updated(changedProps: PropertyValues) { - if (!changedProps.has("stateObj")) { - return; - } - - const oldState = changedProps.get("stateObj") as this["stateObj"]; - const oldEntityId = oldState ? oldState.entity_id : undefined; - const curEntityId = this.stateObj ? this.stateObj.entity_id : undefined; - - // Same entity, ignore. - if (curEntityId === oldEntityId) { - return; - } - - if ( - curEntityId && - isComponentLoaded(this.hass!, "stream") && - supportsFeature(this.stateObj!, CAMERA_SUPPORT_STREAM) && - // The stream component for HLS streams supports a server-side pre-load - // option that client initiated WebRTC streams do not - this.stateObj!.attributes.frontend_stream_type === STREAM_TYPE_HLS - ) { - // Fetch in background while we set up the video. - this._fetchCameraPrefs(); - } - } - - private async _fetchCameraPrefs() { - this._cameraPrefs = await fetchCameraPrefs( - this.hass!, - this.stateObj!.entity_id - ); - } - - private async _handleCheckboxChanged(ev) { - const checkbox = ev.currentTarget as HaCheckbox; - try { - this._cameraPrefs = await updateCameraPrefs( - this.hass!, - this.stateObj!.entity_id, - { - preload_stream: checkbox.checked!, - } - ); - } catch (err: any) { - alert(err.message); - checkbox.checked = !checkbox.checked; - } - } - static get styles(): CSSResultGroup { return css` :host { display: block; - position: relative; - } - ha-formfield { - position: absolute; - top: 0; - right: 0; - background-color: var(--secondary-background-color); - padding-right: 16px; - border-bottom-left-radius: 4px; } `; } diff --git a/src/panels/config/entities/entity-registry-settings.ts b/src/panels/config/entities/entity-registry-settings.ts index 0c6ff704df..e8125074d9 100644 --- a/src/panels/config/entities/entity-registry-settings.ts +++ b/src/panels/config/entities/entity-registry-settings.ts @@ -12,10 +12,12 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { fireEvent } from "../../../common/dom/fire_event"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { computeDomain } from "../../../common/entity/compute_domain"; import { domainIcon } from "../../../common/entity/domain_icon"; +import { supportsFeature } from "../../../common/entity/supports-feature"; import { stringCompare } from "../../../common/string/compare"; import { LocalizeFunc } from "../../../common/translations/localize"; import "../../../components/ha-alert"; @@ -24,8 +26,17 @@ import "../../../components/ha-expansion-panel"; import "../../../components/ha-icon-picker"; import "../../../components/ha-radio"; import "../../../components/ha-select"; +import "../../../components/ha-settings-row"; import "../../../components/ha-switch"; +import type { HaSwitch } from "../../../components/ha-switch"; import "../../../components/ha-textfield"; +import { + CameraPreferences, + CAMERA_SUPPORT_STREAM, + fetchCameraPrefs, + STREAM_TYPE_HLS, + updateCameraPrefs, +} from "../../../data/camera"; import { ConfigEntry, deleteConfigEntry, @@ -133,6 +144,8 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { @state() private _submitting?: boolean; + @state() private _cameraPrefs?: CameraPreferences; + private _origEntityId!: string; private _deviceLookup?: Record; @@ -190,6 +203,20 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { const domain = computeDomain(this.entry.entity_id); + if (domain === "camera" && isComponentLoaded(this.hass, "stream")) { + const stateObj: HassEntity | undefined = + this.hass.states[this.entry.entity_id]; + if ( + stateObj && + supportsFeature(stateObj, CAMERA_SUPPORT_STREAM) && + // The stream component for HLS streams supports a server-side pre-load + // option that client initiated WebRTC streams do not + stateObj.attributes.frontend_stream_type === STREAM_TYPE_HLS + ) { + this._fetchCameraPrefs(); + } + } + if (domain === "sensor") { const stateObj: HassEntity | undefined = this.hass.states[this.entry.entity_id]; @@ -392,7 +419,27 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { @value-changed=${this._areaPicked} >` : ""} - + ${this._cameraPrefs + ? html` + + ${this.hass.localize( + "ui.dialogs.entity_registry.editor.preload_stream" + )} + ${this.hass.localize( + "ui.dialogs.entity_registry.editor.preload_stream_description" + )} + + + + ` + : ""} Date: Mon, 23 May 2022 14:32:11 -0500 Subject: [PATCH 067/112] Use new localized context state and source in logbook (#12742) --- src/data/logbook.ts | 140 +++++++----- src/panels/logbook/ha-logbook-renderer.ts | 262 ++++++++++++++-------- src/translations/en.json | 19 +- 3 files changed, 263 insertions(+), 158 deletions(-) diff --git a/src/data/logbook.ts b/src/data/logbook.ts index 578be0dfbb..d6b8ed08c2 100644 --- a/src/data/logbook.ts +++ b/src/data/logbook.ts @@ -1,6 +1,10 @@ import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; +import { + BINARY_STATE_OFF, + BINARY_STATE_ON, + DOMAINS_WITH_DYNAMIC_PICTURE, +} from "../common/const"; import { computeDomain } from "../common/entity/compute_domain"; -import { BINARY_STATE_OFF, BINARY_STATE_ON } from "../common/const"; import { computeStateDisplay } from "../common/entity/compute_state_display"; import { LocalizeFunc } from "../common/translations/localize"; import { HomeAssistant } from "../types"; @@ -10,26 +14,43 @@ const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages"; export const CONTINUOUS_DOMAINS = ["proximity", "sensor"]; export interface LogbookEntry { - // Python timestamp. Do *1000 to get JS timestamp. - when: number; + // Base data + when: number; // Python timestamp. Do *1000 to get JS timestamp. name: string; message?: string; entity_id?: string; icon?: string; - source?: string; + source?: string; // The trigger source domain?: string; + state?: string; // The state of the entity + // Context data context_id?: string; context_user_id?: string; context_event_type?: string; context_domain?: string; - context_service?: string; + context_service?: string; // Service calls only context_entity_id?: string; - context_entity_id_name?: string; + context_entity_id_name?: string; // Legacy, not longer sent context_name?: string; + context_state?: string; // The state of the entity + context_source?: string; // The trigger source context_message?: string; - state?: string; } +// +// Localization mapping for all the triggers in core +// in homeassistant.components.homeassistant.triggers +// +const triggerPhrases = { + "numeric state of": "triggered_by_numeric_state_of", // number state trigger + "state of": "triggered_by_state_of", // state trigger + event: "triggered_by_event", // event trigger + time: "triggered_by_time", // time trigger + "time pattern": "triggered_by_time_pattern", // time trigger + "Home Assistant stopping": "triggered_by_homeassistant_stopping", // stop event + "Home Assistant starting": "triggered_by_homeassistant_starting", // start event +}; + const DATA_CACHE: { [cacheKey: string]: { [entityId: string]: Promise }; } = {}; @@ -39,17 +60,13 @@ export const getLogbookDataForContext = async ( startDate: string, contextId?: string ): Promise => { - const localize = await hass.loadBackendTranslation("device_class"); - return addLogbookMessage( + await hass.loadBackendTranslation("device_class"); + return getLogbookDataFromServer( hass, - localize, - await getLogbookDataFromServer( - hass, - startDate, - undefined, - undefined, - contextId - ) + startDate, + undefined, + undefined, + contextId ); }; @@ -60,42 +77,17 @@ export const getLogbookData = async ( entityIds?: string[], deviceIds?: string[] ): Promise => { - const localize = await hass.loadBackendTranslation("device_class"); - return addLogbookMessage( - hass, - localize, - // bypass cache if we have a device ID - deviceIds?.length - ? await getLogbookDataFromServer( - hass, - startDate, - endDate, - entityIds, - undefined, - deviceIds - ) - : await getLogbookDataCache(hass, startDate, endDate, entityIds) - ); -}; - -const addLogbookMessage = ( - hass: HomeAssistant, - localize: LocalizeFunc, - logbookData: LogbookEntry[] -): LogbookEntry[] => { - for (const entry of logbookData) { - const stateObj = hass!.states[entry.entity_id!]; - if (entry.state && stateObj) { - entry.message = getLogbookMessage( + await hass.loadBackendTranslation("device_class"); + return deviceIds?.length + ? getLogbookDataFromServer( hass, - localize, - entry.state, - stateObj, - computeDomain(entry.entity_id!) - ); - } - } - return logbookData; + startDate, + endDate, + entityIds, + undefined, + deviceIds + ) + : getLogbookDataCache(hass, startDate, endDate, entityIds); }; const getLogbookDataCache = async ( @@ -204,7 +196,49 @@ export const clearLogbookCache = (startDate: string, endDate: string) => { DATA_CACHE[`${startDate}${endDate}`] = {}; }; -const getLogbookMessage = ( +export const createHistoricState = ( + currentStateObj: HassEntity, + state?: string +): HassEntity => ({ + entity_id: currentStateObj.entity_id, + state: state, + attributes: { + // Rebuild the historical state by copying static attributes only + device_class: currentStateObj?.attributes.device_class, + source_type: currentStateObj?.attributes.source_type, + has_date: currentStateObj?.attributes.has_date, + has_time: currentStateObj?.attributes.has_time, + // We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering, + // as they would present a false state in the log (played media right now vs actual historic data). + entity_picture_local: DOMAINS_WITH_DYNAMIC_PICTURE.has( + computeDomain(currentStateObj.entity_id) + ) + ? undefined + : currentStateObj?.attributes.entity_picture_local, + entity_picture: DOMAINS_WITH_DYNAMIC_PICTURE.has( + computeDomain(currentStateObj.entity_id) + ) + ? undefined + : currentStateObj?.attributes.entity_picture, + }, + }); + +export const localizeTriggerSource = ( + localize: LocalizeFunc, + source: string +) => { + for (const triggerPhrase in triggerPhrases) { + if (source.startsWith(triggerPhrase)) { + return source.replace( + triggerPhrase, + `${localize(`ui.components.logbook.${triggerPhrases[triggerPhrase]}`)}` + ); + } + } + return source; +}; + +export const localizeStateMessage = ( hass: HomeAssistant, localize: LocalizeFunc, state: string, diff --git a/src/panels/logbook/ha-logbook-renderer.ts b/src/panels/logbook/ha-logbook-renderer.ts index e2baefb672..69b7bed3cf 100644 --- a/src/panels/logbook/ha-logbook-renderer.ts +++ b/src/panels/logbook/ha-logbook-renderer.ts @@ -10,7 +10,6 @@ import { import type { HassEntity } from "home-assistant-js-websocket"; import { customElement, eventOptions, property } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; -import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../../common/const"; import { formatDate } from "../../common/datetime/format_date"; import { formatTimeWithSeconds } from "../../common/datetime/format_time"; import { restoreScroll } from "../../common/decorators/restore-scroll"; @@ -21,7 +20,12 @@ import { computeRTL, emitRTLDirection } from "../../common/util/compute_rtl"; import "../../components/entity/state-badge"; import "../../components/ha-circular-progress"; import "../../components/ha-relative-time"; -import { LogbookEntry } from "../../data/logbook"; +import { + createHistoricState, + localizeTriggerSource, + localizeStateMessage, + LogbookEntry, +} from "../../data/logbook"; import { TraceContexts } from "../../data/trace"; import { haStyle, @@ -31,9 +35,12 @@ import { import { HomeAssistant } from "../../types"; import { brandsUrl } from "../../util/brands-url"; -const EVENT_LOCALIZE_MAP = { - script_started: "from_script", -}; +const triggerDomains = ["script", "automation"]; + +const hasContext = (item: LogbookEntry) => + item.context_event_type || item.context_state || item.context_message; +const stripEntityId = (message: string, entityId?: string) => + entityId ? message.replace(entityId, " ") : message; @customElement("ha-logbook-renderer") class HaLogbookRenderer extends LitElement { @@ -128,40 +135,22 @@ class HaLogbookRenderer extends LitElement { if (!item || index === undefined) { return html``; } - - const seenEntityIds: string[] = []; const previous = this.entries[index - 1]; + const seenEntityIds: string[] = []; const currentStateObj = item.entity_id ? this.hass.states[item.entity_id] : undefined; - const item_username = - item.context_user_id && this.userIdToName[item.context_user_id]; + const historicStateObj = currentStateObj + ? createHistoricState(currentStateObj, item.state!) + : undefined; const domain = item.entity_id ? computeDomain(item.entity_id) : // Domain is there if there is no entity ID. item.domain!; - const historicStateObj = item.entity_id ? ({ - entity_id: item.entity_id, - state: item.state, - attributes: { - // Rebuild the historical state by copying static attributes only - device_class: currentStateObj?.attributes.device_class, - source_type: currentStateObj?.attributes.source_type, - has_date: currentStateObj?.attributes.has_date, - has_time: currentStateObj?.attributes.has_time, - // We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering, - // as they would present a false state in the log (played media right now vs actual historic data). - entity_picture_local: DOMAINS_WITH_DYNAMIC_PICTURE.has(domain) - ? undefined - : currentStateObj?.attributes.entity_picture_local, - entity_picture: DOMAINS_WITH_DYNAMIC_PICTURE.has(domain) - ? undefined - : currentStateObj?.attributes.entity_picture, - }, - }) : undefined; const overrideImage = !historicStateObj && !item.icon && + !item.state && domain && isComponentLoaded(this.hass, domain) ? brandsUrl({ @@ -204,45 +193,13 @@ class HaLogbookRenderer extends LitElement { ${!this.noName // Used for more-info panel (single entity case) ? this._renderEntity(item.entity_id, item.name) : ""} - ${item.message - ? html`${this._formatMessageWithPossibleEntity( - item.message, - seenEntityIds, - item.entity_id - )}` - : item.source - ? html` ${this._formatMessageWithPossibleEntity( - item.source, - seenEntityIds, - undefined, - "ui.components.logbook.by" - )}` - : ""} - ${item_username - ? ` ${this.hass.localize( - "ui.components.logbook.by_user" - )} ${item_username}` - : ``} - ${item.context_event_type - ? this._formatEventBy(item, seenEntityIds) - : ""} - ${item.context_message - ? html` ${this._formatMessageWithPossibleEntity( - item.context_message, - seenEntityIds, - item.context_entity_id, - "ui.components.logbook.for" - )}` - : ""} - ${item.context_entity_id && - !seenEntityIds.includes(item.context_entity_id) - ? // Another entity such as an automation or script - html` ${this.hass.localize("ui.components.logbook.for")} - ${this._renderEntity( - item.context_entity_id, - item.context_entity_id_name - )}` - : ""} + ${this._renderMessage( + item, + seenEntityIds, + domain, + historicStateObj + )} + ${this._renderContextMessage(item, seenEntityIds)}

- ${["script", "automation"].includes(item.domain!) && + ${item.context_user_id ? html`${this._renderUser(item)}` : ""} + ${triggerDomains.includes(item.domain!) && item.context_id! in this.traceContexts ? html` - @@ -294,38 +252,149 @@ class HaLogbookRenderer extends LitElement { this._savedScrollPos = (e.target as HTMLDivElement).scrollTop; } - private _formatEventBy(item: LogbookEntry, seenEntities: string[]) { - if (item.context_event_type === "call_service") { - return `${this.hass.localize("ui.components.logbook.from_service")} ${ - item.context_domain - }.${item.context_service}`; + private _renderMessage( + item: LogbookEntry, + seenEntityIds: string[], + domain?: string, + historicStateObj?: HassEntity + ) { + if (item.entity_id) { + if (item.state) { + return historicStateObj + ? localizeStateMessage( + this.hass, + this.hass.localize, + item.state, + historicStateObj, + domain! + ) + : item.state; + } } - if (item.context_event_type === "automation_triggered") { - if (seenEntities.includes(item.context_entity_id!)) { + + const itemHasContext = hasContext(item); + let message = item.message; + if (triggerDomains.includes(domain!) && item.source) { + if (itemHasContext) { + // These domains include the trigger source in the message + // but if we have the context we want to display that instead + // as otherwise we display duplicate triggers return ""; } - seenEntities.push(item.context_entity_id!); - return html`${this.hass.localize("ui.components.logbook.from_automation")} - ${this._renderEntity(item.context_entity_id, item.context_name)}`; + message = localizeTriggerSource(this.hass.localize, item.source); } - if (item.context_name) { - return `${this.hass.localize("ui.components.logbook.from")} ${ - item.context_name - }`; + return message + ? this._formatMessageWithPossibleEntity( + itemHasContext + ? stripEntityId(message, item.context_entity_id) + : message, + seenEntityIds, + undefined + ) + : ""; + } + + private _renderUser(item: LogbookEntry) { + const item_username = + item.context_user_id && this.userIdToName[item.context_user_id]; + if (item_username) { + return `- ${item_username}`; } - if (item.context_event_type === "state_changed") { + return ""; + } + + private _renderUnseenContextSourceEntity( + item: LogbookEntry, + seenEntityIds: string[] + ) { + if ( + !item.context_entity_id || + seenEntityIds.includes(item.context_entity_id!) + ) { return ""; } - if (item.context_event_type! in EVENT_LOCALIZE_MAP) { - return `${this.hass.localize( - `ui.components.logbook.${EVENT_LOCALIZE_MAP[item.context_event_type!]}` - )}`; + // We don't know what caused this entity + // to be included since its an integration + // described event. + return html` (${this._renderEntity( + item.context_entity_id, + item.context_entity_id_name + )})`; + } + + private _renderContextMessage(item: LogbookEntry, seenEntityIds: string[]) { + // State change + if (item.context_state) { + const historicStateObj = + item.context_entity_id && item.context_entity_id in this.hass.states + ? createHistoricState( + this.hass.states[item.context_entity_id], + item.context_state + ) + : undefined; + return html`${this.hass.localize( + "ui.components.logbook.triggered_by_state_of" + )} + ${this._renderEntity(item.context_entity_id, item.context_entity_id_name)} + ${historicStateObj + ? localizeStateMessage( + this.hass, + this.hass.localize, + item.context_state, + historicStateObj, + computeDomain(item.context_entity_id!) + ) + : item.context_state}`; } - return `${this.hass.localize( - "ui.components.logbook.from" - )} ${this.hass.localize("ui.components.logbook.event")} ${ - item.context_event_type - }`; + // Service call + if (item.context_event_type === "call_service") { + return html`${this.hass.localize( + "ui.components.logbook.triggered_by_service" + )} + ${item.context_domain}.${item.context_service}`; + } + if ( + !item.context_message || + seenEntityIds.includes(item.context_entity_id!) + ) { + return ""; + } + // Automation or script + if ( + item.context_event_type === "automation_triggered" || + item.context_event_type === "script_started" + ) { + // context_source is available in 2022.6 and later + const triggerMsg = item.context_source + ? item.context_source + : item.context_message.replace("triggered by ", ""); + const contextTriggerSource = localizeTriggerSource( + this.hass.localize, + triggerMsg + ); + return html`${this.hass.localize( + item.context_event_type === "automation_triggered" + ? "ui.components.logbook.triggered_by_automation" + : "ui.components.logbook.triggered_by_script" + )} + ${this._renderEntity(item.context_entity_id, item.context_entity_id_name)} + ${item.context_message + ? this._formatMessageWithPossibleEntity( + contextTriggerSource, + seenEntityIds + ) + : ""}`; + } + // Generic externally described logbook platform + // These are not localizable + return html` ${this.hass.localize("ui.components.logbook.triggered_by")} + ${item.context_name} + ${this._formatMessageWithPossibleEntity( + item.context_message, + seenEntityIds, + item.context_entity_id + )} + ${this._renderUnseenContextSourceEntity(item, seenEntityIds)}`; } private _renderEntity( @@ -353,8 +422,7 @@ class HaLogbookRenderer extends LitElement { private _formatMessageWithPossibleEntity( message: string, seenEntities: string[], - possibleEntity?: string, - localizePrefix?: string + possibleEntity?: string ) { // // As we are looking at a log(book), we are doing entity_id @@ -376,7 +444,7 @@ class HaLogbookRenderer extends LitElement { seenEntities.push(entityId); const messageEnd = messageParts.splice(i); messageEnd.shift(); // remove the entity - return html` ${messageParts.join(" ")} + return html`${messageParts.join(" ")} ${this._renderEntity( entityId, this.hass.states[entityId].attributes.friendly_name @@ -404,8 +472,8 @@ class HaLogbookRenderer extends LitElement { 0, message.length - possibleEntityName.length ); - return html` ${localizePrefix ? this.hass.localize(localizePrefix) : ""} - ${message} ${this._renderEntity(possibleEntity, possibleEntityName)}`; + return html`${message} + ${this._renderEntity(possibleEntity, possibleEntityName)}`; } } return message; diff --git a/src/translations/en.json b/src/translations/en.json index 9264733ebe..08a5a407f8 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -344,14 +344,17 @@ }, "logbook": { "entries_not_found": "No logbook events found.", - "by_user": "by user", - "by": "by", - "from": "from", - "for": "for", - "event": "event", - "from_service": "from service", - "from_automation": "from automation", - "from_script": "from script", + "triggered_by": "triggered by", + "triggered_by_automation": "triggered by automation", + "triggered_by_script": "triggered by script", + "triggered_by_service": "triggered by service", + "triggered_by_numeric_state_of": "triggered by numeric state of", + "triggered_by_state_of": "triggered by state of", + "triggered_by_event": "triggered by event", + "triggered_by_time": "triggered by time", + "triggered_by_time_pattern": "triggered by time pattern", + "triggered_by_homeassistant_stopping": "triggered by Home Assistant stopping", + "triggered_by_homeassistant_starting": "triggered by Home Assistant starting", "show_trace": "[%key:ui::panel::config::automation::editor::show_trace%]", "retrieval_error": "Could not load logbook", "messages": { From 067c2fdfa8d8dd225202c689251e4813a8bdc903 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 May 2022 17:40:05 -0500 Subject: [PATCH 068/112] Use new logbook streaming websocket api for cases where we need end_time (#12753) --- src/data/logbook.ts | 2 ++ src/panels/logbook/ha-logbook.ts | 55 ++++++-------------------------- 2 files changed, 12 insertions(+), 45 deletions(-) diff --git a/src/data/logbook.ts b/src/data/logbook.ts index d6b8ed08c2..11cf4cfc8d 100644 --- a/src/data/logbook.ts +++ b/src/data/logbook.ts @@ -165,6 +165,7 @@ export const subscribeLogbook = ( hass: HomeAssistant, callbackFunction: (message: LogbookEntry[]) => void, startDate: string, + endDate: string, entityIds?: string[], deviceIds?: string[] ): Promise => { @@ -179,6 +180,7 @@ export const subscribeLogbook = ( const params: any = { type: "logbook/event_stream", start_time: startDate, + end_time: endDate, }; if (entityIds?.length) { params.entity_ids = entityIds; diff --git a/src/panels/logbook/ha-logbook.ts b/src/panels/logbook/ha-logbook.ts index ede038d3c8..287d73b800 100644 --- a/src/panels/logbook/ha-logbook.ts +++ b/src/panels/logbook/ha-logbook.ts @@ -8,7 +8,6 @@ import { throttle } from "../../common/util/throttle"; import "../../components/ha-circular-progress"; import { clearLogbookCache, - getLogbookData, LogbookEntry, subscribeLogbook, } from "../../data/logbook"; @@ -79,9 +78,7 @@ export class HaLogbook extends LitElement { @state() private _error?: string; - private _renderId = 1; - - private _subscribed?: Promise; + private _subscribed?: Promise; private _throttleGetLogbookEntries = throttle( () => this._getLogBookData(), @@ -196,7 +193,7 @@ export class HaLogbook extends LitElement { private _unsubscribe(): void { if (this._subscribed) { - this._subscribed.then((unsub) => unsub()); + this._subscribed.then((unsub) => (unsub ? unsub() : undefined)); this._subscribed = undefined; } } @@ -236,7 +233,8 @@ export class HaLogbook extends LitElement { return { now: now, startTime: new Date(purgeBeforePythonTime * 1000), - endTime: now, + // end streaming one year from now + endTime: new Date(now.getTime() + 86400 * 365 * 1000), purgeBeforePythonTime: findStartOfRecentTime(now, this.time.recent), }; } @@ -244,9 +242,6 @@ export class HaLogbook extends LitElement { } private _subscribeLogbookPeriod(logbookPeriod: LogbookTimePeriod) { - if (logbookPeriod.endTime < logbookPeriod.now) { - return false; - } if (this._subscribed) { return true; } @@ -265,15 +260,17 @@ export class HaLogbook extends LitElement { } }, logbookPeriod.startTime.toISOString(), + logbookPeriod.endTime.toISOString(), ensureArray(this.entityIds), ensureArray(this.deviceIds) - ); + ).catch((err) => { + this._error = err.message; + this._subscribed = undefined; + }); return true; } private async _getLogBookData() { - this._renderId += 1; - const renderId = this._renderId; this._error = undefined; if (this._filterAlwaysEmptyResults) { @@ -294,39 +291,7 @@ export class HaLogbook extends LitElement { this._updateTraceContexts(); } - if (this._subscribeLogbookPeriod(logbookPeriod)) { - // We can go live - return; - } - - // We are only fetching in the past - // with a time window that does not - // extend into the future - this._unsubscribe(); - - let newEntries: LogbookEntry[]; - - try { - newEntries = await getLogbookData( - this.hass, - logbookPeriod.startTime.toISOString(), - logbookPeriod.endTime.toISOString(), - ensureArray(this.entityIds), - ensureArray(this.deviceIds) - ); - } catch (err: any) { - if (renderId === this._renderId) { - this._error = err.message; - } - return; - } - - // New render happening. - if (renderId !== this._renderId) { - return; - } - - this._logbookEntries = [...newEntries].reverse(); + this._subscribeLogbookPeriod(logbookPeriod); } private _nonExpiredRecords = (purgeBeforePythonTime: number | undefined) => From e1fd7244a512a343af54fcf31586fd1313825e11 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 23 May 2022 15:46:34 -0700 Subject: [PATCH 069/112] Open Application Credentials from integration configuration flow (#12708) --- .../config-flow/dialog-data-entry-flow.ts | 2 +- .../show-dialog-data-entry-flow.ts | 2 + src/dialogs/config-flow/step-flow-abort.ts | 54 +++++++++++++++++-- .../dialog-add-application-credential.ts | 13 ++++- .../show-dialog-add-application-credential.ts | 2 + src/translations/en.json | 3 +- 6 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/dialogs/config-flow/dialog-data-entry-flow.ts b/src/dialogs/config-flow/dialog-data-entry-flow.ts index 415be7f833..7654a0b506 100644 --- a/src/dialogs/config-flow/dialog-data-entry-flow.ts +++ b/src/dialogs/config-flow/dialog-data-entry-flow.ts @@ -309,7 +309,7 @@ class DataEntryFlowDialog extends LitElement { : this._step.type === "abort" ? html` void; flowConfig: FlowConfig; showAdvanced?: boolean; + dialogParentElement?: HTMLElement; } export const loadDataEntryFlowDialog = () => import("./dialog-data-entry-flow"); @@ -146,6 +147,7 @@ export const showFlowDialog = ( dialogParams: { ...dialogParams, flowConfig, + dialogParentElement: element, }, }); }; diff --git a/src/dialogs/config-flow/step-flow-abort.ts b/src/dialogs/config-flow/step-flow-abort.ts index 79581e366a..e1909d0bfe 100644 --- a/src/dialogs/config-flow/step-flow-abort.ts +++ b/src/dialogs/config-flow/step-flow-abort.ts @@ -1,15 +1,25 @@ import "@material/mwc-button"; -import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { + CSSResultGroup, + html, + LitElement, + TemplateResult, + PropertyValues, +} from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import { DataEntryFlowStepAbort } from "../../data/data_entry_flow"; import { HomeAssistant } from "../../types"; -import { FlowConfig } from "./show-dialog-data-entry-flow"; +import { showAddApplicationCredentialDialog } from "../../panels/config/application_credentials/show-dialog-add-application-credential"; import { configFlowContentStyles } from "./styles"; +import { showConfirmationDialog } from "../generic/show-dialog-box"; +import { domainToName } from "../../data/integration"; +import { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow"; +import { showConfigFlowDialog } from "./show-dialog-config-flow"; @customElement("step-flow-abort") class StepFlowAbort extends LitElement { - @property({ attribute: false }) public flowConfig!: FlowConfig; + @property({ attribute: false }) public params!: DataEntryFlowDialogParams; @property({ attribute: false }) public hass!: HomeAssistant; @@ -17,11 +27,21 @@ class StepFlowAbort extends LitElement { @property({ attribute: false }) public domain!: string; + protected firstUpdated(changed: PropertyValues) { + super.firstUpdated(changed); + if (this.step.reason === "missing_credentials") { + this._handleMissingCreds(); + } + } + protected render(): TemplateResult { + if (this.step.reason === "missing_credentials") { + return html``; + } return html`

${this.hass.localize(`component.${this.domain}.title`)}

- ${this.flowConfig.renderAbortDescription(this.hass, this.step)} + ${this.params.flowConfig.renderAbortDescription(this.hass, this.step)}
{ + showConfigFlowDialog(this.params.dialogParentElement!, { + dialogClosedCallback: this.params.dialogClosedCallback, + startFlowHandler: this.domain, + showAdvanced: this.hass.userData?.showAdvanced, + }); + }, + }); + } + private _flowDone(): void { fireEvent(this, "flow-update", { step: undefined }); } diff --git a/src/panels/config/application_credentials/dialog-add-application-credential.ts b/src/panels/config/application_credentials/dialog-add-application-credential.ts index eccf71e4fe..9face54d7e 100644 --- a/src/panels/config/application_credentials/dialog-add-application-credential.ts +++ b/src/panels/config/application_credentials/dialog-add-application-credential.ts @@ -51,7 +51,8 @@ export class DialogAddApplicationCredential extends LitElement { public showDialog(params: AddApplicationCredentialDialogParams) { this._params = params; - this._domain = ""; + this._domain = + params.selectedDomain !== undefined ? params.selectedDomain : ""; this._name = ""; this._clientId = ""; this._clientSecret = ""; @@ -75,7 +76,7 @@ export class DialogAddApplicationCredential extends LitElement { return html` void; + dialogAbortedCallback?: () => void; + selectedDomain?: string; } export const loadAddApplicationCredentialDialog = () => diff --git a/src/translations/en.json b/src/translations/en.json index 08a5a407f8..63010bcb35 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2825,7 +2825,8 @@ }, "error": "Error", "could_not_load": "Config flow could not be loaded", - "not_loaded": "The integration could not be loaded, try to restart Home Assistant." + "not_loaded": "The integration could not be loaded, try to restart Home Assistant.", + "missing_credentials": "Setting up {integration} requires configuring application credentials. Do you want to do that now?" } }, "users": { From b71b230bfdd2b52eb54c8289e0cc96f74ce5af33 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 23 May 2022 19:08:44 -0400 Subject: [PATCH 070/112] Make entities and devices independent in the scene editor (#11046) Co-authored-by: Zack Barett Co-authored-by: Erik Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- src/data/scene.ts | 5 ++ src/panels/config/scene/ha-scene-editor.ts | 55 +++++++++++++++------- src/translations/en.json | 7 ++- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/data/scene.ts b/src/data/scene.ts index f97cc2fa59..f15e7c2562 100644 --- a/src/data/scene.ts +++ b/src/data/scene.ts @@ -47,12 +47,17 @@ export interface SceneConfig { name: string; icon?: string; entities: SceneEntities; + metadata?: SceneMetaData; } export interface SceneEntities { [entityId: string]: string | { state: string; [key: string]: any }; } +export interface SceneMetaData { + [entityId: string]: { entity_only?: boolean | undefined }; +} + export const activateScene = ( hass: HomeAssistant, entityId: string diff --git a/src/panels/config/scene/ha-scene-editor.ts b/src/panels/config/scene/ha-scene-editor.ts index 5c0e8f1884..7832d9116c 100644 --- a/src/panels/config/scene/ha-scene-editor.ts +++ b/src/panels/config/scene/ha-scene-editor.ts @@ -55,6 +55,7 @@ import { SceneConfig, SceneEntities, SceneEntity, + SceneMetaData, SCENE_IGNORED_DOMAINS, showSceneEditor, } from "../../../data/scene"; @@ -628,16 +629,22 @@ export class HaSceneEditor extends SubscribeMixin( const filteredEntityReg = this._entityRegistryEntries.filter((entityReg) => this._entities.includes(entityReg.entity_id) ); - this._devices = []; + const newDevices: string[] = []; for (const entityReg of filteredEntityReg) { if (!entityReg.device_id) { continue; } - if (!this._devices.includes(entityReg.device_id)) { - this._devices = [...this._devices, entityReg.device_id]; + const entityMetaData = config.metadata?.[entityReg.entity_id]; + if ( + !newDevices.includes(entityReg.device_id) && + !entityMetaData?.entity_only + ) { + newDevices.push(entityReg.device_id); } } + + this._devices = newDevices; } private _entityPicked(ev: CustomEvent) { @@ -646,18 +653,8 @@ export class HaSceneEditor extends SubscribeMixin( if (this._entities.includes(entityId)) { return; } - const entityRegistry = this._entityRegistryEntries.find( - (entityReg) => entityReg.entity_id === entityId - ); - if ( - entityRegistry?.device_id && - !this._devices.includes(entityRegistry.device_id) - ) { - this._pickDevice(entityRegistry.device_id); - } else { - this._entities = [...this._entities, entityId]; - this._storeState(entityId); - } + this._entities = [...this._entities, entityId]; + this._storeState(entityId); this._dirty = true; } @@ -815,6 +812,28 @@ export class HaSceneEditor extends SubscribeMixin( ); } + private _calculateMetaData(): SceneMetaData { + const output: SceneMetaData = {}; + + for (const entityReg of this._entityRegistryEntries) { + if (!this._entities.includes(entityReg.entity_id)) { + continue; + } + + const entityState = this._getCurrentState(entityReg.entity_id); + + if (!entityState) { + continue; + } + + output[entityReg.entity_id] = { + entity_only: !this._devices.includes(entityReg.device_id!), + }; + } + + return output; + } + private _calculateStates(): SceneEntities { const output: SceneEntities = {}; this._entities.forEach((entityId) => { @@ -847,7 +866,11 @@ export class HaSceneEditor extends SubscribeMixin( private async _saveScene(): Promise { const id = !this.sceneId ? "" + Date.now() : this.sceneId!; - this._config = { ...this._config!, entities: this._calculateStates() }; + this._config = { + ...this._config!, + entities: this._calculateStates(), + metadata: this._calculateMetaData(), + }; try { this._saving = true; await saveScene(this.hass, id, this._config); diff --git a/src/translations/en.json b/src/translations/en.json index 63010bcb35..824a63d04e 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2257,15 +2257,14 @@ "area": "Area", "devices": { "header": "Devices", - "introduction": "Add the devices that you want to be included in your scene. Set all the devices to the state you want for this scene.", + "introduction": "Add the devices that you want to be included in your scene. Set all entities in each device to the state you want for this scene.", "add": "Add a device", "delete": "Delete device" }, "entities": { "header": "Entities", - "introduction": "Entities that do not belong to a device can be set here.", - "without_device": "Entities without device", - "device_entities": "If you add an entity that belongs to a device, the device will be added.", + "introduction": "Individual entities can be added here.", + "without_device": "Entities", "add": "Add an entity", "delete": "Delete entity" } From 49c018c000eee7fe7649ff2948da715822043356 Mon Sep 17 00:00:00 2001 From: Philip Allgaier Date: Tue, 24 May 2022 01:18:08 +0200 Subject: [PATCH 071/112] Allow setting `device_class` "outlet" again through entity settings (#12669) * Allow setting `device_class` "outlet" again through UI * Fixes * Null check deviceClass and adjust used translation --- .../entities/entity-registry-settings.ts | 29 +++++++++++++++++-- src/translations/en.json | 4 +++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/panels/config/entities/entity-registry-settings.ts b/src/panels/config/entities/entity-registry-settings.ts index e8125074d9..6c46903632 100644 --- a/src/panels/config/entities/entity-registry-settings.ts +++ b/src/panels/config/entities/entity-registry-settings.ts @@ -367,9 +367,24 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { @selected=${this._switchAsChanged} @closed=${stopPropagation} > - - ${domainToName(this.hass.localize, "switch")} + ${this.hass.localize( + "ui.dialogs.entity_registry.editor.device_classes.switch.switch" + )} + + + ${this.hass.localize( + "ui.dialogs.entity_registry.editor.device_classes.switch.outlet" + )} +
  • ${this._switchAsDomainsSorted( SWITCH_AS_DOMAINS, @@ -617,7 +632,15 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { if (ev.target.value === "") { return; } - this._switchAs = ev.target.value; + + // If value is "outlet" that means the user kept the "switch" domain, but actually changed + // the device_class of the switch to "outlet". + const switchAs = ev.target.value === "outlet" ? "switch" : ev.target.value; + this._switchAs = switchAs; + + if (ev.target.value === "outlet" || ev.target.value === "switch") { + this._deviceClass = ev.target.value; + } } private _areaPicked(ev: CustomEvent) { diff --git a/src/translations/en.json b/src/translations/en.json index 824a63d04e..02daa046d6 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -853,6 +853,10 @@ "curtain": "Curtain", "damper": "Damper", "shutter": "Shutter" + }, + "switch": { + "outlet": "Outlet", + "switch": "Switch" } }, "unavailable": "This entity is unavailable.", From 49cfde1fe7a2666ca3762883c755683dc3a8abff Mon Sep 17 00:00:00 2001 From: Zack Barett Date: Mon, 23 May 2022 18:26:00 -0500 Subject: [PATCH 072/112] Bumped version to 20220523.0 (#12756) --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index ef71058525..65b4190c93 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = home-assistant-frontend -version = 20220521.0 +version = 20220523.0 author = The Home Assistant Authors author_email = hello@home-assistant.io license = Apache-2.0 From 3acab5a39c4da4c5ecb9d8f86e9b2f5488b00d03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 May 2022 00:37:45 -0500 Subject: [PATCH 073/112] Adjust logbook stream consumer to handle new metadata (#12755) --- src/data/logbook.ts | 13 ++++++++++--- src/panels/logbook/ha-logbook.ts | 33 ++++++++++++++++++-------------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/data/logbook.ts b/src/data/logbook.ts index 11cf4cfc8d..eda3aa4ce6 100644 --- a/src/data/logbook.ts +++ b/src/data/logbook.ts @@ -13,6 +13,13 @@ import { UNAVAILABLE_STATES } from "./entity"; const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages"; export const CONTINUOUS_DOMAINS = ["proximity", "sensor"]; +export interface LogbookStreamMessage { + events: LogbookEntry[]; + start_time?: number; // Start time of this historical chunk + end_time?: number; // End time of this historical chunk + partial?: boolean; // Indiciates more historical chunks are coming +} + export interface LogbookEntry { // Base data when: number; // Python timestamp. Do *1000 to get JS timestamp. @@ -163,7 +170,7 @@ const getLogbookDataFromServer = ( export const subscribeLogbook = ( hass: HomeAssistant, - callbackFunction: (message: LogbookEntry[]) => void, + callbackFunction: (message: LogbookStreamMessage) => void, startDate: string, endDate: string, entityIds?: string[], @@ -188,8 +195,8 @@ export const subscribeLogbook = ( if (deviceIds?.length) { params.device_ids = deviceIds; } - return hass.connection.subscribeMessage( - (message?) => callbackFunction(message), + return hass.connection.subscribeMessage( + (message) => callbackFunction(message), params ); }; diff --git a/src/panels/logbook/ha-logbook.ts b/src/panels/logbook/ha-logbook.ts index 287d73b800..bf5e053027 100644 --- a/src/panels/logbook/ha-logbook.ts +++ b/src/panels/logbook/ha-logbook.ts @@ -9,6 +9,7 @@ import "../../components/ha-circular-progress"; import { clearLogbookCache, LogbookEntry, + LogbookStreamMessage, subscribeLogbook, } from "../../data/logbook"; import { loadTraceContexts, TraceContexts } from "../../data/trace"; @@ -247,17 +248,16 @@ export class HaLogbook extends LitElement { } this._subscribed = subscribeLogbook( this.hass, - (newEntries?) => { - if ("recent" in this.time) { - // start time is a sliding window purge old ones - this._processNewEntries( - newEntries, - findStartOfRecentTime(new Date(), this.time.recent) - ); - } else if ("range" in this.time) { - // start time is fixed, we can just append - this._processNewEntries(newEntries, undefined); - } + (streamMessage) => { + // "recent" means start time is a sliding window + // so we need to calculate an expireTime to + // purge old events + this._processStreamMessage( + streamMessage, + "recent" in this.time + ? findStartOfRecentTime(new Date(), this.time.recent) + : undefined + ); }, logbookPeriod.startTime.toISOString(), logbookPeriod.endTime.toISOString(), @@ -303,17 +303,22 @@ export class HaLogbook extends LitElement { ) : this._logbookEntries; - private _processNewEntries = ( - newEntries: LogbookEntry[], + private _processStreamMessage = ( + streamMessage: LogbookStreamMessage, purgeBeforePythonTime: number | undefined ) => { // Put newest ones on top. Reverse works in-place so // make a copy first. - newEntries = [...newEntries].reverse(); + const newEntries = [...streamMessage.events].reverse(); if (!this._logbookEntries) { this._logbookEntries = newEntries; return; } + if (!newEntries.length) { + // Empty messages are still sent to + // indicate no more historical events + return; + } const nonExpiredRecords = this._nonExpiredRecords(purgeBeforePythonTime); this._logbookEntries = newEntries[0].when >= this._logbookEntries[0].when From 36e1203fb196d766889750a5d4e32f31fad6e473 Mon Sep 17 00:00:00 2001 From: Philip Allgaier Date: Tue, 24 May 2022 15:10:26 +0200 Subject: [PATCH 074/112] Adjust path to version info in issue template (#12760) --- .github/ISSUE_TEMPLATE.md | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index c42de86e68..2326565499 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -51,7 +51,7 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w