mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-19 12:19:32 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63d1d1b9b8 | ||
|
|
bab1485473 | ||
|
|
363a1861e6 | ||
|
|
38ac2a01ec | ||
|
|
7edd0f1c38 | ||
|
|
313ec9440e | ||
|
|
5f0fb8ab23 | ||
|
|
b9a04ed660 | ||
|
|
60108e1d74 | ||
|
|
ca685a5c13 | ||
|
|
f008af2751 | ||
|
|
47a0f8863d | ||
|
|
f2bf89ad69 | ||
|
|
c2955a8745 |
No files matched your search
@@ -34,7 +34,7 @@ runs:
|
||||
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@
|
||||
"home-assistant-js-websocket": "9.7.1",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "12.1.3",
|
||||
"js-yaml": "5.4.3",
|
||||
"js-yaml": "5.5.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
|
||||
Generated
+11
-6
@@ -386,8 +386,8 @@ importers:
|
||||
specifier: 12.1.3
|
||||
version: 12.1.3
|
||||
js-yaml:
|
||||
specifier: 5.4.3
|
||||
version: 5.4.3
|
||||
specifier: 5.5.0
|
||||
version: 5.5.0
|
||||
leaflet:
|
||||
specifier: 1.9.4
|
||||
version: 1.9.4
|
||||
@@ -3916,6 +3916,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dgojXfc4SiqmNwe38PnbT3zJasrz7g62dLAPD+VFT5RJb8W7LGRqw2IFd2ES+plnhsp4HYNJmFqMU1tCThdCww==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -5629,8 +5632,8 @@ packages:
|
||||
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@5.4.3:
|
||||
resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==}
|
||||
js-yaml@5.5.0:
|
||||
resolution: {integrity: sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
@@ -11518,6 +11521,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -13365,9 +13370,9 @@ snapshots:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
js-yaml@5.4.3:
|
||||
js-yaml@5.5.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
argparse: 3.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
@@ -29,7 +29,7 @@ const ALLOWED_LICENSES = new Set([
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT OR Apache-2.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"PSF-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
"W3C-20150513", // wicg-inert - W3C Software and Document License (permissive)
|
||||
"Unlicense",
|
||||
|
||||
@@ -5,14 +5,20 @@ export const subscribePollingCollection = (
|
||||
updateData: (hass: HomeAssistant) => void,
|
||||
interval: number
|
||||
) => {
|
||||
let timeout;
|
||||
let timeout: number | undefined;
|
||||
let cancelled = false;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
await updateData(hass);
|
||||
} finally {
|
||||
timeout = setTimeout(() => fetchData(), interval);
|
||||
if (!cancelled) {
|
||||
timeout = window.setTimeout(() => fetchData(), interval);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
return () => clearTimeout(timeout);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { parsePlainText } from "zrender/lib/graphic/helper/parseText.js";
|
||||
|
||||
const FONT_FAMILY = "Roboto, Noto, sans-serif";
|
||||
// zrender's overflow "break" only wraps at these characters, see isWordBreakChar
|
||||
const WORD_BREAK =
|
||||
/[ ,&?/;\]]|[^\u0020-\u024F\u0370-\u10FF\u1200-\u13FF\u1E00-\u206F]/;
|
||||
|
||||
interface FitLabelOptions {
|
||||
/** Font size the label is measured at, and the largest size returned */
|
||||
fontSize: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Height of the label at `fontSize` before ECharts wraps it */
|
||||
unwrappedHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest font size at which an ECharts label with `overflow: "break"`,
|
||||
* wrapped at `width`, fits in `height` without breaking a word.
|
||||
*/
|
||||
export function fitLabelFontSize(
|
||||
text: string,
|
||||
{ fontSize: baseFontSize, width, height, unwrappedHeight }: FitLabelOptions
|
||||
) {
|
||||
const font = `${baseFontSize}px ${FONT_FAMILY}`;
|
||||
const measure = (label: string, wrapWidth?: number) =>
|
||||
parsePlainText(
|
||||
label,
|
||||
{ font, width: wrapWidth, overflow: "break" },
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
// ECharts measures at the final font size, leave room for rounding
|
||||
const fitWidth = Math.max(width - 1, 1);
|
||||
const unwrappedContentHeight = measure(text).contentHeight;
|
||||
const words = text.split(WORD_BREAK).filter(Boolean);
|
||||
// a label at a smaller font wraps like one at baseFontSize in a wider box
|
||||
const wrapWidthAt = (fontSize: number) =>
|
||||
(fitWidth * baseFontSize) / fontSize;
|
||||
const maxFontSize = (height / unwrappedHeight) * baseFontSize;
|
||||
const fits = (fontSize: number) => {
|
||||
const wrapWidth = wrapWidthAt(fontSize);
|
||||
if (words.some((word) => measure(word, wrapWidth).lines.length > 1)) {
|
||||
return false;
|
||||
}
|
||||
const wrappedHeight =
|
||||
unwrappedHeight +
|
||||
(measure(text, wrapWidth).contentHeight - unwrappedContentHeight);
|
||||
// exact at maxFontSize when the label doesn't wrap
|
||||
return fontSize <= maxFontSize * (unwrappedHeight / wrappedHeight);
|
||||
};
|
||||
let low = 0;
|
||||
let high = Math.min(baseFontSize, maxFontSize);
|
||||
if (fits(high)) {
|
||||
low = high;
|
||||
} else {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const mid = (low + high) / 2;
|
||||
if (fits(mid)) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
// zrender moves a space that doesn't fit to the next line, indenting it.
|
||||
// Shrinking further only widens the wrap, so the label still fits.
|
||||
const spaceWidth = measure(" ").contentWidth;
|
||||
const indents = (fontSize: number) =>
|
||||
text
|
||||
.split("\n")
|
||||
.some((paragraph) =>
|
||||
measure(paragraph, wrapWidthAt(fontSize)).lines.some(
|
||||
(line, i) => i > 0 && line.startsWith(" ")
|
||||
)
|
||||
);
|
||||
let fontSize = low;
|
||||
for (let i = 0; i < 3 && indents(fontSize); i++) {
|
||||
fontSize = (fitWidth * baseFontSize) / (wrapWidthAt(fontSize) + spaceWidth);
|
||||
}
|
||||
return fontSize;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import SankeyChart from "../../resources/echarts/components/sankey/install";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fitLabelFontSize } from "./fit-label-font-size";
|
||||
import "./ha-chart-base";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import "../ha-alert";
|
||||
@@ -279,13 +280,14 @@ export class HaSankeyChart extends LitElement {
|
||||
}
|
||||
|
||||
const availableHeight = params.rect.height + 8 * this._currentZoom; // account for the margin
|
||||
const fontSize = Math.min(
|
||||
(availableHeight / params.labelRect.height) * FONT_SIZE,
|
||||
FONT_SIZE
|
||||
);
|
||||
return {
|
||||
fontSize,
|
||||
lineHeight: fontSize,
|
||||
// labelRect is measured before the width is applied, so unwrapped
|
||||
fontSize: fitLabelFontSize(params.text, {
|
||||
fontSize: FONT_SIZE,
|
||||
width: labelSpace,
|
||||
height: availableHeight,
|
||||
unwrappedHeight: params.labelRect.height,
|
||||
}),
|
||||
width: labelSpace,
|
||||
height: params.rect.height,
|
||||
};
|
||||
|
||||
@@ -15,14 +15,16 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { transform } from "../common/decorators/transform";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import {
|
||||
createAssistMessageProcessor,
|
||||
type AssistMessage,
|
||||
type AssistMessageProcessor,
|
||||
} from "../data/assist_chat";
|
||||
import {
|
||||
runAssistPipeline,
|
||||
type AssistPipeline,
|
||||
type ConversationChatLogAssistantDelta,
|
||||
type ConversationChatLogToolResultDelta,
|
||||
type PipelineRunEvent,
|
||||
} from "../data/assist_pipeline";
|
||||
import type { ChatLogToolResult } from "../data/chat_log";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
@@ -52,22 +54,6 @@ import "./ha-markdown";
|
||||
import "./input/ha-input";
|
||||
import type { HaInput } from "./input/ha-input";
|
||||
|
||||
interface AssistMessage {
|
||||
who: string;
|
||||
text: string;
|
||||
thinking: string;
|
||||
thinking_expanded?: boolean;
|
||||
tool_calls: Record<
|
||||
string,
|
||||
{
|
||||
tool_name: string;
|
||||
tool_args: Record<string, unknown>;
|
||||
result?: ChatLogToolResult;
|
||||
}
|
||||
>;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const initialPromptToSubmit = (
|
||||
prompt: string | undefined,
|
||||
submit: boolean
|
||||
@@ -653,6 +639,9 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
hassMessageProcesser.addMessage();
|
||||
} else if (event.type.startsWith("intent-")) {
|
||||
hassMessageProcesser.processEvent(event);
|
||||
if (event.type === "intent-end") {
|
||||
this._conversationId = event.data.intent_output.conversation_id;
|
||||
}
|
||||
} else if (event.type === "run-end") {
|
||||
this._stt_binary_handler_id = undefined;
|
||||
unsub();
|
||||
@@ -744,6 +733,9 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
(event) => {
|
||||
if (event.type.startsWith("intent-")) {
|
||||
hassMessageProcesser.processEvent(event);
|
||||
if (event.type === "intent-end") {
|
||||
this._conversationId = event.data.intent_output.conversation_id;
|
||||
}
|
||||
}
|
||||
if (event.type === "intent-end") {
|
||||
unsub();
|
||||
@@ -770,118 +762,11 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
}
|
||||
}
|
||||
|
||||
private _createAddHassMessageProcessor() {
|
||||
let currentDeltaRole = "";
|
||||
|
||||
const progressToNextMessage = () => {
|
||||
if (
|
||||
progress.hassMessage.text === "…" &&
|
||||
!progress.hassMessage.thinking &&
|
||||
(!progress.hassMessage.tool_calls ||
|
||||
Object.keys(progress.hassMessage.tool_calls).length === 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (progress.hassMessage.text?.endsWith("…")) {
|
||||
progress.hassMessage.text = progress.hassMessage.text.slice(0, -1);
|
||||
}
|
||||
|
||||
progress.hassMessage = {
|
||||
who: "hass",
|
||||
text: "…",
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
error: false,
|
||||
};
|
||||
this._addMessage(progress.hassMessage);
|
||||
};
|
||||
|
||||
const isAssistantDelta = (
|
||||
_delta: any
|
||||
): _delta is Partial<ConversationChatLogAssistantDelta> =>
|
||||
currentDeltaRole === "assistant";
|
||||
|
||||
const isToolResult = (
|
||||
_delta: any
|
||||
): _delta is ConversationChatLogToolResultDelta =>
|
||||
currentDeltaRole === "tool_result";
|
||||
|
||||
const progress = {
|
||||
continueConversation: false,
|
||||
hassMessage: {
|
||||
who: "hass",
|
||||
text: "…",
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
error: false,
|
||||
},
|
||||
addMessage: () => {
|
||||
this._addMessage(progress.hassMessage);
|
||||
},
|
||||
setError: (error: string) => {
|
||||
progressToNextMessage();
|
||||
progress.hassMessage.text = error;
|
||||
progress.hassMessage.error = true;
|
||||
this.requestUpdate("_conversation");
|
||||
},
|
||||
processEvent: (event: PipelineRunEvent) => {
|
||||
if (event.type === "intent-progress" && event.data.chat_log_delta) {
|
||||
const delta = event.data.chat_log_delta;
|
||||
|
||||
// new message
|
||||
if (delta.role) {
|
||||
currentDeltaRole = delta.role;
|
||||
}
|
||||
|
||||
if (isAssistantDelta(delta)) {
|
||||
if (delta.content) {
|
||||
if (progress.hassMessage.text.endsWith("…")) {
|
||||
progress.hassMessage.text =
|
||||
progress.hassMessage.text.substring(
|
||||
0,
|
||||
progress.hassMessage.text.length - 1
|
||||
) +
|
||||
delta.content +
|
||||
"…";
|
||||
} else {
|
||||
progress.hassMessage.text += delta.content + "…";
|
||||
}
|
||||
}
|
||||
if (delta.thinking_content) {
|
||||
progress.hassMessage.thinking += delta.thinking_content;
|
||||
}
|
||||
if (delta.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
progress.hassMessage.tool_calls[toolCall.id] = toolCall;
|
||||
}
|
||||
}
|
||||
this.requestUpdate("_conversation");
|
||||
} else if (isToolResult(delta)) {
|
||||
if (progress.hassMessage.tool_calls[delta.tool_call_id]) {
|
||||
progress.hassMessage.tool_calls[delta.tool_call_id].result =
|
||||
delta.result;
|
||||
this.requestUpdate("_conversation");
|
||||
}
|
||||
}
|
||||
} else if (event.type === "intent-end") {
|
||||
this._conversationId = event.data.intent_output.conversation_id;
|
||||
progress.continueConversation =
|
||||
event.data.intent_output.continue_conversation;
|
||||
const response =
|
||||
event.data.intent_output.response.speech.plain?.speech;
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
if (event.data.intent_output.response.response_type === "error") {
|
||||
progress.setError(response);
|
||||
} else {
|
||||
progress.hassMessage.text = response;
|
||||
this.requestUpdate("_conversation");
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
return progress;
|
||||
private _createAddHassMessageProcessor(): AssistMessageProcessor {
|
||||
return createAssistMessageProcessor({
|
||||
addMessage: (message) => this._addMessage(message),
|
||||
requestUpdate: () => this.requestUpdate("_conversation"),
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -17,7 +17,10 @@ import { UNIT_KM } from "../../common/const";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import {
|
||||
findFirstActiveZone,
|
||||
getEntityLocation,
|
||||
} from "../../common/entity/get_entity_location";
|
||||
import { supportsVectorMaps } from "../../common/map/base-layer";
|
||||
import type {
|
||||
MapCircleHandle,
|
||||
@@ -66,6 +69,7 @@ import {
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../../data/context";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { ensureMapTilesToken } from "../../data/map_tiles";
|
||||
import "../ha-tooltip";
|
||||
@@ -78,7 +82,6 @@ import type {
|
||||
ThemeMode,
|
||||
} from "../../types";
|
||||
import { FLOATING_LIFT, floatingMarkerFootprint } from "./ha-entity-marker";
|
||||
import { computeZoneTrackerState } from "../../data/zone";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -528,8 +531,19 @@ export class HaMap extends ReactiveElement {
|
||||
} else if (this._loaded && oldStates && this.entities) {
|
||||
// Check if any state has changed
|
||||
for (const entity of this.entities) {
|
||||
const entityId = getEntityId(entity);
|
||||
const stateObj = this._states[entityId];
|
||||
const inZones: string[] | undefined = stateObj?.attributes.in_zones;
|
||||
if (
|
||||
oldStates[getEntityId(entity)] !== this._states[getEntityId(entity)]
|
||||
oldStates[entityId] !== stateObj ||
|
||||
(stateObj &&
|
||||
["person", "device_tracker"].includes(
|
||||
computeStateDomain(stateObj)
|
||||
) &&
|
||||
Array.isArray(inZones) &&
|
||||
inZones.some(
|
||||
(zoneId) => oldStates[zoneId] !== this._states[zoneId]
|
||||
))
|
||||
) {
|
||||
this._drawEntities();
|
||||
autoFitRequired = !this._pauseAutoFit;
|
||||
@@ -1347,8 +1361,6 @@ export class HaMap extends ReactiveElement {
|
||||
this._watchRegistry();
|
||||
|
||||
const computedStyles = getComputedStyle(this);
|
||||
// A person's state is "home" for the home zone, the zone name otherwise
|
||||
const zoneByState: Record<string, string> = {};
|
||||
this._zonePositions = {};
|
||||
this._zoneRadii = {};
|
||||
for (const entity of this.entities) {
|
||||
@@ -1359,7 +1371,6 @@ export class HaMap extends ReactiveElement {
|
||||
computeStateDomain(stateObj) === "zone" &&
|
||||
(this.renderPassive || !stateObj.attributes.passive)
|
||||
) {
|
||||
zoneByState[computeZoneTrackerState(stateObj)] = stateObj.entity_id;
|
||||
if (
|
||||
typeof stateObj.attributes.latitude === "number" &&
|
||||
typeof stateObj.attributes.longitude === "number"
|
||||
@@ -1518,6 +1529,22 @@ export class HaMap extends ReactiveElement {
|
||||
typeof entity !== "string" && (entity.selected ?? false);
|
||||
entityMarker.floating = true;
|
||||
|
||||
let zoneId: string | undefined;
|
||||
if (["person", "device_tracker"].includes(computeStateDomain(stateObj))) {
|
||||
const inZones: string[] | undefined = stateObj.attributes.in_zones;
|
||||
if (Array.isArray(inZones) && inZones.length > 0) {
|
||||
const activeZone = findFirstActiveZone(inZones, states);
|
||||
if (activeZone && this._zonePositions[activeZone.entity_id]) {
|
||||
zoneId = activeZone.entity_id;
|
||||
}
|
||||
} else if (
|
||||
!Array.isArray(inZones) ||
|
||||
!["not_home", UNKNOWN, UNAVAILABLE].includes(stateObj.state)
|
||||
) {
|
||||
zoneId = this._zoneContaining(position, gpsAccuracy);
|
||||
}
|
||||
}
|
||||
|
||||
const clusterData: ClusterData = {
|
||||
entityId,
|
||||
title,
|
||||
@@ -1527,11 +1554,7 @@ export class HaMap extends ReactiveElement {
|
||||
unit: entityMarker.entityUnit ?? "",
|
||||
color: entityColor,
|
||||
selected: entityMarker.selected,
|
||||
zoneId: ["person", "device_tracker"].includes(
|
||||
computeStateDomain(stateObj)
|
||||
)
|
||||
? (zoneByState[stateObj.state] ?? this._zoneContaining(position))
|
||||
: undefined,
|
||||
zoneId,
|
||||
};
|
||||
|
||||
const accuracy =
|
||||
@@ -1591,16 +1614,26 @@ export class HaMap extends ReactiveElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _zoneContaining(position: MapLatLng): string | undefined {
|
||||
private _zoneContaining(
|
||||
position: MapLatLng,
|
||||
gpsAccuracy = 0
|
||||
): string | undefined {
|
||||
let found: string | undefined;
|
||||
let foundRadius = Infinity;
|
||||
let foundDistance = Infinity;
|
||||
for (const [zoneId, radius] of Object.entries(this._zoneRadii)) {
|
||||
if (this._states[zoneId]?.attributes.passive) {
|
||||
continue;
|
||||
}
|
||||
const distance = distanceMeters(position, this._zonePositions[zoneId]);
|
||||
if (
|
||||
radius < foundRadius &&
|
||||
distanceMeters(position, this._zonePositions[zoneId]) <= radius
|
||||
distance - gpsAccuracy < radius &&
|
||||
(radius < foundRadius ||
|
||||
(radius === foundRadius && distance < foundDistance))
|
||||
) {
|
||||
found = zoneId;
|
||||
foundRadius = radius;
|
||||
foundDistance = distance;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type {
|
||||
ConversationChatLogAssistantDelta,
|
||||
ConversationChatLogToolResultDelta,
|
||||
PipelineRunEvent,
|
||||
} from "./assist_pipeline";
|
||||
import type { ChatLogToolResult } from "./chat_log";
|
||||
|
||||
export interface AssistMessage {
|
||||
who: string;
|
||||
text: string;
|
||||
thinking: string;
|
||||
thinking_expanded?: boolean;
|
||||
tool_calls: Record<
|
||||
string,
|
||||
{
|
||||
tool_name: string;
|
||||
tool_args: Record<string, unknown>;
|
||||
result?: ChatLogToolResult;
|
||||
}
|
||||
>;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export interface AssistMessageProcessor {
|
||||
readonly continueConversation: boolean;
|
||||
readonly hassMessage: AssistMessage;
|
||||
addMessage: () => void;
|
||||
setError: (error: string) => void;
|
||||
processEvent: (event: PipelineRunEvent) => void;
|
||||
}
|
||||
|
||||
type ChatLogDelta =
|
||||
| Partial<ConversationChatLogAssistantDelta>
|
||||
| ConversationChatLogToolResultDelta;
|
||||
|
||||
const STREAMING_ELLIPSIS = "…";
|
||||
|
||||
const stripStreamingEllipsis = (text: string): string =>
|
||||
text.endsWith(STREAMING_ELLIPSIS) ? text.slice(0, -1) : text;
|
||||
|
||||
const joinWithBreak = (previous: string, next: string): string => {
|
||||
if (previous === "" || next === "") {
|
||||
return previous + next;
|
||||
}
|
||||
if (
|
||||
/\r?\n\r?\n$/.test(previous) ||
|
||||
/^\r?\n\r?\n/.test(next) ||
|
||||
(/\r?\n$/.test(previous) && /^\r?\n/.test(next))
|
||||
) {
|
||||
return previous + next;
|
||||
}
|
||||
return /\r?\n$/.test(previous) || /^\r?\n/.test(next)
|
||||
? `${previous}\n${next}`
|
||||
: `${previous}\n\n${next}`;
|
||||
};
|
||||
|
||||
const newAssistantMessage = (): AssistMessage => ({
|
||||
who: "hass",
|
||||
text: STREAMING_ELLIPSIS,
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
error: false,
|
||||
});
|
||||
|
||||
export const createAssistMessageProcessor = ({
|
||||
addMessage,
|
||||
requestUpdate,
|
||||
}: {
|
||||
addMessage: (message: AssistMessage) => void;
|
||||
requestUpdate: () => void;
|
||||
}): AssistMessageProcessor => {
|
||||
let currentDeltaRole = "";
|
||||
// The role and the content of a new message can arrive in separate deltas.
|
||||
let pendingMessageBoundary = false;
|
||||
// Once finalized, a trailing ellipsis is part of the reply and is kept.
|
||||
let streamingMarker = true;
|
||||
let latestSegment = "";
|
||||
let continueConversation = false;
|
||||
let hassMessage: AssistMessage = newAssistantMessage();
|
||||
|
||||
const isMessageEmpty = (message: AssistMessage) =>
|
||||
streamingMarker &&
|
||||
message.text === STREAMING_ELLIPSIS &&
|
||||
!message.thinking &&
|
||||
Object.keys(message.tool_calls).length === 0;
|
||||
|
||||
const finalizeStreamingText = () => {
|
||||
if (streamingMarker) {
|
||||
hassMessage.text = stripStreamingEllipsis(hassMessage.text);
|
||||
}
|
||||
streamingMarker = false;
|
||||
};
|
||||
|
||||
const progressToNextMessage = () => {
|
||||
if (isMessageEmpty(hassMessage)) {
|
||||
return;
|
||||
}
|
||||
finalizeStreamingText();
|
||||
hassMessage = newAssistantMessage();
|
||||
streamingMarker = true;
|
||||
latestSegment = "";
|
||||
addMessage(hassMessage);
|
||||
};
|
||||
|
||||
const isAssistantDelta = (
|
||||
_delta: ChatLogDelta
|
||||
): _delta is Partial<ConversationChatLogAssistantDelta> =>
|
||||
currentDeltaRole === "assistant";
|
||||
|
||||
const isToolResultDelta = (
|
||||
_delta: ChatLogDelta
|
||||
): _delta is ConversationChatLogToolResultDelta =>
|
||||
currentDeltaRole === "tool_result";
|
||||
|
||||
// The response speech only contains the final assistant message, so merge
|
||||
// it into the streamed text instead of replacing it.
|
||||
const applyFinalResponse = (response: string) => {
|
||||
finalizeStreamingText();
|
||||
const streamed = hassMessage.text;
|
||||
if (streamed === "") {
|
||||
hassMessage.text = response;
|
||||
} else if (latestSegment.trim() !== response.trim()) {
|
||||
hassMessage.text = joinWithBreak(streamed, response);
|
||||
}
|
||||
};
|
||||
|
||||
const setError = (error: string) => {
|
||||
progressToNextMessage();
|
||||
hassMessage.text = error;
|
||||
hassMessage.error = true;
|
||||
streamingMarker = false;
|
||||
requestUpdate();
|
||||
};
|
||||
|
||||
const processEvent = (event: PipelineRunEvent) => {
|
||||
if (event.type === "intent-progress" && event.data.chat_log_delta) {
|
||||
const delta = event.data.chat_log_delta;
|
||||
|
||||
// new message
|
||||
if (delta.role) {
|
||||
currentDeltaRole = delta.role;
|
||||
if (delta.role === "assistant") {
|
||||
pendingMessageBoundary = true;
|
||||
latestSegment = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (isAssistantDelta(delta)) {
|
||||
if (delta.content) {
|
||||
let text = stripStreamingEllipsis(hassMessage.text);
|
||||
if (pendingMessageBoundary) {
|
||||
text = joinWithBreak(text, delta.content);
|
||||
pendingMessageBoundary = false;
|
||||
} else {
|
||||
text += delta.content;
|
||||
}
|
||||
latestSegment += delta.content;
|
||||
hassMessage.text = text + STREAMING_ELLIPSIS;
|
||||
streamingMarker = true;
|
||||
}
|
||||
if (delta.thinking_content) {
|
||||
hassMessage.thinking += delta.thinking_content;
|
||||
}
|
||||
if (delta.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
hassMessage.tool_calls[toolCall.id] = toolCall;
|
||||
}
|
||||
}
|
||||
requestUpdate();
|
||||
} else if (isToolResultDelta(delta)) {
|
||||
if (hassMessage.tool_calls[delta.tool_call_id]) {
|
||||
hassMessage.tool_calls[delta.tool_call_id].result = delta.result;
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
} else if (event.type === "intent-end") {
|
||||
continueConversation = event.data.intent_output.continue_conversation;
|
||||
const response = event.data.intent_output.response.speech.plain?.speech;
|
||||
if (
|
||||
event.data.intent_output.response.response_type === "error" &&
|
||||
response
|
||||
) {
|
||||
setError(response);
|
||||
return;
|
||||
}
|
||||
if (response) {
|
||||
applyFinalResponse(response);
|
||||
} else {
|
||||
finalizeStreamingText();
|
||||
}
|
||||
pendingMessageBoundary = false;
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get continueConversation() {
|
||||
return continueConversation;
|
||||
},
|
||||
get hassMessage() {
|
||||
return hassMessage;
|
||||
},
|
||||
addMessage: () => {
|
||||
addMessage(hassMessage);
|
||||
},
|
||||
setError,
|
||||
processEvent,
|
||||
};
|
||||
};
|
||||
+15
-11
@@ -1,7 +1,11 @@
|
||||
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { atLeastVersion } from "../common/config/version";
|
||||
import type { HomeAssistant, LogFileDisabledReason } from "../types";
|
||||
import type {
|
||||
HomeAssistantApi,
|
||||
HomeAssistantConfig,
|
||||
LogFileDisabledReason,
|
||||
} from "../types";
|
||||
import type { HassioAddonInfo } from "./hassio/addon";
|
||||
import { supervisorUrl } from "./hassio/common";
|
||||
|
||||
@@ -11,12 +15,12 @@ export interface LogProvider {
|
||||
addon?: HassioAddonInfo;
|
||||
}
|
||||
|
||||
const hasSupervisorCoreLogDownload = (hass: HomeAssistant): boolean =>
|
||||
isComponentLoaded(hass.config, "hassio") &&
|
||||
atLeastVersion(hass.config.version, 2025, 10);
|
||||
const hasSupervisorCoreLogDownload = (config: HomeAssistantConfig): boolean =>
|
||||
isComponentLoaded(config.config, "hassio") &&
|
||||
atLeastVersion(config.config.version, 2025, 10);
|
||||
|
||||
export const fetchErrorLog = (hass: HomeAssistant) =>
|
||||
hass.callApi<string>("GET", "error_log");
|
||||
export const fetchErrorLog = (api: HomeAssistantApi) =>
|
||||
api.callApi<string>("GET", "error_log");
|
||||
|
||||
export const subscribeIntegrationLog = (
|
||||
connection: Connection,
|
||||
@@ -31,17 +35,17 @@ export const subscribeIntegrationLog = (
|
||||
{ resubscribe: false }
|
||||
);
|
||||
|
||||
export const getErrorLogDownloadUrl = (hass: HomeAssistant) =>
|
||||
hasSupervisorCoreLogDownload(hass)
|
||||
export const getErrorLogDownloadUrl = (config: HomeAssistantConfig) =>
|
||||
hasSupervisorCoreLogDownload(config)
|
||||
? supervisorUrl("core/logs/latest")
|
||||
: "/api/error_log";
|
||||
|
||||
export const getCoreLogFileDownloadUnavailableReason = (
|
||||
hass: HomeAssistant
|
||||
config: HomeAssistantConfig
|
||||
): LogFileDisabledReason | undefined => {
|
||||
if (hasSupervisorCoreLogDownload(hass)) {
|
||||
if (hasSupervisorCoreLogDownload(config)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return hass.config.logging?.log_file_disabled_reason ?? undefined;
|
||||
return config.config.logging?.log_file_disabled_reason ?? undefined;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import type { HomeAssistant, HomeAssistantApi, PanelInfo } from "../../types";
|
||||
import type { SupervisorArch } from "../supervisor/supervisor";
|
||||
import type { AddonState } from "./addon";
|
||||
import { supervisorUrl, type HassioResponse } from "./common";
|
||||
@@ -157,25 +157,25 @@ export const fetchHassioInfo = async (
|
||||
method: "get",
|
||||
});
|
||||
|
||||
export const fetchHassioBoots = async (hass: HomeAssistant) =>
|
||||
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
|
||||
export const fetchHassioBoots = async (api: HomeAssistantApi) =>
|
||||
api.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
|
||||
|
||||
export const fetchHassioLogsLegacy = async (
|
||||
hass: HomeAssistant,
|
||||
api: HomeAssistantApi,
|
||||
provider: string
|
||||
) =>
|
||||
hass.callApi<string>(
|
||||
api.callApi<string>(
|
||||
"GET",
|
||||
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs`
|
||||
);
|
||||
|
||||
export const fetchHassioLogs = async (
|
||||
hass: HomeAssistant,
|
||||
api: HomeAssistantApi,
|
||||
provider: string,
|
||||
range?: string,
|
||||
boot = 0
|
||||
) =>
|
||||
hass.callApiRaw(
|
||||
api.callApiRaw(
|
||||
"GET",
|
||||
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}`,
|
||||
undefined,
|
||||
@@ -187,13 +187,13 @@ export const fetchHassioLogs = async (
|
||||
);
|
||||
|
||||
export const fetchHassioLogsFollow = async (
|
||||
hass: HomeAssistant,
|
||||
api: HomeAssistantApi,
|
||||
provider: string,
|
||||
signal: AbortSignal,
|
||||
lines = 100,
|
||||
boot = 0
|
||||
) =>
|
||||
hass.callApiRaw(
|
||||
api.callApiRaw(
|
||||
"GET",
|
||||
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}/follow?lines=${lines}`,
|
||||
undefined,
|
||||
@@ -202,7 +202,7 @@ export const fetchHassioLogsFollow = async (
|
||||
);
|
||||
|
||||
export const fetchHassioLogsFollowSkip = async (
|
||||
hass: HomeAssistant,
|
||||
api: HomeAssistantApi,
|
||||
provider: string,
|
||||
signal: AbortSignal,
|
||||
cursor: string,
|
||||
@@ -210,7 +210,7 @@ export const fetchHassioLogsFollowSkip = async (
|
||||
lines = 100,
|
||||
boot = 0
|
||||
) =>
|
||||
hass.callApiRaw(
|
||||
api.callApiRaw(
|
||||
"GET",
|
||||
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}/follow`,
|
||||
undefined,
|
||||
|
||||
@@ -241,6 +241,10 @@ class HaPanelApp extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._getAddonSlug() !== addonSlug || !this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!addon.version) {
|
||||
await this._showErrorAndNavigateHome(
|
||||
addon.name,
|
||||
@@ -324,7 +328,7 @@ class HaPanelApp extends LitElement {
|
||||
}
|
||||
|
||||
// Check if user navigated away while we were fetching
|
||||
if (this._getAddonSlug() !== addonSlug) {
|
||||
if (this._getAddonSlug() !== addonSlug || !this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ class SupervisorAppLogDashboard extends LitElement {
|
||||
</div>
|
||||
<div class="content">
|
||||
<error-log-card
|
||||
.hass=${this.hass}
|
||||
.header=${this.addon.name}
|
||||
.provider=${this.addon.slug}
|
||||
.filter=${this._filter}
|
||||
|
||||
@@ -316,6 +316,12 @@ export class HaAutomationAddItems extends LitElement {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selected-target state-badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selected-target ha-icon,
|
||||
.selected-target ha-svg-icon,
|
||||
.selected-target ha-domain-icon {
|
||||
|
||||
@@ -4,6 +4,7 @@ import "../../../../components/ha-domain-icon";
|
||||
import "../../../../components/ha-floor-icon";
|
||||
import "../../../../components/ha-icon";
|
||||
import "../../../../components/ha-state-icon";
|
||||
import "../../../../components/entity/state-badge";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { ConfigEntry } from "../../../../data/config_entries";
|
||||
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
|
||||
@@ -57,8 +58,20 @@ export const getTargetIcon = (
|
||||
}
|
||||
|
||||
if (targetType === "entity" && states[targetId]) {
|
||||
const stateObj = states[targetId];
|
||||
const hasEntityPicture =
|
||||
stateObj.attributes.entity_picture ||
|
||||
stateObj.attributes.entity_picture_local;
|
||||
|
||||
if (hasEntityPicture) {
|
||||
return html`<state-badge
|
||||
.stateObj=${stateObj}
|
||||
.slot=${slot}
|
||||
></state-badge>`;
|
||||
}
|
||||
|
||||
return html`<ha-state-icon
|
||||
.stateObj=${states[targetId]}
|
||||
.stateObj=${stateObj}
|
||||
.slot=${slot}
|
||||
></ha-state-icon>`;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import "../../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/entity/state-badge";
|
||||
import { showTargetDetailsDialog } from "../../../../components/target-picker/dialog/show-dialog-target-details";
|
||||
import type { ConfigEntry } from "../../../../data/config_entries";
|
||||
import {
|
||||
@@ -680,6 +681,12 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
padding: var(--ha-space-1) 0;
|
||||
}
|
||||
|
||||
.target state-badge,
|
||||
ha-dropdown-item state-badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.target ha-floor-icon {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
@@ -689,18 +696,25 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
:host([size="s"]) {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
:host([size="s"]) .target {
|
||||
height: 24px;
|
||||
}
|
||||
/* A default 24px icon would fill the whole small chip. */
|
||||
|
||||
:host([size="s"]) .target ha-floor-icon {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
:host([size="s"]) .target ha-icon,
|
||||
:host([size="s"]) .target ha-svg-icon,
|
||||
:host([size="s"]) .target ha-domain-icon,
|
||||
:host([size="s"]) .target ha-floor-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
:host([size="s"]) .target ha-floor-icon {
|
||||
height: 24px;
|
||||
:host([size="s"]) .target state-badge {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
|
||||
button.target {
|
||||
|
||||
@@ -1080,8 +1080,8 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
minmax(min(300px, 100%), 1fr)
|
||||
);
|
||||
gap: var(--ha-space-4);
|
||||
padding: var(--ha-space-4);
|
||||
margin: var(--ha-space-2);
|
||||
padding: var(--ha-space-6) var(--ha-space-4) 0;
|
||||
margin: 0;
|
||||
}
|
||||
.empty-message {
|
||||
margin: auto;
|
||||
@@ -1166,10 +1166,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
position: relative;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 8px;
|
||||
margin-left: 16px;
|
||||
margin-inline-start: 16px;
|
||||
margin-inline-end: initial;
|
||||
margin: var(--ha-space-6) var(--ha-space-4) 0;
|
||||
}
|
||||
ha-dropdown ha-icon-button {
|
||||
color: var(--primary-text-color);
|
||||
|
||||
@@ -185,12 +185,17 @@ class MqttSubscribeCard extends LitElement {
|
||||
this._subscribed();
|
||||
this._subscribed = undefined;
|
||||
} else {
|
||||
this._subscribed = await subscribeMQTTTopic(
|
||||
const unsubscribe = await subscribeMQTTTopic(
|
||||
this.hass!,
|
||||
this._topic,
|
||||
(message) => this._handleMessage(message),
|
||||
parseInt(this._qos)
|
||||
);
|
||||
if (!this.isConnected) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
this._subscribed = unsubscribe;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,13 @@ class ZHADevicePage extends LitElement {
|
||||
|
||||
private _deviceRefreshInterval?: number;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this._device) {
|
||||
this._startDeviceRefreshInterval();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._clearDeviceRefreshInterval();
|
||||
@@ -191,7 +198,9 @@ class ZHADevicePage extends LitElement {
|
||||
if (!tabs.includes(this._currTab)) {
|
||||
this._currTab = tabs[0];
|
||||
}
|
||||
this._startDeviceRefreshInterval();
|
||||
if (this.isConnected) {
|
||||
this._startDeviceRefreshInterval();
|
||||
}
|
||||
} catch (_err: any) {
|
||||
if (this.ieee === ieee) {
|
||||
this._error = this.hass.localize(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
|
||||
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import "../../../components/chips/ha-assist-chip";
|
||||
import "../../../components/ha-alert";
|
||||
@@ -40,11 +41,18 @@ import { getSignedPath } from "../../../data/auth";
|
||||
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { atLeastVersion } from "../../../common/config/version";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import type { ConnectionStatus } from "../../../data/connection-status";
|
||||
import {
|
||||
apiContext,
|
||||
configContext,
|
||||
internationalizationContext,
|
||||
} from "../../../data/context";
|
||||
import {
|
||||
fetchErrorLog,
|
||||
getCoreLogFileDownloadUnavailableReason,
|
||||
@@ -59,7 +67,7 @@ import {
|
||||
fetchHassioLogsLegacy,
|
||||
getHassioLogDownloadUrl,
|
||||
} from "../../../data/hassio/supervisor";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HomeAssistantApi, HomeAssistantConfig } from "../../../types";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import { showDownloadLogsDialog } from "./show-dialog-download-logs";
|
||||
|
||||
@@ -67,8 +75,6 @@ const NUMBER_OF_LINES = 100;
|
||||
|
||||
@customElement("error-log-card")
|
||||
class ErrorLogCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public localizeFunc?: LocalizeFunc<any>;
|
||||
|
||||
@property() public filter = "";
|
||||
@@ -80,6 +86,20 @@ class ErrorLogCard extends LitElement {
|
||||
@property({ attribute: "allow-switch", type: Boolean }) public allowSwitch =
|
||||
false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: HomeAssistantApi;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config!: HomeAssistantConfig;
|
||||
|
||||
@query(".error-log") private _logElement?: HTMLElement;
|
||||
|
||||
@query("#scroll-top-marker") private _scrollTopMarkerElement?: HTMLElement;
|
||||
@@ -129,15 +149,15 @@ class ErrorLogCard extends LitElement {
|
||||
const streaming =
|
||||
this._streamSupported &&
|
||||
this.provider &&
|
||||
isComponentLoaded(this.hass.config, "hassio") &&
|
||||
isComponentLoaded(this._config.config, "hassio") &&
|
||||
this._loadingState !== "loading";
|
||||
|
||||
const hasBoots = this._streamSupported && Array.isArray(this._boots);
|
||||
|
||||
const localize = this.localizeFunc || this.hass.localize;
|
||||
const localize = this.localizeFunc || this._localize;
|
||||
const logFileDownloadUnavailableReason =
|
||||
!this.provider || this.provider === "core"
|
||||
? getCoreLogFileDownloadUnavailableReason(this.hass)
|
||||
? getCoreLogFileDownloadUnavailableReason(this._config)
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
@@ -250,7 +270,7 @@ class ErrorLogCard extends LitElement {
|
||||
slot="icon"
|
||||
.path=${mdiFolderTextOutline}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
${this._localize(
|
||||
"ui.panel.config.logs.show_condensed_logs"
|
||||
)}
|
||||
</ha-dropdown-item>`
|
||||
@@ -352,7 +372,7 @@ class ErrorLogCard extends LitElement {
|
||||
super.willUpdate(changedProps);
|
||||
if (!this.hasUpdated) {
|
||||
this._streamSupported = atLeastVersion(
|
||||
this.hass.config.version,
|
||||
this._config.config.version,
|
||||
2024,
|
||||
11
|
||||
);
|
||||
@@ -365,7 +385,7 @@ class ErrorLogCard extends LitElement {
|
||||
this._handleConnectionStatus
|
||||
);
|
||||
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
this._i18n.loadFragmentTranslation("config");
|
||||
}
|
||||
|
||||
if (changedProps.has("provider")) {
|
||||
@@ -421,7 +441,7 @@ class ErrorLogCard extends LitElement {
|
||||
private async _downloadLogs(): Promise<void> {
|
||||
if (
|
||||
(!this.provider || this.provider === "core") &&
|
||||
getCoreLogFileDownloadUnavailableReason(this.hass)
|
||||
getCoreLogFileDownloadUnavailableReason(this._config)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -438,12 +458,13 @@ class ErrorLogCard extends LitElement {
|
||||
const downloadUrl =
|
||||
this.provider && this.provider !== "core"
|
||||
? getHassioLogDownloadUrl(this.provider)
|
||||
: getErrorLogDownloadUrl(this.hass);
|
||||
: getErrorLogDownloadUrl(this._config);
|
||||
const logFileName =
|
||||
this.provider && this.provider !== "core"
|
||||
? `${this.provider}_${timeString}.log`
|
||||
: `home-assistant_${timeString}.log`;
|
||||
const signedUrl = await getSignedPath(this.hass, downloadUrl);
|
||||
|
||||
const signedUrl = await getSignedPath(this._api, downloadUrl);
|
||||
fileDownload(signedUrl.path, logFileName);
|
||||
}
|
||||
}
|
||||
@@ -461,7 +482,7 @@ class ErrorLogCard extends LitElement {
|
||||
|
||||
if (
|
||||
(!this.provider || this.provider === "core") &&
|
||||
getCoreLogFileDownloadUnavailableReason(this.hass)
|
||||
getCoreLogFileDownloadUnavailableReason(this._config)
|
||||
) {
|
||||
this._loadingState = "loaded";
|
||||
return;
|
||||
@@ -469,7 +490,7 @@ class ErrorLogCard extends LitElement {
|
||||
|
||||
const streamLogs =
|
||||
this._streamSupported &&
|
||||
isComponentLoaded(this.hass.config, "hassio") &&
|
||||
isComponentLoaded(this._config.config, "hassio") &&
|
||||
this.provider;
|
||||
|
||||
try {
|
||||
@@ -484,7 +505,7 @@ class ErrorLogCard extends LitElement {
|
||||
if (!retry) {
|
||||
// check if there are any logs at all
|
||||
const testResponse = await fetchHassioLogs(
|
||||
this.hass,
|
||||
this._api,
|
||||
this.provider!,
|
||||
`entries=:-1:`,
|
||||
this._boot
|
||||
@@ -499,7 +520,7 @@ class ErrorLogCard extends LitElement {
|
||||
|
||||
if (retry && this._firstCursor) {
|
||||
response = await fetchHassioLogsFollowSkip(
|
||||
this.hass,
|
||||
this._api,
|
||||
this.provider!,
|
||||
this._logStreamAborter.signal,
|
||||
this._firstCursor,
|
||||
@@ -509,7 +530,7 @@ class ErrorLogCard extends LitElement {
|
||||
);
|
||||
} else {
|
||||
response = await fetchHassioLogsFollow(
|
||||
this.hass,
|
||||
this._api,
|
||||
this.provider!,
|
||||
this._logStreamAborter.signal,
|
||||
NUMBER_OF_LINES,
|
||||
@@ -578,10 +599,11 @@ class ErrorLogCard extends LitElement {
|
||||
// fallback to old method
|
||||
this._streamSupported = false;
|
||||
let logs = "";
|
||||
if (isComponentLoaded(this.hass.config, "hassio") && this.provider) {
|
||||
logs = await fetchHassioLogsLegacy(this.hass, this.provider);
|
||||
|
||||
if (isComponentLoaded(this._config.config, "hassio") && this.provider) {
|
||||
logs = await fetchHassioLogsLegacy(this._api, this.provider);
|
||||
} else {
|
||||
logs = await fetchErrorLog(this.hass);
|
||||
logs = await fetchErrorLog(this._api);
|
||||
}
|
||||
|
||||
if (logs) {
|
||||
@@ -601,7 +623,7 @@ class ErrorLogCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._error = (this.localizeFunc || this.hass.localize)(
|
||||
this._error = (this.localizeFunc || this._localize)(
|
||||
"ui.panel.config.logs.failed_get_logs",
|
||||
{
|
||||
provider: this.provider,
|
||||
@@ -659,7 +681,7 @@ class ErrorLogCard extends LitElement {
|
||||
this._logElement.scrollHeight - this._logElement.scrollTop;
|
||||
this._loadingPrevState = "loading";
|
||||
const response = await fetchHassioLogs(
|
||||
this.hass,
|
||||
this._api,
|
||||
this.provider,
|
||||
`entries=${this._firstCursor}:-100:100`,
|
||||
this._boot
|
||||
@@ -715,10 +737,10 @@ class ErrorLogCard extends LitElement {
|
||||
private async _loadBoots() {
|
||||
if (
|
||||
this._streamSupported &&
|
||||
isComponentLoaded(this.hass.config, "hassio")
|
||||
isComponentLoaded(this._config.config, "hassio")
|
||||
) {
|
||||
try {
|
||||
const { data } = await fetchHassioBoots(this.hass);
|
||||
const { data } = await fetchHassioBoots(this._api);
|
||||
const boots = Object.keys(data.boots)
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
@@ -177,7 +177,6 @@ export class HaConfigLogs extends LitElement {
|
||||
></system-log-card>
|
||||
`
|
||||
: html`<error-log-card
|
||||
.hass=${this.hass}
|
||||
.header=${
|
||||
this._logProviders.find(
|
||||
(p) => p.key === this._selectedLogProvider
|
||||
|
||||
@@ -50,7 +50,7 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _config: FrontendSystemData["home"] = {};
|
||||
@state() private _config?: HomeFrontendSystemData;
|
||||
|
||||
@state() private _securityConfig: SecurityFrontendSystemData = {};
|
||||
|
||||
@@ -83,8 +83,8 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private get _showBanner(): boolean {
|
||||
// Don't show if already dismissed
|
||||
if (this._config.welcome_banner_dismissed) {
|
||||
// Don't show until the config loaded or if already dismissed
|
||||
if (!this._config || this._config.welcome_banner_dismissed) {
|
||||
return false;
|
||||
}
|
||||
// Don't show if HA is not running
|
||||
@@ -197,7 +197,7 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
if (homeResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load home configuration:", homeResult.reason);
|
||||
this._config = {};
|
||||
this._config = undefined;
|
||||
} else {
|
||||
this._config = homeResult.value || {};
|
||||
}
|
||||
@@ -260,7 +260,7 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _editHome = () => {
|
||||
showEditHomeDialog(this, {
|
||||
config: this._config,
|
||||
config: this._config ?? {},
|
||||
saveConfig: async (config) => {
|
||||
await this._saveConfig(config);
|
||||
},
|
||||
@@ -382,6 +382,9 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
private _learnMore() {
|
||||
showNewOverviewDialog(this, {
|
||||
dismiss: async () => {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const newConfig = {
|
||||
...this._config,
|
||||
welcome_banner_dismissed: true,
|
||||
@@ -397,11 +400,11 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
strategy: {
|
||||
type: "home",
|
||||
alert_entities: this._securityConfig.alert_entities,
|
||||
favorite_entities: this._config.favorite_entities,
|
||||
favorite_entities: this._config?.favorite_entities,
|
||||
home_panel: true,
|
||||
hide_welcome_message: this._config.hide_welcome_message,
|
||||
hide_suggested_entities: this._config.hide_suggested_entities,
|
||||
shortcuts: this._config.shortcuts,
|
||||
hide_welcome_message: this._config?.hide_welcome_message,
|
||||
hide_suggested_entities: this._config?.hide_suggested_entities,
|
||||
shortcuts: this._config?.shortcuts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type { AlertBadgeConfig } from "../badges/types";
|
||||
import type { BadgeSuggestionProvider } from "./types";
|
||||
|
||||
export const alertBadgeSuggestions: BadgeSuggestionProvider<AlertBadgeConfig> =
|
||||
{
|
||||
getEntitySuggestion(_hass, entityId) {
|
||||
if (!ALERT_DOMAINS.includes(computeDomain(entityId))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ config: { type: "alert", entity: entityId } }];
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { alertBadgeSuggestions } from "./hui-alert-badge-suggestions";
|
||||
import { entityBadgeSuggestions } from "./hui-entity-badge-suggestions";
|
||||
import type { BadgeSuggestionProvider } from "./types";
|
||||
|
||||
@@ -6,4 +7,5 @@ export const BADGE_SUGGESTION_PROVIDERS: Record<
|
||||
BadgeSuggestionProvider
|
||||
> = {
|
||||
entity: entityBadgeSuggestions,
|
||||
alert: alertBadgeSuggestions,
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiAlertCircle } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import {
|
||||
consumeEntityState,
|
||||
consumeLocalize,
|
||||
} from "../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import {
|
||||
ALERT_DOMAINS,
|
||||
DEFAULT_ALERT_COLOR,
|
||||
isAlertActive,
|
||||
} from "../../../common/entity/alert_active";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
|
||||
import "../../../components/ha-badge";
|
||||
import "../../../components/ha-state-icon";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { formattersContext } from "../../../data/context";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import type { LovelaceBadge, LovelaceBadgeEditor } from "../types";
|
||||
import type { AlertBadgeConfig } from "./types";
|
||||
|
||||
@customElement("hui-alert-badge")
|
||||
export class HuiAlertBadge extends LitElement implements LovelaceBadge {
|
||||
public static async getConfigElement(): Promise<LovelaceBadgeEditor> {
|
||||
await import("../editor/config-elements/hui-alert-badge-editor");
|
||||
|
||||
return document.createElement("hui-alert-badge-editor");
|
||||
}
|
||||
|
||||
public static getStubConfig(
|
||||
hass: HomeAssistant,
|
||||
entities: string[],
|
||||
entitiesFallback: string[]
|
||||
): AlertBadgeConfig {
|
||||
const [entity] = findEntities(
|
||||
hass,
|
||||
1,
|
||||
entities,
|
||||
entitiesFallback,
|
||||
ALERT_DOMAINS
|
||||
);
|
||||
|
||||
return { type: "alert", entity: entity || "" };
|
||||
}
|
||||
|
||||
@state() private _config?: AlertBadgeConfig;
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@state()
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters!: ContextType<typeof formattersContext>;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
public setConfig(config: AlertBadgeConfig): void {
|
||||
if (
|
||||
!isValidEntityId(config.entity) ||
|
||||
(config.alert_color !== undefined &&
|
||||
typeof config.alert_color !== "string") ||
|
||||
(config.clear_color !== undefined &&
|
||||
typeof config.clear_color !== "string") ||
|
||||
(config.pulse !== undefined && typeof config.pulse !== "boolean")
|
||||
) {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
|
||||
if (!ALERT_DOMAINS.includes(computeDomain(config.entity))) {
|
||||
throw new Error(
|
||||
`Specify an entity from within the ${ALERT_DOMAINS.join(", ")} domains`
|
||||
);
|
||||
}
|
||||
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || !this._formatters || !this._localize) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const stateObj = this._stateObj;
|
||||
|
||||
if (!stateObj) {
|
||||
return html`
|
||||
<ha-badge .label=${this._config.entity} class="error">
|
||||
<ha-svg-icon slot="icon" .path=${mdiAlertCircle}></ha-svg-icon>
|
||||
${this._localize("ui.badge.entity.not_found")}
|
||||
</ha-badge>
|
||||
`;
|
||||
}
|
||||
|
||||
const active = isAlertActive(stateObj);
|
||||
|
||||
const color = active
|
||||
? (this._config.alert_color ?? DEFAULT_ALERT_COLOR)
|
||||
: this._config.clear_color;
|
||||
|
||||
const pulse = active && this._config.pulse === true;
|
||||
const hasColor = color !== undefined && color !== "none";
|
||||
|
||||
return html`
|
||||
<ha-badge
|
||||
type="button"
|
||||
.label=${this._formatters.formatEntityName(stateObj)}
|
||||
class=${classMap({ alert: true, pulse, "no-color": !hasColor })}
|
||||
style=${styleMap({
|
||||
"--ha-alert-color":
|
||||
color && hasColor ? computeCssColor(color) : undefined,
|
||||
})}
|
||||
@action=${this._handleAction}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: false,
|
||||
hasDoubleClick: false,
|
||||
})}
|
||||
>
|
||||
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
|
||||
${this._formatters.formatEntityState(stateObj)}
|
||||
</ha-badge>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleAction(ev: ActionHandlerEvent): void {
|
||||
if (ev.detail.action === "tap" && this._config) {
|
||||
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@keyframes pulse-opacity {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: var(--ha-alert-pulse-opacity);
|
||||
}
|
||||
}
|
||||
:host {
|
||||
--ha-alert-pulse-duration: 1s;
|
||||
--ha-alert-pulse-opacity: 0.3;
|
||||
--ha-alert-badge-background: var(
|
||||
--ha-card-background,
|
||||
var(--card-background-color, white)
|
||||
);
|
||||
}
|
||||
ha-badge.error {
|
||||
--badge-color: var(--red-color);
|
||||
}
|
||||
ha-badge.alert {
|
||||
display: block;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
border-radius: var(
|
||||
--ha-badge-border-radius,
|
||||
calc(var(--ha-badge-size, 36px) / 2)
|
||||
);
|
||||
background: var(--ha-alert-badge-background);
|
||||
--ha-card-background: transparent;
|
||||
--badge-color: var(--ha-alert-color);
|
||||
}
|
||||
ha-badge.alert.no-color {
|
||||
--badge-color: var(--secondary-text-color);
|
||||
}
|
||||
ha-badge.alert::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background-color: var(--ha-alert-color);
|
||||
content: "";
|
||||
opacity: var(--ha-alert-pulse-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
ha-badge.alert.pulse::before {
|
||||
animation: pulse-opacity var(--ha-alert-pulse-duration) ease-in-out
|
||||
infinite alternate;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
ha-badge.alert.pulse::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-alert-badge": HuiAlertBadge;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,14 @@ export interface StateLabelBadgeConfig extends LovelaceBadgeConfig {
|
||||
double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface AlertBadgeConfig extends LovelaceBadgeConfig {
|
||||
type: "alert";
|
||||
entity: string;
|
||||
alert_color?: string;
|
||||
clear_color?: string;
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
export interface ShortcutBadgeConfig extends LovelaceBadgeConfig {
|
||||
text?: string;
|
||||
icon?: string;
|
||||
|
||||
@@ -140,8 +140,8 @@ export class HuiHumidifierCard extends LitElement implements LovelaceCard {
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<p class="title">${name}</p>
|
||||
<ha-card role="group" aria-labelledby="title">
|
||||
<p class="title" id="title">${name}</p>
|
||||
<div class="container">
|
||||
<ha-state-control-humidifier-humidity
|
||||
style=${styleMap({
|
||||
|
||||
@@ -30,7 +30,7 @@ export class HuiRecoveryModeCard extends LitElement implements LovelaceCard {
|
||||
"ui.panel.lovelace.cards.recovery-mode.description"
|
||||
)}
|
||||
</div>
|
||||
<error-log-card .hass=${this.hass} provider="core"></error-log-card>
|
||||
<error-log-card provider="core"></error-log-card>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -139,8 +139,8 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
<p class="title">${name}</p>
|
||||
<ha-card role="group" aria-labelledby="title">
|
||||
<p class="title" id="title">${name}</p>
|
||||
<div class="container">
|
||||
${
|
||||
domain === "water_heater"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
const ALWAYS_LOADED_TYPES = new Set(["error", "entity"]);
|
||||
const LAZY_LOAD_TYPES = {
|
||||
alert: () => import("../badges/hui-alert-badge"),
|
||||
"entity-filter": () => import("../badges/hui-entity-filter-badge"),
|
||||
shortcut: () => import("../badges/hui-shortcut-badge"),
|
||||
"state-label": () => import("../badges/hui-state-label-badge"),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { assert, assign, boolean, object, optional, string } from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import {
|
||||
ALERT_DOMAINS,
|
||||
DEFAULT_ALERT_COLOR,
|
||||
} from "../../../../common/entity/alert_active";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { AlertBadgeConfig } from "../../badges/types";
|
||||
import type { LovelaceBadgeEditor } from "../../types";
|
||||
import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct";
|
||||
|
||||
const badgeConfigStruct = assign(
|
||||
baseLovelaceBadgeConfig,
|
||||
object({
|
||||
entity: optional(string()),
|
||||
alert_color: optional(string()),
|
||||
clear_color: optional(string()),
|
||||
pulse: optional(boolean()),
|
||||
})
|
||||
);
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "entity",
|
||||
required: true,
|
||||
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "clear_color",
|
||||
selector: { ui_color: { include_none: true, default_color: "none" } },
|
||||
},
|
||||
{
|
||||
name: "alert_color",
|
||||
selector: {
|
||||
ui_color: { include_none: true, default_color: DEFAULT_ALERT_COLOR },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: "pulse", selector: { boolean: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("hui-alert-badge-editor")
|
||||
export class HuiAlertBadgeEditor
|
||||
extends LitElement
|
||||
implements LovelaceBadgeEditor
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() private _config?: AlertBadgeConfig;
|
||||
|
||||
public setConfig(config: AlertBadgeConfig): void {
|
||||
assert(config, badgeConfigStruct);
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<AlertBadgeConfig>): void {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "config-changed", { config: ev.detail.value });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
if (schema.name === "entity") {
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.entity"
|
||||
);
|
||||
}
|
||||
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}`
|
||||
);
|
||||
};
|
||||
|
||||
private _computeHelperCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
if (
|
||||
schema.name === "pulse" ||
|
||||
schema.name === "alert_color" ||
|
||||
schema.name === "clear_color"
|
||||
) {
|
||||
return this.hass?.localize(
|
||||
`ui.panel.lovelace.editor.card.alert.${schema.name}_helper`
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-alert-badge-editor": HuiAlertBadgeEditor;
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,8 @@ export const coreBadges: Badge[] = [
|
||||
type: "shortcut",
|
||||
showElement: true,
|
||||
},
|
||||
{
|
||||
type: "alert",
|
||||
showElement: true,
|
||||
},
|
||||
];
|
||||
@@ -465,14 +465,20 @@ class HUIRoot extends LitElement {
|
||||
!view.visible.some((e) => e.user === this.hass!.user?.id)) ||
|
||||
view.visible === false);
|
||||
|
||||
const tabs = html`<ha-tab-group @wa-tab-show=${this._handleViewSelected}>
|
||||
const tabs = html`<ha-tab-group
|
||||
.active=${typeof this._curView === "number" ? String(this._curView) : ""}
|
||||
@wa-tab-show=${this._handleViewSelected}
|
||||
>
|
||||
${views.map((view, index) => {
|
||||
const icon_and_title =
|
||||
view.show_icon_and_title && view.icon && view.title;
|
||||
const icon_only = view.icon && !icon_and_title;
|
||||
const title_only = !icon_only && !icon_and_title;
|
||||
const current = this._curView === index;
|
||||
const hidden =
|
||||
!this._editMode && (view.subview || _isTabHiddenForUser(view));
|
||||
!this._editMode &&
|
||||
!current &&
|
||||
(view.subview || _isTabHiddenForUser(view));
|
||||
const tabContent = html`
|
||||
${
|
||||
icon_only || icon_and_title
|
||||
@@ -497,7 +503,7 @@ class HUIRoot extends LitElement {
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
panel=${index}
|
||||
.active=${this._curView === index}
|
||||
.active=${current}
|
||||
.disabled=${hidden}
|
||||
aria-label=${ifDefined(view.title)}
|
||||
data-path=${view.path || index}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { dataTableQueryParamConfig } from "../../../common/url/data-table-query-params";
|
||||
import {
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
} from "../../../common/url/query-params";
|
||||
import type { DataTableRowData } from "../../../components/data-table/ha-data-table";
|
||||
import type { RepositoryBase } from "../../../data/marketplace/repository";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { MarketplaceTab } from "./ha-marketplace-dashboard";
|
||||
|
||||
export const STATUS_ORDER = [
|
||||
@@ -51,6 +54,74 @@ export type RepositoryFilters = Partial<
|
||||
>
|
||||
>;
|
||||
|
||||
export interface MarketplaceRepository extends RepositoryBase {
|
||||
update_skipped?: boolean;
|
||||
}
|
||||
|
||||
export const marketplaceUpdateEntityIds = (
|
||||
entities: HomeAssistant["entities"]
|
||||
): string[] =>
|
||||
Object.values(entities)
|
||||
.filter(
|
||||
(entity) =>
|
||||
entity.platform === "marketplace" &&
|
||||
computeDomain(entity.entity_id) === "update" &&
|
||||
entity.device_id
|
||||
)
|
||||
.map((entity) => entity.entity_id);
|
||||
|
||||
// The skip is kept on the update entity, its device names the repository
|
||||
export const skippedUpdateRepositories = (
|
||||
entityIds: string[],
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
states: HassEntities
|
||||
): string[] =>
|
||||
entityIds
|
||||
.flatMap((entityId) => {
|
||||
const entity = entities[entityId];
|
||||
const attributes = states[entityId]?.attributes;
|
||||
|
||||
if (
|
||||
!entity?.device_id ||
|
||||
!attributes?.latest_version ||
|
||||
attributes.skipped_version !== attributes.latest_version
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const repositoryId = devices[entity.device_id]?.identifiers.find(
|
||||
([domain]) => domain === "marketplace"
|
||||
)?.[1];
|
||||
|
||||
return repositoryId ? [repositoryId] : [];
|
||||
})
|
||||
.sort();
|
||||
|
||||
// A skipped update leaves the repository installed. A restart it waits for
|
||||
// is still needed, so that status stays.
|
||||
export const applySkippedUpdates = (
|
||||
repositories: RepositoryBase[],
|
||||
skipped: string[]
|
||||
): MarketplaceRepository[] =>
|
||||
skipped.length
|
||||
? repositories.map((repository): MarketplaceRepository => {
|
||||
if (!repository.pending_upgrade || !skipped.includes(repository.id)) {
|
||||
return repository;
|
||||
}
|
||||
|
||||
return {
|
||||
...repository,
|
||||
update_skipped: true,
|
||||
pending_upgrade: false,
|
||||
status:
|
||||
repository.status === "pending-upgrade"
|
||||
? "installed"
|
||||
: repository.status,
|
||||
};
|
||||
})
|
||||
: repositories;
|
||||
|
||||
export const statusesOfTab = (
|
||||
tab: MarketplaceTab
|
||||
): readonly (typeof STATUS_ORDER)[number][] =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import {
|
||||
mdiCheckCircleOutline,
|
||||
mdiCompassOutline,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
mdiStore,
|
||||
mdiViewGridOutline,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoize from "memoize-one";
|
||||
@@ -21,12 +22,14 @@ import type {
|
||||
HASSDomEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { consumeEntityStates } from "../../../common/decorators/consume-context-entry";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { formatNumber } from "../../../common/number/format_number";
|
||||
import { marketplaceFilterQueryParamConfig } from "../../../common/url/marketplace-query-params";
|
||||
import { shallowEqual } from "../../../common/util/shallow-equal";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
SelectionChangedEvent,
|
||||
@@ -68,6 +71,8 @@ import type { MarketplaceData } from "../../../data/marketplace/marketplace";
|
||||
import {
|
||||
apiContext,
|
||||
configContext,
|
||||
devicesContext,
|
||||
entitiesContext,
|
||||
internationalizationContext,
|
||||
uiContext,
|
||||
} from "../../../data/context";
|
||||
@@ -82,9 +87,12 @@ import {
|
||||
} from "../../../data/marketplace/repository";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import {
|
||||
applySkippedUpdates,
|
||||
browseLinkFromUrl,
|
||||
filterRepositories,
|
||||
filtersOfTab,
|
||||
marketplaceUpdateEntityIds,
|
||||
skippedUpdateRepositories,
|
||||
SOURCE_FILTER,
|
||||
SOURCE_ORDER,
|
||||
STATUS_FILTER,
|
||||
@@ -93,7 +101,10 @@ import {
|
||||
statusFilterOfTab,
|
||||
TYPE_FILTER,
|
||||
} from "./dashboard-repositories";
|
||||
import type { RepositoryFilters } from "./dashboard-repositories";
|
||||
import type {
|
||||
MarketplaceRepository,
|
||||
RepositoryFilters,
|
||||
} from "./dashboard-repositories";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { renderRepositoryIcon } from "../tools/repository-icon";
|
||||
import {
|
||||
@@ -176,6 +187,20 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@state()
|
||||
@consumeEntityStates({ entityIdPath: ["_marketplaceUpdateEntityIds"] })
|
||||
private _states?: HassEntities;
|
||||
|
||||
@state()
|
||||
@consume({ context: entitiesContext, subscribe: true })
|
||||
private _entities!: ContextType<typeof entitiesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: devicesContext, subscribe: true })
|
||||
private _devices!: ContextType<typeof devicesContext>;
|
||||
|
||||
@state() private _marketplaceUpdateEntityIds: string[] = [];
|
||||
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
key: "marketplace-dashboard-table-filters",
|
||||
@@ -254,10 +279,18 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
);
|
||||
|
||||
// Stored settings ignore what is set before the first update
|
||||
protected willUpdate() {
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
if (!this.hasUpdated) {
|
||||
this._applyLink();
|
||||
}
|
||||
|
||||
// The states of these are only selected after this runs, so the skipped
|
||||
// updates are worked out in render
|
||||
if (changedProps.has("_entities")) {
|
||||
this._marketplaceUpdateEntityIds = marketplaceUpdateEntityIds(
|
||||
this._entities
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Links, like See all on Discover, say in the URL what to show. The router
|
||||
@@ -286,11 +319,19 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const marketplaceRepositories = this._applySkippedUpdates(
|
||||
this.marketplace.repositories,
|
||||
this._skippedUpdateRepositories(
|
||||
this._marketplaceUpdateEntityIds,
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._states ?? {}
|
||||
)
|
||||
);
|
||||
const tabs = this._tabs(
|
||||
this._i18n.localize,
|
||||
this.marketplace.repositories.filter(
|
||||
(repository) => repository.pending_upgrade
|
||||
).length
|
||||
marketplaceRepositories.filter((repository) => repository.pending_upgrade)
|
||||
.length
|
||||
);
|
||||
|
||||
if (this.tab === "discover") {
|
||||
@@ -310,7 +351,7 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
|
||||
const filters = this._filtersOfTab(this._filters, this.tab);
|
||||
const repositories = this._filterRepositories(
|
||||
this._repositoriesOfTab(this.marketplace.repositories, this.tab),
|
||||
this._repositoriesOfTab(marketplaceRepositories, this.tab),
|
||||
this._i18n.localize,
|
||||
filters
|
||||
);
|
||||
@@ -503,6 +544,16 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
|
||||
private _repositoriesOfTab = memoize(repositoriesOfTab);
|
||||
|
||||
private _skippedUpdateRepositories = memoize(skippedUpdateRepositories);
|
||||
|
||||
// Registry changes give a new list of the same skipped updates, the table is
|
||||
// left as it is
|
||||
private _applySkippedUpdates = memoize(
|
||||
applySkippedUpdates,
|
||||
([repositories, skipped], [lastRepositories, lastSkipped]) =>
|
||||
repositories === lastRepositories && shallowEqual(skipped, lastSkipped)
|
||||
);
|
||||
|
||||
private _filterRepositories = memoize(filterRepositories);
|
||||
|
||||
private _filtersOfTab = memoize(filtersOfTab);
|
||||
@@ -513,7 +564,7 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
narrow: boolean,
|
||||
darkMode: boolean | undefined,
|
||||
markInstalled: boolean
|
||||
): DataTableColumnContainer<RepositoryBase> => ({
|
||||
): DataTableColumnContainer<MarketplaceRepository> => ({
|
||||
icon: {
|
||||
title: "",
|
||||
label: localizeFunc("ui.panel.marketplace.column.icon"),
|
||||
@@ -521,7 +572,7 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
hidden: false,
|
||||
moveable: false,
|
||||
showNarrow: true,
|
||||
template: (repository: RepositoryBase) =>
|
||||
template: (repository: MarketplaceRepository) =>
|
||||
renderRepositoryIcon(repository, {
|
||||
darkMode,
|
||||
hassUrl: this._config.auth.data.hassUrl,
|
||||
@@ -531,6 +582,9 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
updateLabel: localizeFunc(
|
||||
"ui.panel.marketplace.repository_status.pending-upgrade"
|
||||
),
|
||||
skippedLabel: localizeFunc(
|
||||
"ui.panel.marketplace.repository_status.update_skipped"
|
||||
),
|
||||
}),
|
||||
},
|
||||
name: {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { mdiArrowUpCircle, mdiCheckCircle } from "@mdi/js";
|
||||
import {
|
||||
mdiArrowUpCircle,
|
||||
mdiArrowUpCircleOutline,
|
||||
mdiCheckCircle,
|
||||
} from "@mdi/js";
|
||||
import { html, nothing } from "lit";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { RepositoryBase } from "../../../data/marketplace/repository";
|
||||
@@ -11,6 +15,7 @@ interface RepositoryIconOptions {
|
||||
// Leave it out where everything shown is installed, a mark would say nothing
|
||||
installedLabel?: string;
|
||||
updateLabel?: string;
|
||||
skippedLabel?: string;
|
||||
}
|
||||
|
||||
// A corner of the icon, ringed so it stands out on any brand image
|
||||
@@ -20,8 +25,14 @@ const BADGE_STYLE =
|
||||
// Like the icons on the devices page, the category icon without a domain.
|
||||
// Styled inline, as the tables render it where the styles of a page do not reach.
|
||||
export const renderRepositoryIcon = (
|
||||
repository: RepositoryBase,
|
||||
{ darkMode, hassUrl, installedLabel, updateLabel }: RepositoryIconOptions
|
||||
repository: RepositoryBase & { update_skipped?: boolean },
|
||||
{
|
||||
darkMode,
|
||||
hassUrl,
|
||||
installedLabel,
|
||||
updateLabel,
|
||||
skippedLabel,
|
||||
}: RepositoryIconOptions
|
||||
) =>
|
||||
html`<div style="position: relative; display: flex">
|
||||
${
|
||||
@@ -44,23 +55,32 @@ export const renderRepositoryIcon = (
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
${
|
||||
// An update says more than that it is installed, so it takes its place
|
||||
updateLabel && repository.pending_upgrade
|
||||
// A skipped update is still waiting, but muted as it was set aside
|
||||
skippedLabel && repository.update_skipped
|
||||
? html`<ha-svg-icon
|
||||
class="update-badge"
|
||||
class="skipped-badge"
|
||||
role="img"
|
||||
aria-label=${updateLabel}
|
||||
.path=${mdiArrowUpCircle}
|
||||
style="${BADGE_STYLE}; color: var(--warning-color)"
|
||||
aria-label=${skippedLabel}
|
||||
.path=${mdiArrowUpCircleOutline}
|
||||
style="${BADGE_STYLE}; color: var(--secondary-text-color)"
|
||||
></ha-svg-icon>`
|
||||
: installedLabel && repository.installed
|
||||
: // An update says more than that it is installed, so it takes its place
|
||||
updateLabel && repository.pending_upgrade
|
||||
? html`<ha-svg-icon
|
||||
class="installed-badge"
|
||||
class="update-badge"
|
||||
role="img"
|
||||
aria-label=${installedLabel}
|
||||
.path=${mdiCheckCircle}
|
||||
style="${BADGE_STYLE}; color: var(--success-color)"
|
||||
aria-label=${updateLabel}
|
||||
.path=${mdiArrowUpCircle}
|
||||
style="${BADGE_STYLE}; color: var(--warning-color)"
|
||||
></ha-svg-icon>`
|
||||
: nothing
|
||||
: installedLabel && repository.installed
|
||||
? html`<ha-svg-icon
|
||||
class="installed-badge"
|
||||
role="img"
|
||||
aria-label=${installedLabel}
|
||||
.path=${mdiCheckCircle}
|
||||
style="${BADGE_STYLE}; color: var(--success-color)"
|
||||
></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</div>`;
|
||||
@@ -11289,6 +11289,10 @@
|
||||
"state_filter": "[%key:ui::panel::lovelace::editor::card::picture-elements::state_filter%]"
|
||||
},
|
||||
"badge": {
|
||||
"alert": {
|
||||
"name": "Alert",
|
||||
"description": "A badge that highlights an entity that needs attention, with an optional color and pulsing background."
|
||||
},
|
||||
"entity": {
|
||||
"name": "Entity",
|
||||
"description": "The Entity badge gives you a quick overview of an entity.",
|
||||
@@ -12775,7 +12779,8 @@
|
||||
"pending-upgrade": "Update available",
|
||||
"installed": "Installed",
|
||||
"new": "New",
|
||||
"default": "Available"
|
||||
"default": "Available",
|
||||
"update_skipped": "Update skipped"
|
||||
},
|
||||
"menu": {
|
||||
"custom_repositories": "Custom repositories",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { setPlatformAPI } from "zrender/lib/core/platform.js";
|
||||
import { parsePlainText } from "zrender/lib/graphic/helper/parseText.js";
|
||||
import { fitLabelFontSize } from "../../../src/components/chart/fit-label-font-size";
|
||||
|
||||
const FONT_SIZE = 12;
|
||||
// jsdom has no canvas, so measure every character as 0.6em wide
|
||||
const EM = 0.6;
|
||||
const LINE_HEIGHT = EM * FONT_SIZE;
|
||||
|
||||
const fit = (text: string, width: number, height: number) =>
|
||||
fitLabelFontSize(text, {
|
||||
fontSize: FONT_SIZE,
|
||||
width,
|
||||
height,
|
||||
unwrappedHeight: text.split("\n").length * LINE_HEIGHT,
|
||||
});
|
||||
|
||||
// wrap like ECharts does at the returned font size
|
||||
const wrap = (text: string, fontSize: number, width: number) =>
|
||||
text.split("\n").map(
|
||||
(paragraph) =>
|
||||
parsePlainText(
|
||||
paragraph,
|
||||
{
|
||||
font: `${fontSize}px Roboto, Noto, sans-serif`,
|
||||
width,
|
||||
overflow: "break",
|
||||
},
|
||||
undefined,
|
||||
undefined
|
||||
).lines
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
setPlatformAPI({
|
||||
measureText: (text, font) => ({
|
||||
width: text.length * EM * parseFloat(/([\d.]+)px/.exec(font!)![1]),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitLabelFontSize", () => {
|
||||
it("keeps the font size when the label fits", () => {
|
||||
expect(fit("Kitchen", 100, 20)).toBe(FONT_SIZE);
|
||||
});
|
||||
|
||||
it("ignores indentation after an explicit newline", () => {
|
||||
expect(fit("Kitchen\n light", 100, 30)).toBe(FONT_SIZE);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["narrow wrapped label", "Untracked consumption", 70, 22],
|
||||
["long word", "Geschirrspülmaschine", 70, 40],
|
||||
["word that fills the width", "Electric vehicle charger", 40, 20],
|
||||
["label and value", "Electric vehicle charger\n140 kWh", 70, 20],
|
||||
["small height", "Coffee machine", 70, 9],
|
||||
])("fits a %s without breaking words", (_name, text, width, height) => {
|
||||
const fontSize = fit(text, width, height);
|
||||
expect(fontSize).toBeGreaterThan(0);
|
||||
expect(fontSize).toBeLessThanOrEqual(FONT_SIZE);
|
||||
|
||||
const paragraphs = wrap(text, fontSize, width);
|
||||
const lines = paragraphs.flat();
|
||||
expect(lines.length * EM * fontSize).toBeLessThanOrEqual(height);
|
||||
for (const word of text.split(/\s+/)) {
|
||||
expect(lines.some((line) => line.includes(word))).toBe(true);
|
||||
}
|
||||
for (const paragraph of paragraphs) {
|
||||
expect(paragraph.slice(1).some((line) => line.startsWith(" "))).toBe(
|
||||
false
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
HaMap,
|
||||
HaMapEditableLocation,
|
||||
} from "../../../src/components/map/ha-map";
|
||||
import { createMockEntityState } from "../../fixtures/hass";
|
||||
|
||||
const FORMATTERS = {
|
||||
formatEntityName: computeEntityNameDisplayWithoutContext,
|
||||
@@ -187,6 +188,21 @@ const STATES = {
|
||||
},
|
||||
} as unknown as HassEntities;
|
||||
|
||||
const ZONES = {
|
||||
"zone.paulus": createMockEntityState("zone.paulus", "0", {
|
||||
friendly_name: "Work",
|
||||
latitude: 52.372,
|
||||
longitude: 4.89,
|
||||
radius: 100,
|
||||
}),
|
||||
"zone.anne_therese": createMockEntityState("zone.anne_therese", "0", {
|
||||
friendly_name: "Work",
|
||||
latitude: 52.377,
|
||||
longitude: 4.895,
|
||||
radius: 100,
|
||||
}),
|
||||
};
|
||||
|
||||
const leafletMap = (el: HaMap) => (el as any)._engine?.leafletMap;
|
||||
const container = (el: HaMap) => el.shadowRoot!.getElementById("map")!;
|
||||
const isRevealed = (el: HaMap) => container(el).classList.contains("drawn");
|
||||
@@ -194,11 +210,14 @@ const isLoaded = (el: HaMap) => (el as any)._loaded as boolean;
|
||||
const entityHandles = (el: HaMap) =>
|
||||
(el as any)._entityHandles as MapMarkerHandle[];
|
||||
|
||||
const createMap = async (): Promise<HaMap> => {
|
||||
const createMap = async (
|
||||
states: HassEntities = STATES,
|
||||
entities: string[] = Object.keys(states)
|
||||
): Promise<HaMap> => {
|
||||
const el = document.createElement("ha-map");
|
||||
el.entities = ["device_tracker.paulus", "device_tracker.anne_therese"];
|
||||
el.entities = entities;
|
||||
el.clusterMarkers = false;
|
||||
(el as any)._states = STATES;
|
||||
(el as any)._states = states;
|
||||
(el as any)._formatters = FORMATTERS;
|
||||
(el as any)._config = {
|
||||
config: { latitude: 52.3731339, longitude: 4.8903147 },
|
||||
@@ -236,6 +255,110 @@ describe("ha-map engine selection", () => {
|
||||
expect(entityHandles(el)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("groups same-named zones by reported IDs", async () => {
|
||||
const states: HassEntities = {
|
||||
...ZONES,
|
||||
"person.paulus": createMockEntityState("person.paulus", "Work", {
|
||||
in_zones: ["zone.paulus"],
|
||||
}),
|
||||
"device_tracker.anne_therese": createMockEntityState(
|
||||
"device_tracker.anne_therese",
|
||||
"Work",
|
||||
{
|
||||
latitude: ZONES["zone.paulus"].attributes.latitude,
|
||||
longitude: ZONES["zone.paulus"].attributes.longitude,
|
||||
in_zones: ["zone.anne_therese"],
|
||||
}
|
||||
),
|
||||
};
|
||||
const el = await createMap(states);
|
||||
|
||||
expect(entityHandles(el).map((marker) => marker.clusterData)).toMatchObject(
|
||||
[
|
||||
{ entityId: "person.paulus", zoneId: "zone.paulus" },
|
||||
{
|
||||
entityId: "device_tracker.anne_therese",
|
||||
zoneId: "zone.anne_therese",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
entityId: "device_tracker.paulus",
|
||||
state: "Work",
|
||||
inZones: undefined,
|
||||
expectedZoneId: "zone.paulus",
|
||||
},
|
||||
{
|
||||
entityId: "person.paulus",
|
||||
state: "Work",
|
||||
inZones: [],
|
||||
expectedZoneId: "zone.paulus",
|
||||
},
|
||||
{
|
||||
entityId: "person.paulus",
|
||||
state: "not_home",
|
||||
inZones: [],
|
||||
expectedZoneId: undefined,
|
||||
},
|
||||
])(
|
||||
"handles zone membership for $entityId ($state, in_zones: $inZones)",
|
||||
async ({ entityId, state, inZones, expectedZoneId }) => {
|
||||
const el = await createMap({
|
||||
...ZONES,
|
||||
[entityId]: createMockEntityState(entityId, state, {
|
||||
latitude: ZONES["zone.paulus"].attributes.latitude,
|
||||
longitude: ZONES["zone.paulus"].attributes.longitude,
|
||||
in_zones: inZones,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(entityHandles(el)).toMatchObject([
|
||||
{ clusterData: { entityId, zoneId: expectedZoneId } },
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
it("updates grouping when a hidden active zone becomes passive and is restored", async () => {
|
||||
const hiddenZone = ZONES["zone.paulus"];
|
||||
const states: HassEntities = {
|
||||
...ZONES,
|
||||
"zone.enclosing": createMockEntityState("zone.enclosing", "0", {
|
||||
...hiddenZone.attributes,
|
||||
friendly_name: "City",
|
||||
radius: 1000,
|
||||
}),
|
||||
"person.paulus": createMockEntityState("person.paulus", "Work", {
|
||||
in_zones: [hiddenZone.entity_id, "zone.enclosing"],
|
||||
}),
|
||||
};
|
||||
const el = await createMap(states, ["zone.enclosing", "person.paulus"]);
|
||||
expect(entityHandles(el)[0].clusterData).toMatchObject({
|
||||
zoneId: undefined,
|
||||
});
|
||||
|
||||
(el as any)._states = {
|
||||
...states,
|
||||
[hiddenZone.entity_id]: createMockEntityState(
|
||||
hiddenZone.entity_id,
|
||||
hiddenZone.state,
|
||||
{ ...hiddenZone.attributes, passive: true }
|
||||
),
|
||||
};
|
||||
await el.updateComplete;
|
||||
expect(entityHandles(el)[0].clusterData).toMatchObject({
|
||||
zoneId: "zone.enclosing",
|
||||
});
|
||||
|
||||
(el as any)._states = states;
|
||||
await el.updateComplete;
|
||||
expect(entityHandles(el)[0].clusterData).toMatchObject({
|
||||
zoneId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets up while themes are not loaded yet, as during onboarding", async () => {
|
||||
const el = document.createElement("ha-map");
|
||||
el.themeMode = "auto";
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAssistMessageProcessor,
|
||||
type AssistMessage,
|
||||
} from "../../src/data/assist_chat";
|
||||
import type {
|
||||
ConversationChatLogAssistantDelta,
|
||||
PipelineRunEvent,
|
||||
} from "../../src/data/assist_pipeline";
|
||||
|
||||
const assistantDelta = (
|
||||
delta: Partial<ConversationChatLogAssistantDelta>
|
||||
): PipelineRunEvent => ({
|
||||
type: "intent-progress",
|
||||
timestamp: "2026-09-21T00:00:00.000Z",
|
||||
data: { chat_log_delta: delta },
|
||||
});
|
||||
|
||||
const toolCall = {
|
||||
id: "call-1",
|
||||
tool_name: "get_weather",
|
||||
tool_args: {},
|
||||
external: false,
|
||||
};
|
||||
|
||||
const toolResultDelta: PipelineRunEvent = {
|
||||
type: "intent-progress",
|
||||
timestamp: "2026-09-21T00:00:01.000Z",
|
||||
data: {
|
||||
chat_log_delta: {
|
||||
role: "tool_result",
|
||||
agent_id: "agent",
|
||||
tool_call_id: "call-1",
|
||||
tool_name: "get_weather",
|
||||
result: { data: { temperature: "21 °C" }, error: false },
|
||||
created: "2026-09-21T00:00:01.000Z",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const intentEnd = (speech?: string): PipelineRunEvent => ({
|
||||
type: "intent-end",
|
||||
timestamp: "2026-09-21T00:00:02.000Z",
|
||||
data: {
|
||||
processed_locally: false,
|
||||
intent_output: {
|
||||
conversation_id: "conversation-id",
|
||||
response: {
|
||||
language: "en",
|
||||
response_type: "query_answer",
|
||||
speech:
|
||||
speech === undefined
|
||||
? {}
|
||||
: {
|
||||
plain: { extra_data: {}, speech },
|
||||
ssml: { extra_data: {}, speech },
|
||||
},
|
||||
data: { success: [], failed: [] },
|
||||
},
|
||||
continue_conversation: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createProcessor = (added: AssistMessage[] = []) =>
|
||||
createAssistMessageProcessor({
|
||||
addMessage: (message) => added.push(message),
|
||||
requestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
describe("createAssistMessageProcessor", () => {
|
||||
it("keeps the beginning of a reply that contains tool calls", () => {
|
||||
const processor = createProcessor();
|
||||
processor.processEvent(
|
||||
assistantDelta({
|
||||
role: "assistant",
|
||||
content: "Let me check the weather for you.",
|
||||
})
|
||||
);
|
||||
processor.processEvent(assistantDelta({ tool_calls: [toolCall] }));
|
||||
processor.processEvent(toolResultDelta);
|
||||
processor.processEvent(
|
||||
assistantDelta({
|
||||
role: "assistant",
|
||||
content: "It is 21 degrees in Utrecht.",
|
||||
})
|
||||
);
|
||||
processor.processEvent(intentEnd("It is 21 degrees in Utrecht."));
|
||||
|
||||
expect(processor.hassMessage.text).toBe(
|
||||
"Let me check the weather for you.\n\nIt is 21 degrees in Utrecht."
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate a reply that was fully streamed", () => {
|
||||
const processor = createProcessor();
|
||||
processor.processEvent(
|
||||
assistantDelta({ role: "assistant", content: "The lights " })
|
||||
);
|
||||
processor.processEvent(assistantDelta({ content: "are on." }));
|
||||
processor.processEvent(intentEnd("The lights are on."));
|
||||
|
||||
expect(processor.hassMessage.text).toBe("The lights are on.");
|
||||
});
|
||||
|
||||
it("uses the response when nothing was streamed", () => {
|
||||
const processor = createProcessor();
|
||||
processor.processEvent(intentEnd("The lights are on."));
|
||||
|
||||
expect(processor.hassMessage.text).toBe("The lights are on.");
|
||||
});
|
||||
|
||||
it("keeps a message boundary when role and content arrive separately", () => {
|
||||
const processor = createProcessor();
|
||||
processor.processEvent(
|
||||
assistantDelta({ role: "assistant", content: "Let me check." })
|
||||
);
|
||||
processor.processEvent(assistantDelta({ role: "assistant" }));
|
||||
processor.processEvent(assistantDelta({ content: "Done." }));
|
||||
processor.processEvent(intentEnd("Done."));
|
||||
|
||||
expect(processor.hassMessage.text).toBe("Let me check.\n\nDone.");
|
||||
});
|
||||
|
||||
it("removes the streaming ellipsis when there is no spoken response", () => {
|
||||
const processor = createProcessor();
|
||||
processor.processEvent(
|
||||
assistantDelta({ role: "assistant", content: "Done." })
|
||||
);
|
||||
processor.processEvent(intentEnd());
|
||||
|
||||
expect(processor.hassMessage.text).toBe("Done.");
|
||||
});
|
||||
|
||||
it("keeps streamed content and adds the error as its own message", () => {
|
||||
const added: AssistMessage[] = [];
|
||||
const processor = createProcessor(added);
|
||||
processor.addMessage();
|
||||
processor.processEvent(
|
||||
assistantDelta({ role: "assistant", content: "Checking the sensor." })
|
||||
);
|
||||
|
||||
processor.setError("Timeout running pipeline");
|
||||
|
||||
expect(added).toHaveLength(2);
|
||||
expect(added[0].text).toBe("Checking the sensor.");
|
||||
expect(added[1].text).toBe("Timeout running pipeline");
|
||||
expect(added[1].error).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { provideHass } from "../../../src/fake_data/provide_hass";
|
||||
import type { HomeFrontendSystemData } from "../../../src/data/frontend";
|
||||
import "../../../src/panels/home/ha-panel-home";
|
||||
|
||||
type HomePanel = HTMLElement & {
|
||||
hass: unknown;
|
||||
updateComplete: Promise<boolean>;
|
||||
_showBanner: boolean;
|
||||
_loadConfigPromise?: Promise<void>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
for (const tag of ["ha-button", "ha-svg-icon"]) {
|
||||
if (!customElements.get(tag)) {
|
||||
customElements.define(tag, class extends HTMLElement {});
|
||||
}
|
||||
}
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe = vi.fn();
|
||||
|
||||
unobserve = vi.fn();
|
||||
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
);
|
||||
vi.mock("../../../src/components/ha-button", () => ({}));
|
||||
vi.mock("../../../src/components/ha-svg-icon", () => ({}));
|
||||
vi.mock("../../../src/panels/lovelace/hui-root", () => ({}));
|
||||
vi.mock("../../../src/data/frontend", async (importOriginal) => {
|
||||
const actual = await importOriginal<object>();
|
||||
return {
|
||||
...actual,
|
||||
fetchFrontendSystemData: vi.fn(),
|
||||
saveFrontendSystemData: vi.fn(),
|
||||
subscribeFrontendSystemData: vi.fn(() => () => undefined),
|
||||
};
|
||||
});
|
||||
vi.mock("../../../src/panels/home/dialogs/show-dialog-new-overview", () => ({
|
||||
showNewOverviewDialog: vi.fn(),
|
||||
}));
|
||||
|
||||
const STORED: HomeFrontendSystemData = {
|
||||
welcome_banner_dismissed: false,
|
||||
favorite_entities: ["light.kitchen", "climate.living"],
|
||||
shortcuts: [
|
||||
{ type: "summary", key: "light" },
|
||||
{ type: "custom", path: "/energy", label: "Energy" },
|
||||
],
|
||||
};
|
||||
|
||||
const openPanel = async (fetchImpl: () => Promise<any>) => {
|
||||
const { fetchFrontendSystemData } =
|
||||
await import("../../../src/data/frontend");
|
||||
vi.mocked(fetchFrontendSystemData).mockImplementation(fetchImpl as any);
|
||||
const host = document.createElement("div");
|
||||
// No legacy overview panel: the banner decision must rest on loaded config.
|
||||
const hass = provideHass(host, { panels: {} });
|
||||
document.body.append(host);
|
||||
const panel = document.createElement("ha-panel-home") as unknown as HomePanel;
|
||||
host.append(panel);
|
||||
panel.hass = hass;
|
||||
await panel.updateComplete;
|
||||
await panel._loadConfigPromise;
|
||||
return panel;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("home panel welcome banner", () => {
|
||||
it("stays hidden when the home config load is rejected", async () => {
|
||||
const { saveFrontendSystemData } =
|
||||
await import("../../../src/data/frontend");
|
||||
const { showNewOverviewDialog } =
|
||||
await import("../../../src/panels/home/dialogs/show-dialog-new-overview");
|
||||
let dismissed: Promise<void> | undefined;
|
||||
vi.mocked(showNewOverviewDialog).mockImplementation(((
|
||||
_el: any,
|
||||
opts: any
|
||||
) => {
|
||||
dismissed = opts.dismiss();
|
||||
}) as any);
|
||||
const panel = await openPanel(async () => {
|
||||
throw new Error("no answer");
|
||||
});
|
||||
await panel.updateComplete;
|
||||
expect(panel._showBanner).toBe(false);
|
||||
// Defense in depth: even if invoked directly, dismiss must not persist
|
||||
// an unloaded (empty) config over stored favourites/shortcuts.
|
||||
(panel["_learnMore"] as () => void)();
|
||||
await dismissed;
|
||||
await panel.updateComplete;
|
||||
expect(vi.mocked(saveFrontendSystemData)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dismissing saves the loaded favourites, not an empty config", async () => {
|
||||
const { saveFrontendSystemData } =
|
||||
await import("../../../src/data/frontend");
|
||||
const { showNewOverviewDialog } =
|
||||
await import("../../../src/panels/home/dialogs/show-dialog-new-overview");
|
||||
let dismissed: Promise<void> | undefined;
|
||||
vi.mocked(showNewOverviewDialog).mockImplementation(((
|
||||
_el: any,
|
||||
opts: any
|
||||
) => {
|
||||
dismissed = opts.dismiss();
|
||||
}) as any);
|
||||
const panel = await openPanel(async () => ({ ...STORED }));
|
||||
await panel.updateComplete;
|
||||
expect(panel._showBanner).toBe(true);
|
||||
(panel["_learnMore"] as () => void)();
|
||||
await dismissed;
|
||||
await panel.updateComplete;
|
||||
expect(vi.mocked(saveFrontendSystemData)).toHaveBeenCalledTimes(1);
|
||||
const saved = vi.mocked(saveFrontendSystemData).mock.calls[0][2] as any;
|
||||
expect(saved.favorite_entities).toEqual(STORED.favorite_entities);
|
||||
expect(saved.shortcuts).toEqual(STORED.shortcuts);
|
||||
expect(saved.welcome_banner_dismissed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { DataTableRowData } from "../../../src/components/data-table/ha-data-table";
|
||||
import type { RepositoryBase } from "../../../src/data/marketplace/repository";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import {
|
||||
applySkippedUpdates,
|
||||
browseLinkFromUrl,
|
||||
browseUrl,
|
||||
filterRepositories,
|
||||
filtersOfTab,
|
||||
marketplaceUpdateEntityIds,
|
||||
skippedUpdateRepositories,
|
||||
statusesOfTab,
|
||||
statusFilterOfTab,
|
||||
} from "../../../src/panels/marketplace/dashboards/dashboard-repositories";
|
||||
import {
|
||||
mockDevice,
|
||||
mockEntity,
|
||||
mockStateObj,
|
||||
} from "../../common/entity/context/context-mock";
|
||||
|
||||
const localize = ((key: string) => key) as LocalizeFunc;
|
||||
|
||||
@@ -236,3 +246,104 @@ describe("filtersOfTab", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skippedUpdateRepositories", () => {
|
||||
const entities: HomeAssistant["entities"] = {
|
||||
"update.mushroom": mockEntity({
|
||||
entity_id: "update.mushroom",
|
||||
platform: "marketplace",
|
||||
device_id: "mushroom_device",
|
||||
}),
|
||||
"update.older": mockEntity({
|
||||
entity_id: "update.older",
|
||||
platform: "marketplace",
|
||||
device_id: "older_device",
|
||||
}),
|
||||
"update.other": mockEntity({
|
||||
entity_id: "update.other",
|
||||
platform: "esphome",
|
||||
device_id: "other_device",
|
||||
}),
|
||||
};
|
||||
|
||||
const devices: HomeAssistant["devices"] = {
|
||||
mushroom_device: mockDevice({
|
||||
id: "mushroom_device",
|
||||
identifiers: [["marketplace", "444350375"]],
|
||||
}),
|
||||
older_device: mockDevice({
|
||||
id: "older_device",
|
||||
identifiers: [["marketplace", "1"]],
|
||||
}),
|
||||
other_device: mockDevice({
|
||||
id: "other_device",
|
||||
identifiers: [["esphome", "2"]],
|
||||
}),
|
||||
};
|
||||
|
||||
const update = (
|
||||
entityId: string,
|
||||
latest: string,
|
||||
skipped: string | null
|
||||
): HassEntity =>
|
||||
mockStateObj({
|
||||
entity_id: entityId,
|
||||
state: "off",
|
||||
attributes: { latest_version: latest, skipped_version: skipped },
|
||||
});
|
||||
|
||||
it("finds the repositories of Marketplace update entities whose latest version is skipped", () => {
|
||||
const states: HassEntities = {
|
||||
"update.mushroom": update("update.mushroom", "v5.0.0", "v5.0.0"),
|
||||
// A newer release than the one skipped is an update again
|
||||
"update.older": update("update.older", "v2.0.0", "v1.0.0"),
|
||||
"update.other": update("update.other", "2026.10.0", "2026.10.0"),
|
||||
};
|
||||
|
||||
const updateEntityIds = marketplaceUpdateEntityIds(entities);
|
||||
|
||||
expect(updateEntityIds).toEqual(["update.mushroom", "update.older"]);
|
||||
expect(
|
||||
skippedUpdateRepositories(updateEntityIds, entities, devices, states)
|
||||
).toEqual(["444350375"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySkippedUpdates", () => {
|
||||
const repositories = [
|
||||
repository("Skipped", {
|
||||
status: "pending-upgrade",
|
||||
installed: true,
|
||||
pending_upgrade: true,
|
||||
}),
|
||||
repository("Restart", {
|
||||
status: "pending-restart",
|
||||
installed: true,
|
||||
pending_upgrade: true,
|
||||
}),
|
||||
repository("Outdated", {
|
||||
status: "pending-upgrade",
|
||||
installed: true,
|
||||
pending_upgrade: true,
|
||||
}),
|
||||
];
|
||||
|
||||
it("shows a skipped update as installed", () => {
|
||||
const [skipped, restart, outdated] = applySkippedUpdates(repositories, [
|
||||
"Skipped",
|
||||
"Restart",
|
||||
]);
|
||||
|
||||
expect(skipped).toMatchObject({
|
||||
status: "installed",
|
||||
pending_upgrade: false,
|
||||
update_skipped: true,
|
||||
});
|
||||
// It still needs the restart
|
||||
expect(restart).toMatchObject({
|
||||
status: "pending-restart",
|
||||
pending_upgrade: false,
|
||||
});
|
||||
expect(outdated).toBe(repositories[2]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user