mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-19 12:19:32 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
224a356490 |
No files matched your search
@@ -5,20 +5,14 @@ export const subscribePollingCollection = (
|
||||
updateData: (hass: HomeAssistant) => void,
|
||||
interval: number
|
||||
) => {
|
||||
let timeout: number | undefined;
|
||||
let cancelled = false;
|
||||
let timeout;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
await updateData(hass);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
timeout = window.setTimeout(() => fetchData(), interval);
|
||||
}
|
||||
timeout = setTimeout(() => fetchData(), interval);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
return () => clearTimeout(timeout);
|
||||
};
|
||||
@@ -15,16 +15,14 @@ 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,
|
||||
@@ -54,6 +52,22 @@ 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
|
||||
@@ -639,9 +653,6 @@ ${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();
|
||||
@@ -733,9 +744,6 @@ ${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();
|
||||
@@ -762,11 +770,118 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
}
|
||||
}
|
||||
|
||||
private _createAddHassMessageProcessor(): AssistMessageProcessor {
|
||||
return createAssistMessageProcessor({
|
||||
addMessage: (message) => this._addMessage(message),
|
||||
requestUpdate: () => this.requestUpdate("_conversation"),
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
+11
-15
@@ -1,11 +1,7 @@
|
||||
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 {
|
||||
HomeAssistantApi,
|
||||
HomeAssistantConfig,
|
||||
LogFileDisabledReason,
|
||||
} from "../types";
|
||||
import type { HomeAssistant, LogFileDisabledReason } from "../types";
|
||||
import type { HassioAddonInfo } from "./hassio/addon";
|
||||
import { supervisorUrl } from "./hassio/common";
|
||||
|
||||
@@ -15,12 +11,12 @@ export interface LogProvider {
|
||||
addon?: HassioAddonInfo;
|
||||
}
|
||||
|
||||
const hasSupervisorCoreLogDownload = (config: HomeAssistantConfig): boolean =>
|
||||
isComponentLoaded(config.config, "hassio") &&
|
||||
atLeastVersion(config.config.version, 2025, 10);
|
||||
const hasSupervisorCoreLogDownload = (hass: HomeAssistant): boolean =>
|
||||
isComponentLoaded(hass.config, "hassio") &&
|
||||
atLeastVersion(hass.config.version, 2025, 10);
|
||||
|
||||
export const fetchErrorLog = (api: HomeAssistantApi) =>
|
||||
api.callApi<string>("GET", "error_log");
|
||||
export const fetchErrorLog = (hass: HomeAssistant) =>
|
||||
hass.callApi<string>("GET", "error_log");
|
||||
|
||||
export const subscribeIntegrationLog = (
|
||||
connection: Connection,
|
||||
@@ -35,17 +31,17 @@ export const subscribeIntegrationLog = (
|
||||
{ resubscribe: false }
|
||||
);
|
||||
|
||||
export const getErrorLogDownloadUrl = (config: HomeAssistantConfig) =>
|
||||
hasSupervisorCoreLogDownload(config)
|
||||
export const getErrorLogDownloadUrl = (hass: HomeAssistant) =>
|
||||
hasSupervisorCoreLogDownload(hass)
|
||||
? supervisorUrl("core/logs/latest")
|
||||
: "/api/error_log";
|
||||
|
||||
export const getCoreLogFileDownloadUnavailableReason = (
|
||||
config: HomeAssistantConfig
|
||||
hass: HomeAssistant
|
||||
): LogFileDisabledReason | undefined => {
|
||||
if (hasSupervisorCoreLogDownload(config)) {
|
||||
if (hasSupervisorCoreLogDownload(hass)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return config.config.logging?.log_file_disabled_reason ?? undefined;
|
||||
return hass.config.logging?.log_file_disabled_reason ?? undefined;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant, HomeAssistantApi, PanelInfo } from "../../types";
|
||||
import type { HomeAssistant, 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 (api: HomeAssistantApi) =>
|
||||
api.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
|
||||
export const fetchHassioBoots = async (hass: HomeAssistant) =>
|
||||
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
|
||||
|
||||
export const fetchHassioLogsLegacy = async (
|
||||
api: HomeAssistantApi,
|
||||
hass: HomeAssistant,
|
||||
provider: string
|
||||
) =>
|
||||
api.callApi<string>(
|
||||
hass.callApi<string>(
|
||||
"GET",
|
||||
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs`
|
||||
);
|
||||
|
||||
export const fetchHassioLogs = async (
|
||||
api: HomeAssistantApi,
|
||||
hass: HomeAssistant,
|
||||
provider: string,
|
||||
range?: string,
|
||||
boot = 0
|
||||
) =>
|
||||
api.callApiRaw(
|
||||
hass.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 (
|
||||
api: HomeAssistantApi,
|
||||
hass: HomeAssistant,
|
||||
provider: string,
|
||||
signal: AbortSignal,
|
||||
lines = 100,
|
||||
boot = 0
|
||||
) =>
|
||||
api.callApiRaw(
|
||||
hass.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 (
|
||||
api: HomeAssistantApi,
|
||||
hass: HomeAssistant,
|
||||
provider: string,
|
||||
signal: AbortSignal,
|
||||
cursor: string,
|
||||
@@ -210,7 +210,7 @@ export const fetchHassioLogsFollowSkip = async (
|
||||
lines = 100,
|
||||
boot = 0
|
||||
) =>
|
||||
api.callApiRaw(
|
||||
hass.callApiRaw(
|
||||
"GET",
|
||||
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}/follow`,
|
||||
undefined,
|
||||
|
||||
@@ -241,10 +241,6 @@ class HaPanelApp extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._getAddonSlug() !== addonSlug || !this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!addon.version) {
|
||||
await this._showErrorAndNavigateHome(
|
||||
addon.name,
|
||||
@@ -328,7 +324,7 @@ class HaPanelApp extends LitElement {
|
||||
}
|
||||
|
||||
// Check if user navigated away while we were fetching
|
||||
if (this._getAddonSlug() !== addonSlug || !this.isConnected) {
|
||||
if (this._getAddonSlug() !== addonSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ 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,12 +316,6 @@ 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,7 +4,6 @@ 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";
|
||||
@@ -58,20 +57,8 @@ 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=${stateObj}
|
||||
.stateObj=${states[targetId]}
|
||||
.slot=${slot}
|
||||
></ha-state-icon>`;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ 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 {
|
||||
@@ -681,12 +680,6 @@ 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;
|
||||
@@ -696,25 +689,18 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
:host([size="s"]) {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
:host([size="s"]) .target {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
:host([size="s"]) .target ha-floor-icon {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* A default 24px icon would fill the whole small chip. */
|
||||
: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 state-badge {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
--mdc-icon-size: 16px;
|
||||
:host([size="s"]) .target ha-floor-icon {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
button.target {
|
||||
|
||||
@@ -185,17 +185,12 @@ class MqttSubscribeCard extends LitElement {
|
||||
this._subscribed();
|
||||
this._subscribed = undefined;
|
||||
} else {
|
||||
const unsubscribe = await subscribeMQTTTopic(
|
||||
this._subscribed = await subscribeMQTTTopic(
|
||||
this.hass!,
|
||||
this._topic,
|
||||
(message) => this._handleMessage(message),
|
||||
parseInt(this._qos)
|
||||
);
|
||||
if (!this.isConnected) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
this._subscribed = unsubscribe;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,13 +61,6 @@ class ZHADevicePage extends LitElement {
|
||||
|
||||
private _deviceRefreshInterval?: number;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this._device) {
|
||||
this._startDeviceRefreshInterval();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._clearDeviceRefreshInterval();
|
||||
@@ -198,9 +191,7 @@ class ZHADevicePage extends LitElement {
|
||||
if (!tabs.includes(this._currTab)) {
|
||||
this._currTab = tabs[0];
|
||||
}
|
||||
if (this.isConnected) {
|
||||
this._startDeviceRefreshInterval();
|
||||
}
|
||||
this._startDeviceRefreshInterval();
|
||||
} catch (_err: any) {
|
||||
if (this.ieee === ieee) {
|
||||
this._error = this.hass.localize(
|
||||
|
||||
@@ -23,7 +23,6 @@ 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";
|
||||
@@ -41,18 +40,11 @@ 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,
|
||||
@@ -67,7 +59,7 @@ import {
|
||||
fetchHassioLogsLegacy,
|
||||
getHassioLogDownloadUrl,
|
||||
} from "../../../data/hassio/supervisor";
|
||||
import type { HomeAssistantApi, HomeAssistantConfig } from "../../../types";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import { showDownloadLogsDialog } from "./show-dialog-download-logs";
|
||||
|
||||
@@ -75,6 +67,8 @@ 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 = "";
|
||||
@@ -86,20 +80,6 @@ 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;
|
||||
@@ -149,15 +129,15 @@ class ErrorLogCard extends LitElement {
|
||||
const streaming =
|
||||
this._streamSupported &&
|
||||
this.provider &&
|
||||
isComponentLoaded(this._config.config, "hassio") &&
|
||||
isComponentLoaded(this.hass.config, "hassio") &&
|
||||
this._loadingState !== "loading";
|
||||
|
||||
const hasBoots = this._streamSupported && Array.isArray(this._boots);
|
||||
|
||||
const localize = this.localizeFunc || this._localize;
|
||||
const localize = this.localizeFunc || this.hass.localize;
|
||||
const logFileDownloadUnavailableReason =
|
||||
!this.provider || this.provider === "core"
|
||||
? getCoreLogFileDownloadUnavailableReason(this._config)
|
||||
? getCoreLogFileDownloadUnavailableReason(this.hass)
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
@@ -270,7 +250,7 @@ class ErrorLogCard extends LitElement {
|
||||
slot="icon"
|
||||
.path=${mdiFolderTextOutline}
|
||||
></ha-svg-icon>
|
||||
${this._localize(
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.logs.show_condensed_logs"
|
||||
)}
|
||||
</ha-dropdown-item>`
|
||||
@@ -372,7 +352,7 @@ class ErrorLogCard extends LitElement {
|
||||
super.willUpdate(changedProps);
|
||||
if (!this.hasUpdated) {
|
||||
this._streamSupported = atLeastVersion(
|
||||
this._config.config.version,
|
||||
this.hass.config.version,
|
||||
2024,
|
||||
11
|
||||
);
|
||||
@@ -385,7 +365,7 @@ class ErrorLogCard extends LitElement {
|
||||
this._handleConnectionStatus
|
||||
);
|
||||
|
||||
this._i18n.loadFragmentTranslation("config");
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
}
|
||||
|
||||
if (changedProps.has("provider")) {
|
||||
@@ -441,7 +421,7 @@ class ErrorLogCard extends LitElement {
|
||||
private async _downloadLogs(): Promise<void> {
|
||||
if (
|
||||
(!this.provider || this.provider === "core") &&
|
||||
getCoreLogFileDownloadUnavailableReason(this._config)
|
||||
getCoreLogFileDownloadUnavailableReason(this.hass)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -458,13 +438,12 @@ class ErrorLogCard extends LitElement {
|
||||
const downloadUrl =
|
||||
this.provider && this.provider !== "core"
|
||||
? getHassioLogDownloadUrl(this.provider)
|
||||
: getErrorLogDownloadUrl(this._config);
|
||||
: getErrorLogDownloadUrl(this.hass);
|
||||
const logFileName =
|
||||
this.provider && this.provider !== "core"
|
||||
? `${this.provider}_${timeString}.log`
|
||||
: `home-assistant_${timeString}.log`;
|
||||
|
||||
const signedUrl = await getSignedPath(this._api, downloadUrl);
|
||||
const signedUrl = await getSignedPath(this.hass, downloadUrl);
|
||||
fileDownload(signedUrl.path, logFileName);
|
||||
}
|
||||
}
|
||||
@@ -482,7 +461,7 @@ class ErrorLogCard extends LitElement {
|
||||
|
||||
if (
|
||||
(!this.provider || this.provider === "core") &&
|
||||
getCoreLogFileDownloadUnavailableReason(this._config)
|
||||
getCoreLogFileDownloadUnavailableReason(this.hass)
|
||||
) {
|
||||
this._loadingState = "loaded";
|
||||
return;
|
||||
@@ -490,7 +469,7 @@ class ErrorLogCard extends LitElement {
|
||||
|
||||
const streamLogs =
|
||||
this._streamSupported &&
|
||||
isComponentLoaded(this._config.config, "hassio") &&
|
||||
isComponentLoaded(this.hass.config, "hassio") &&
|
||||
this.provider;
|
||||
|
||||
try {
|
||||
@@ -505,7 +484,7 @@ class ErrorLogCard extends LitElement {
|
||||
if (!retry) {
|
||||
// check if there are any logs at all
|
||||
const testResponse = await fetchHassioLogs(
|
||||
this._api,
|
||||
this.hass,
|
||||
this.provider!,
|
||||
`entries=:-1:`,
|
||||
this._boot
|
||||
@@ -520,7 +499,7 @@ class ErrorLogCard extends LitElement {
|
||||
|
||||
if (retry && this._firstCursor) {
|
||||
response = await fetchHassioLogsFollowSkip(
|
||||
this._api,
|
||||
this.hass,
|
||||
this.provider!,
|
||||
this._logStreamAborter.signal,
|
||||
this._firstCursor,
|
||||
@@ -530,7 +509,7 @@ class ErrorLogCard extends LitElement {
|
||||
);
|
||||
} else {
|
||||
response = await fetchHassioLogsFollow(
|
||||
this._api,
|
||||
this.hass,
|
||||
this.provider!,
|
||||
this._logStreamAborter.signal,
|
||||
NUMBER_OF_LINES,
|
||||
@@ -599,11 +578,10 @@ class ErrorLogCard extends LitElement {
|
||||
// fallback to old method
|
||||
this._streamSupported = false;
|
||||
let logs = "";
|
||||
|
||||
if (isComponentLoaded(this._config.config, "hassio") && this.provider) {
|
||||
logs = await fetchHassioLogsLegacy(this._api, this.provider);
|
||||
if (isComponentLoaded(this.hass.config, "hassio") && this.provider) {
|
||||
logs = await fetchHassioLogsLegacy(this.hass, this.provider);
|
||||
} else {
|
||||
logs = await fetchErrorLog(this._api);
|
||||
logs = await fetchErrorLog(this.hass);
|
||||
}
|
||||
|
||||
if (logs) {
|
||||
@@ -623,7 +601,7 @@ class ErrorLogCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._error = (this.localizeFunc || this._localize)(
|
||||
this._error = (this.localizeFunc || this.hass.localize)(
|
||||
"ui.panel.config.logs.failed_get_logs",
|
||||
{
|
||||
provider: this.provider,
|
||||
@@ -681,7 +659,7 @@ class ErrorLogCard extends LitElement {
|
||||
this._logElement.scrollHeight - this._logElement.scrollTop;
|
||||
this._loadingPrevState = "loading";
|
||||
const response = await fetchHassioLogs(
|
||||
this._api,
|
||||
this.hass,
|
||||
this.provider,
|
||||
`entries=${this._firstCursor}:-100:100`,
|
||||
this._boot
|
||||
@@ -737,10 +715,10 @@ class ErrorLogCard extends LitElement {
|
||||
private async _loadBoots() {
|
||||
if (
|
||||
this._streamSupported &&
|
||||
isComponentLoaded(this._config.config, "hassio")
|
||||
isComponentLoaded(this.hass.config, "hassio")
|
||||
) {
|
||||
try {
|
||||
const { data } = await fetchHassioBoots(this._api);
|
||||
const { data } = await fetchHassioBoots(this.hass);
|
||||
const boots = Object.keys(data.boots)
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
@@ -177,6 +177,7 @@ 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?: HomeFrontendSystemData;
|
||||
@state() private _config: FrontendSystemData["home"] = {};
|
||||
|
||||
@state() private _securityConfig: SecurityFrontendSystemData = {};
|
||||
|
||||
@@ -83,8 +83,8 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private get _showBanner(): boolean {
|
||||
// Don't show until the config loaded or if already dismissed
|
||||
if (!this._config || this._config.welcome_banner_dismissed) {
|
||||
// Don't show if already dismissed
|
||||
if (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 = undefined;
|
||||
this._config = {};
|
||||
} 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,9 +382,6 @@ class PanelHome extends SubscribeMixin(LitElement) {
|
||||
private _learnMore() {
|
||||
showNewOverviewDialog(this, {
|
||||
dismiss: async () => {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
const newConfig = {
|
||||
...this._config,
|
||||
welcome_banner_dismissed: true,
|
||||
@@ -400,11 +397,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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export class HuiRecoveryModeCard extends LitElement implements LovelaceCard {
|
||||
"ui.panel.lovelace.cards.recovery-mode.description"
|
||||
)}
|
||||
</div>
|
||||
<error-log-card provider="core"></error-log-card>
|
||||
<error-log-card .hass=${this.hass} provider="core"></error-log-card>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -465,20 +465,14 @@ class HUIRoot extends LitElement {
|
||||
!view.visible.some((e) => e.user === this.hass!.user?.id)) ||
|
||||
view.visible === false);
|
||||
|
||||
const tabs = html`<ha-tab-group
|
||||
.active=${typeof this._curView === "number" ? String(this._curView) : ""}
|
||||
@wa-tab-show=${this._handleViewSelected}
|
||||
>
|
||||
const tabs = html`<ha-tab-group @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 &&
|
||||
!current &&
|
||||
(view.subview || _isTabHiddenForUser(view));
|
||||
!this._editMode && (view.subview || _isTabHiddenForUser(view));
|
||||
const tabContent = html`
|
||||
${
|
||||
icon_only || icon_and_title
|
||||
@@ -503,7 +497,7 @@ class HUIRoot extends LitElement {
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
panel=${index}
|
||||
.active=${current}
|
||||
.active=${this._curView === index}
|
||||
.disabled=${hidden}
|
||||
aria-label=${ifDefined(view.title)}
|
||||
data-path=${view.path || index}
|
||||
@@ -1206,8 +1200,6 @@ class HUIRoot extends LitElement {
|
||||
if (viewIndex !== this._curView) {
|
||||
const path = this.config.views[viewIndex].path || viewIndex;
|
||||
this._navigateToView(path);
|
||||
} else if (!this._editMode) {
|
||||
scrollTo({ behavior: "smooth", top: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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 {
|
||||
@@ -15,7 +13,6 @@ 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 = [
|
||||
@@ -54,74 +51,6 @@ 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,6 +1,5 @@
|
||||
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,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
mdiStore,
|
||||
mdiViewGridOutline,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoize from "memoize-one";
|
||||
@@ -22,14 +21,12 @@ 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,
|
||||
@@ -71,8 +68,6 @@ import type { MarketplaceData } from "../../../data/marketplace/marketplace";
|
||||
import {
|
||||
apiContext,
|
||||
configContext,
|
||||
devicesContext,
|
||||
entitiesContext,
|
||||
internationalizationContext,
|
||||
uiContext,
|
||||
} from "../../../data/context";
|
||||
@@ -87,12 +82,9 @@ import {
|
||||
} from "../../../data/marketplace/repository";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import {
|
||||
applySkippedUpdates,
|
||||
browseLinkFromUrl,
|
||||
filterRepositories,
|
||||
filtersOfTab,
|
||||
marketplaceUpdateEntityIds,
|
||||
skippedUpdateRepositories,
|
||||
SOURCE_FILTER,
|
||||
SOURCE_ORDER,
|
||||
STATUS_FILTER,
|
||||
@@ -101,10 +93,7 @@ import {
|
||||
statusFilterOfTab,
|
||||
TYPE_FILTER,
|
||||
} from "./dashboard-repositories";
|
||||
import type {
|
||||
MarketplaceRepository,
|
||||
RepositoryFilters,
|
||||
} from "./dashboard-repositories";
|
||||
import type { RepositoryFilters } from "./dashboard-repositories";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { renderRepositoryIcon } from "../tools/repository-icon";
|
||||
import {
|
||||
@@ -187,20 +176,6 @@ 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",
|
||||
@@ -279,18 +254,10 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
);
|
||||
|
||||
// Stored settings ignore what is set before the first update
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
protected willUpdate() {
|
||||
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
|
||||
@@ -319,19 +286,11 @@ 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,
|
||||
marketplaceRepositories.filter((repository) => repository.pending_upgrade)
|
||||
.length
|
||||
this.marketplace.repositories.filter(
|
||||
(repository) => repository.pending_upgrade
|
||||
).length
|
||||
);
|
||||
|
||||
if (this.tab === "discover") {
|
||||
@@ -351,7 +310,7 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
|
||||
const filters = this._filtersOfTab(this._filters, this.tab);
|
||||
const repositories = this._filterRepositories(
|
||||
this._repositoriesOfTab(marketplaceRepositories, this.tab),
|
||||
this._repositoriesOfTab(this.marketplace.repositories, this.tab),
|
||||
this._i18n.localize,
|
||||
filters
|
||||
);
|
||||
@@ -544,16 +503,6 @@ 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);
|
||||
@@ -564,7 +513,7 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
narrow: boolean,
|
||||
darkMode: boolean | undefined,
|
||||
markInstalled: boolean
|
||||
): DataTableColumnContainer<MarketplaceRepository> => ({
|
||||
): DataTableColumnContainer<RepositoryBase> => ({
|
||||
icon: {
|
||||
title: "",
|
||||
label: localizeFunc("ui.panel.marketplace.column.icon"),
|
||||
@@ -572,7 +521,7 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
hidden: false,
|
||||
moveable: false,
|
||||
showNarrow: true,
|
||||
template: (repository: MarketplaceRepository) =>
|
||||
template: (repository: RepositoryBase) =>
|
||||
renderRepositoryIcon(repository, {
|
||||
darkMode,
|
||||
hassUrl: this._config.auth.data.hassUrl,
|
||||
@@ -582,9 +531,6 @@ 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,8 +1,4 @@
|
||||
import {
|
||||
mdiArrowUpCircle,
|
||||
mdiArrowUpCircleOutline,
|
||||
mdiCheckCircle,
|
||||
} from "@mdi/js";
|
||||
import { mdiArrowUpCircle, mdiCheckCircle } from "@mdi/js";
|
||||
import { html, nothing } from "lit";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { RepositoryBase } from "../../../data/marketplace/repository";
|
||||
@@ -15,7 +11,6 @@ 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
|
||||
@@ -25,14 +20,8 @@ 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 & { update_skipped?: boolean },
|
||||
{
|
||||
darkMode,
|
||||
hassUrl,
|
||||
installedLabel,
|
||||
updateLabel,
|
||||
skippedLabel,
|
||||
}: RepositoryIconOptions
|
||||
repository: RepositoryBase,
|
||||
{ darkMode, hassUrl, installedLabel, updateLabel }: RepositoryIconOptions
|
||||
) =>
|
||||
html`<div style="position: relative; display: flex">
|
||||
${
|
||||
@@ -55,32 +44,23 @@ export const renderRepositoryIcon = (
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
${
|
||||
// A skipped update is still waiting, but muted as it was set aside
|
||||
skippedLabel && repository.update_skipped
|
||||
// An update says more than that it is installed, so it takes its place
|
||||
updateLabel && repository.pending_upgrade
|
||||
? html`<ha-svg-icon
|
||||
class="skipped-badge"
|
||||
class="update-badge"
|
||||
role="img"
|
||||
aria-label=${skippedLabel}
|
||||
.path=${mdiArrowUpCircleOutline}
|
||||
style="${BADGE_STYLE}; color: var(--secondary-text-color)"
|
||||
aria-label=${updateLabel}
|
||||
.path=${mdiArrowUpCircle}
|
||||
style="${BADGE_STYLE}; color: var(--warning-color)"
|
||||
></ha-svg-icon>`
|
||||
: // An update says more than that it is installed, so it takes its place
|
||||
updateLabel && repository.pending_upgrade
|
||||
: installedLabel && repository.installed
|
||||
? html`<ha-svg-icon
|
||||
class="update-badge"
|
||||
class="installed-badge"
|
||||
role="img"
|
||||
aria-label=${updateLabel}
|
||||
.path=${mdiArrowUpCircle}
|
||||
style="${BADGE_STYLE}; color: var(--warning-color)"
|
||||
aria-label=${installedLabel}
|
||||
.path=${mdiCheckCircle}
|
||||
style="${BADGE_STYLE}; color: var(--success-color)"
|
||||
></ha-svg-icon>`
|
||||
: 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
|
||||
: nothing
|
||||
}
|
||||
</div>`;
|
||||
@@ -12779,8 +12779,7 @@
|
||||
"pending-upgrade": "Update available",
|
||||
"installed": "Installed",
|
||||
"new": "New",
|
||||
"default": "Available",
|
||||
"update_skipped": "Update skipped"
|
||||
"default": "Available"
|
||||
},
|
||||
"menu": {
|
||||
"custom_repositories": "Custom repositories",
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
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,25 +1,15 @@
|
||||
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;
|
||||
|
||||
@@ -246,104 +236,3 @@ 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