diff --git a/docs/configuration/elements/custom/README.md b/docs/configuration/elements/custom/README.md index 101a8d69..d6ceb7f9 100644 --- a/docs/configuration/elements/custom/README.md +++ b/docs/configuration/elements/custom/README.md @@ -156,6 +156,7 @@ elements: | `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. | | `severity` | | An optional severity level, one of `low`, `medium` or `high`. Colors the item accordingly. | | `sufficient` | `false` | Whether or not this item is sufficient to display the status bar if it's otherwise hidden (e.g. with the `popup` [status bar style](../../status-bar.md)). | +| `title` | | An optional tooltip shown on hover. | ## `status-bar-image` @@ -178,6 +179,7 @@ elements: | `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. | | `severity` | | An optional severity level, one of `low`, `medium` or `high`. Colors the item accordingly. | | `sufficient` | `false` | Whether or not this item is sufficient to display the status bar if it's otherwise hidden (e.g. with the `popup` [status bar style](../../status-bar.md)). | +| `title` | | An optional tooltip shown on hover. | ## `status-bar-string` @@ -200,6 +202,7 @@ elements: | `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. | | `severity` | | An optional severity level, one of `low`, `medium` or `high`. Colors the item accordingly. | | `sufficient` | `false` | Whether or not this item is sufficient to display the status bar if it's otherwise hidden (e.g. with the `popup` [status bar style](../../status-bar.md)). | +| `title` | | An optional tooltip shown on hover. | ## Fully expanded reference diff --git a/docs/configuration/status-bar.md b/docs/configuration/status-bar.md index fce09095..51d20d03 100644 --- a/docs/configuration/status-bar.md +++ b/docs/configuration/status-bar.md @@ -11,7 +11,7 @@ status_bar: | --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `position` | `bottom` | Whether to place the status bar at the `top` or `bottom` of the card. | | `popup_seconds` | `3` | The number of seconds to display the status bar when using the `popup` style. | -| `height` | `46` | The height of the status bar in pixels. | +| `height` | `40` | The height of the status bar in pixels. | | `items` | | Whether to show or hide built-in status bar items. See [`items`](#items). | | `style` | `popup` | The status bar style to show by default, one of `none`, `hover`, `hover-card`, `overlay`, `outside` or `popup`. See [`style`](#style). | @@ -28,14 +28,16 @@ status_bar: ### Available Items -| Button name | Description | -| ------------ | ------------------------------------------------------------------------------------------- | -| `engine` | The icon of the camera engine for the relevant camera. | -| `resolution` | The detected media resolution (if any). | -| `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). | -| `technology` | The detected media technology (if any). | -| `title` | The media title. | -| `upgrade` | An indicator that appears when a configuration upgrade is available. | +| Button name | Description | +| ---------------------------- | ------------------------------------------------------------------------------------------- | +| `engine` | The icon of the camera engine for the relevant camera. | +| `problem_config_upgrade` | An indicator that appears when a configuration upgrade is available. | +| `problem_legacy_resource` | An indicator that appears when a legacy `frigate-hass-card` resource is still registered. | +| `problem_stream_not_loading` | An indicator that appears when a live stream has not loaded within 10 seconds. | +| `resolution` | The detected media resolution (if any). | +| `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). | +| `technology` | The detected media technology (if any). | +| `title` | The media title. | ### Options for each item @@ -68,12 +70,21 @@ This card supports several menu styles. status_bar: position: bottom popup_seconds: 3 - height: 46 + height: 40 style: popup items: engine: enabled: true priority: 50 + problem_config_upgrade: + enabled: true + priority: 50 + problem_legacy_resource: + enabled: true + priority: 50 + problem_stream_not_loading: + enabled: true + priority: 50 resolution: enabled: true priority: 50 @@ -86,7 +97,4 @@ status_bar: title: enabled: true priority: 50 - upgrade: - enabled: true - priority: 50 ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7d1dde90..f75759f6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,23 +2,46 @@ ## Highlighted Issues -### Duplicate versions / Duplicate element registrations / `Custom element not found: advanced-camera-card` +### Legacy dashboard resource detected -If your card appears to not load anymore (but was working previously), you're -seeing the version of the card changing between reloads, or seeing log entries -like: +You still have the old `frigate-hass-card.js` resource registered in your +dashboard (and not `advanced-camera-card.js`). Having both registered causes +duplicate element registration errors and unpredictable behavior. Symptoms +include the card not loading, the version changing between reloads, or log +entries like: `Failed to execute 'define' on 'CustomElementRegistry': the name "focus-trap" has already been used with this registry window` -Verify that your dashboard resources contain only a single instance of the card -(for HACS users, you should see only `/hacsfiles/advanced-camera-card/`. If you -_also_ see `/hacsfiles/frigate-card/`, remove it, clear your caches and reload). +To fix: -Steps: +1. Edit your dashboard (click the three-dot menu in the top right) and select + **Manage Resources**. +1. Remove any entry referring to `frigate-hass-card`. You should only have a + single entry for `advanced-camera-card`. +1. Optionally, delete the `frigate-hass-card` directory on your filesystem if + present (e.g. `$HA_PATH/www/community/frigate-hass-card`), as long as an + `advanced-camera-card` directory exists there too. +1. Clear your browser cache and reload. -1. Edit your dashboard -> (Three dots menu) -> `Manage Resources`. Remove any line item that refers to `frigate-hass-card`. You should only have a single row entry for `advanced-camera-card`. -1. [Optionally] You can delete the frigate-hass-card directory on your filesystem if present, e.g. `$HA_PATH/www/community/frigate-hass-card`, as long as it has an `advanced-camera-card` directory there too. -1. Clear all your caches. +If you are an admin user and the card detects both resources are registered, an +alert icon will appear in the status bar -- clicking it will display a +notification with a button to automatically remove the legacy resource. + +### Configuration upgrade available + +If you see a notification that a configuration upgrade is available, it means +your card configuration uses an older format that can be automatically updated. + +To upgrade: + +1. Open your Home Assistant dashboard. +1. Click the pencil icon to enter edit mode. +1. Click the three-dot menu on your card and choose **Edit**. +1. In the card editor, click the **Automatic Upgrade** button at the top. +1. Review the changes and save. + +If the automatic upgrade button is not visible, your configuration may already +be up to date. Try clearing your browser cache and reloading. ### Stream does not load diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index 1a06f9c9..276b99c0 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -70,6 +70,7 @@ export class CardElementManager { this._api.getMediaLoadedInfoManager().initialize(); this._api.getMicrophoneManager().initialize(); this._api.getPIPManager().initialize(); + this._api.getProblemManager().initialize(); this._api.getKeyboardStateManager().initialize(); // These initializers are called when the config is updated, but on initial @@ -171,6 +172,7 @@ export class CardElementManager { this._api.getMediaLoadedInfoManager().clear(); this._api.getFullscreenManager().disconnect(); this._api.getPIPManager().uninitialize(); + this._api.getProblemManager().uninitialize(); this._api.getKeyboardStateManager().uninitialize(); this._api.getActionsManager().uninitialize(); this._api.getDefaultManager().uninitialize(); diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index 02cc1d94..edfaba73 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -28,7 +28,6 @@ export class ConfigManager { private _overriddenConfig: AdvancedCameraCardConfig | null = null; private _rawConfig: RawAdvancedCameraCardConfig | null = null; private _cardWideConfig: CardWideConfig | null = null; - private _upgradeable = false; private _overridesManager = new OverridesManager(() => this._processOverrideConfig()); constructor(api: CardConfigAPI) { @@ -55,22 +54,17 @@ export class ConfigManager { return this._rawConfig; } - public isUpgradeable(): boolean { - return this._upgradeable; - } - public setConfig(inputConfig?: RawAdvancedCameraCardConfig): void { if (!inputConfig) { throw new Error(localize('error.invalid_configuration')); } const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig); - this._upgradeable = isConfigUpgradeable(inputConfig); if (!parseResult.success) { const hint = getParseError(parseResult.error); let upgradeMessage = ''; - if (this._upgradeable) { + if (isConfigUpgradeable(inputConfig)) { upgradeMessage = `${localize('error.upgrade_available')}. `; } throw new Error( diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index cd7e1dda..36daa899 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -33,6 +33,7 @@ import { MessageManager } from './message-manager'; import { MicrophoneManager } from './microphone-manager'; import { NotificationManager } from './notification-manager'; import { PIPManager } from './pip-manager'; +import { ProblemManager } from './problems/manager'; import { QueryStringManager } from './query-string-manager'; import { StatusBarItemManager } from './status-bar-item-manager'; import { StyleManager } from './style-manager'; @@ -60,6 +61,7 @@ import { CardMicrophoneAPI, CardNotificationAPI, CardPIPAPI, + CardProblemAPI, CardQueryStringAPI, CardStyleAPI, CardTriggersAPI, @@ -83,6 +85,7 @@ export class CardController CardFullscreenAPI, CardHASSAPI, CardPIPAPI, + CardProblemAPI, CardInitializerAPI, CardInteractionAPI, CardKeyboardStateAPI, @@ -127,6 +130,7 @@ export class CardController private _microphoneManager = new MicrophoneManager(this); private _notificationManager = new NotificationManager(this); private _pipManager = new PIPManager(this); + private _problemManager = new ProblemManager(this); private _queryStringManager = new QueryStringManager(this); private _statusBarItemManager = new StatusBarItemManager(this); private _styleManager = new StyleManager(this); @@ -260,6 +264,10 @@ export class CardController return this._pipManager; } + public getProblemManager(): ProblemManager { + return this._problemManager; + } + public getQueryStringManager(): QueryStringManager { return this._queryStringManager; } diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index 92f9210e..54132e36 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -1,6 +1,6 @@ import PQueue from 'p-queue'; -import { loadLanguages } from '../localize/localize'; import { sideLoadHomeAssistantElements } from '../ha/side-load-ha-elements'; +import { loadLanguages } from '../localize/localize'; import { Initializer } from '../utils/initializer/initializer'; import { CardInitializerAPI } from './types'; @@ -9,6 +9,7 @@ export enum InitializationAspect { SIDE_LOAD_ELEMENTS = 'side-load-elements', CAMERAS = 'cameras', MICROPHONE_CONNECT = 'microphone-connect', + PROBLEMS = 'problems', VIEW = 'view', // The initial triggering must happen after both the config is set (and @@ -50,6 +51,10 @@ export class InitializationManager { return this._initializer.isInitialized(aspect); } + public isInitializedBackground(): boolean { + return this._initializer.isInitialized(InitializationAspect.PROBLEMS); + } + public isInitializedMandatory(): boolean { const config = this._api.getConfigManager().getConfig(); if (!config) { @@ -167,6 +172,25 @@ export class InitializationManager { this._api.getCardElementManager().update(); } + public async initializeBackground(): Promise { + await this._initializationQueue.add(() => this._initializeBackground()); + } + + private async _initializeBackground(): Promise { + const hass = this._api.getHASSManager().getHASS(); + if (!hass) { + return; + } + + await this._initializer.initializeIfNecessary( + InitializationAspect.PROBLEMS, + async () => { + await this._api.getProblemManager().detectStatic(hass); + return true; + }, + ); + } + public uninitialize(aspect: InitializationAspect): void { this._initializer.uninitialize(aspect); } diff --git a/src/card-controller/problems/manager.ts b/src/card-controller/problems/manager.ts new file mode 100644 index 00000000..e07c5625 --- /dev/null +++ b/src/card-controller/problems/manager.ts @@ -0,0 +1,130 @@ +import { ConditionStateChange } from '../../conditions/types'; +import { HomeAssistant } from '../../ha/types'; +import { CardProblemAPI } from '../types'; +import { ConfigUpgradeProblem } from './problems/config-upgrade'; +import { LegacyResourceProblem } from './problems/legacy-resource'; +import { StreamNotLoadingProblem } from './problems/stream-not-loading'; +import { + KeyedProblemResult, + Problem, + ProblemDynamicContext, + ProblemKey, + ProblemPresence, + ProblemTriggerContext, +} from './types'; + +export class ProblemManager { + private _api: CardProblemAPI; + private _problems = new Map(); + + constructor(api: CardProblemAPI) { + this._api = api; + + this._addProblem( + new ConfigUpgradeProblem(() => this._api.getConfigManager().getRawConfig()), + ); + this._addProblem( + new LegacyResourceProblem(() => this._api.getCardElementManager().update()), + ); + this._addProblem( + new StreamNotLoadingProblem(() => this._api.getCardElementManager().update()), + ); + } + + public initialize(): void { + this._api.getConditionStateManager().addListener(this._stateChangeHandler); + } + + public uninitialize(): void { + this._api.getConditionStateManager().removeListener(this._stateChangeHandler); + } + + private _addProblem(problem: Problem): void { + this._problems.set(problem.key, problem); + } + + public async detectStatic(hass: HomeAssistant): Promise { + for (const problem of this._problems.values()) { + await problem.detectStatic?.(hass); + } + this._api.getCardElementManager().update(); + } + + // Silently trigger a problem by key, updating state without user + // interaction. Use this for system-originated events (e.g. provider errors). + public trigger(key: ProblemKey, context?: ProblemTriggerContext): void { + const problem = this._problems.get(key); + if (!problem) { + return; + } + problem.trigger?.(context); + + // Re-evaluate dynamic state so the trigger could take effect immediately. + // trigger() only records context (e.g. marking a camera as errored); + // detectDynamic() decides whether to activate based on current state (e.g. + // whether it is the selected camera with the error). + const state = this._api.getConditionStateManager().getState(); + this._detectAllDynamic({ + cameraID: state.camera, + view: state.view, + mediaLoaded: !!state.mediaLoadedInfo, + }); + } + + // Show the notification popup for a problem, regardless of whether or not + // that problem has triggered (example usecase: the stream is loading and the + // user clicks the blue loading icon). + public forceNotify(key: ProblemKey): void { + const notification = this._problems.get(key)?.getNotification?.(); + if (notification) { + this._api.getNotificationManager().setNotification(notification); + } + } + + public getProblemResults(): KeyedProblemResult[] { + const results: KeyedProblemResult[] = []; + for (const problem of this._problems.values()) { + const result = problem.getResult(); + if (result) { + results.push({ key: problem.key, problem: result }); + } + } + return results; + } + + public getProblemPresence(): ProblemPresence { + const presence: ProblemPresence = {}; + for (const problem of this._problems.values()) { + presence[problem.key] = problem.hasResult(); + } + return presence; + } + + public destroy(): void { + this.uninitialize(); + for (const problem of this._problems.values()) { + problem.destroy?.(); + } + this._problems.clear(); + } + + private _stateChangeHandler = (change: ConditionStateChange): void => { + this._detectAllDynamic({ + cameraID: change.new.camera, + view: change.new.view, + mediaLoaded: !!change.new.mediaLoadedInfo, + }); + }; + + private _detectAllDynamic(context: ProblemDynamicContext): void { + let stateChanged = false; + for (const problem of this._problems.values()) { + const hadResult = problem.hasResult(); + problem.detectDynamic?.(context); + stateChanged ||= problem.hasResult() !== hadResult; + } + if (stateChanged) { + this._api.getCardElementManager().update(); + } + } +} diff --git a/src/card-controller/problems/problems/config-upgrade.ts b/src/card-controller/problems/problems/config-upgrade.ts new file mode 100644 index 00000000..70fe2b6f --- /dev/null +++ b/src/card-controller/problems/problems/config-upgrade.ts @@ -0,0 +1,47 @@ +import { isConfigUpgradeable } from '../../../config/management.js'; +import { RawAdvancedCameraCardConfig } from '../../../config/types.js'; +import { TROUBLESHOOTING_CONFIG_UPGRADE_URL } from '../../../const.js'; +import { localize } from '../../../localize/localize.js'; +import { Problem, ProblemResult } from '../types'; + +export class ConfigUpgradeProblem implements Problem { + public readonly key = 'config_upgrade' as const; + + private _upgradeable = false; + private _getRawConfig: () => RawAdvancedCameraCardConfig | null; + + constructor(getRawConfig: () => RawAdvancedCameraCardConfig | null) { + this._getRawConfig = getRawConfig; + } + + public async detectStatic(): Promise { + const rawConfig = this._getRawConfig(); + this._upgradeable = !!rawConfig && isConfigUpgradeable(rawConfig); + } + + public hasResult(): boolean { + return this._upgradeable; + } + + public getResult(): ProblemResult | null { + if (!this._upgradeable) { + return null; + } + return { + icon: 'mdi:update', + severity: 'medium', + notification: { + heading: { + text: localize('problems.config_upgrade.heading'), + icon: 'mdi:update', + severity: 'medium', + }, + text: localize('problems.config_upgrade.text'), + link: { + url: TROUBLESHOOTING_CONFIG_UPGRADE_URL, + title: localize('problems.troubleshooting_guide'), + }, + }, + }; + } +} diff --git a/src/card-controller/problems/problems/legacy-resource.ts b/src/card-controller/problems/problems/legacy-resource.ts new file mode 100644 index 00000000..a66112a1 --- /dev/null +++ b/src/card-controller/problems/problems/legacy-resource.ts @@ -0,0 +1,164 @@ +import { z } from 'zod'; +import { TROUBLESHOOTING_LEGACY_RESOURCE_URL } from '../../../const.js'; +import { HomeAssistant } from '../../../ha/types'; +import { localize } from '../../../localize/localize'; +import { createInternalCallbackAction } from '../../../utils/action'; +import { CardActionsAPI } from '../../types'; +import { Problem, ProblemResult } from '../types'; + +const LEGACY_RESOURCE_FILENAME = 'frigate-hass-card.js'; + +const ADVANCED_CAMERA_CARD_PATTERN = 'advanced-camera-card.js'; + +const getResourcePath = (url: string, baseURL: string): string => { + try { + return new URL(url, baseURL).pathname; + } catch { + // Fallback: strip query string manually. + const queryIndex = url.indexOf('?'); + return queryIndex >= 0 ? url.slice(0, queryIndex) : url; + } +}; + +const resourcesSchema = z.array( + z.object({ + id: z.string(), + type: z.string(), + url: z.string(), + }), +); + +export class LegacyResourceProblem implements Problem { + public readonly key = 'legacy_resource' as const; + + private _legacyResourceIDs: string[] = []; + private _hasCorrectResource = false; + private _checked = false; + private _triggerUpdate: () => void; + + constructor(triggerUpdate: () => void) { + this._triggerUpdate = triggerUpdate; + } + + public async detectStatic(hass: HomeAssistant): Promise { + // Only admin users can view/modify dashboard resources. + if (!hass.user?.is_admin) { + return; + } + + try { + const rawResources = await hass.callWS({ + type: 'lovelace/resources', + }); + + const parseResult = resourcesSchema.safeParse(rawResources); + if (!parseResult.success) { + return; + } + + this._legacyResourceIDs = []; + this._hasCorrectResource = false; + + for (const resource of parseResult.data) { + const path = getResourcePath(resource.url, hass.hassUrl()); + + if (path.endsWith(LEGACY_RESOURCE_FILENAME)) { + this._legacyResourceIDs.push(resource.id); + } + if (path.endsWith(ADVANCED_CAMERA_CARD_PATTERN)) { + this._hasCorrectResource = true; + } + } + + this._checked = true; + } catch { + // Silently ignore WS failures (e.g. non-admin, connection issues). + } + } + + public hasResult(): boolean { + return this._checked && this._legacyResourceIDs.length > 0; + } + + public getResult(): ProblemResult | null { + if (!this.hasResult()) { + return null; + } + + const text = this._hasCorrectResource + ? localize('problems.legacy_resource.text_both') + : localize('problems.legacy_resource.text_only_legacy'); + + return { + icon: 'mdi:alert', + severity: 'high', + notification: { + heading: { + text: localize('problems.legacy_resource.heading'), + icon: 'mdi:alert', + severity: 'high', + }, + text, + link: { + url: TROUBLESHOOTING_LEGACY_RESOURCE_URL, + title: localize('problems.troubleshooting_guide'), + }, + ...(this._hasCorrectResource + ? { + controls: [ + { + tooltip: localize('problems.legacy_resource.remove'), + icon: 'mdi:delete', + severity: 'high', + actions: { + tap_action: createInternalCallbackAction( + async (api: CardActionsAPI) => { + const hass = api.getHASSManager().getHASS(); + if (hass) { + await this.fix(hass); + } + }, + ), + }, + dismiss: true, + }, + ], + } + : {}), + }, + }; + } + + public async fix(hass: HomeAssistant): Promise { + if ( + !hass.user?.is_admin || + !this._hasCorrectResource || + !this._legacyResourceIDs.length + ) { + return false; + } + + try { + await Promise.all( + this._legacyResourceIDs.map((id) => + hass.callWS({ + type: 'lovelace/resources/delete', + resource_id: id, + }), + ), + ); + + // Re-detect to verify removal. + this._checked = false; + await this.detectStatic(hass); + + const fixed = !this.hasResult(); + if (fixed) { + this._triggerUpdate(); + } + return fixed; + } catch { + return false; + } + } +} diff --git a/src/card-controller/problems/problems/stream-not-loading.ts b/src/card-controller/problems/problems/stream-not-loading.ts new file mode 100644 index 00000000..caa84b5d --- /dev/null +++ b/src/card-controller/problems/problems/stream-not-loading.ts @@ -0,0 +1,122 @@ +import { Notification } from '../../../config/schema/actions/types.js'; +import { TROUBLESHOOTING_STREAM_URL } from '../../../const.js'; +import { localize } from '../../../localize/localize.js'; +import { Timer } from '../../../utils/timer.js'; +import { + Problem, + ProblemDynamicContext, + ProblemResult, + ProblemTriggerContext, +} from '../types.js'; + +const STREAM_LOADING_TIMEOUT_SECONDS = 10; + +export class StreamNotLoadingProblem implements Problem { + public readonly key = 'stream_not_loading' as const; + + private _problemActive = false; + private _cameraIDsWithErrors = new Set(); + private _timer = new Timer(); + private _timerCameraID: string | null = null; + private _triggerUpdate: () => void; + + constructor(triggerUpdate: () => void) { + this._triggerUpdate = triggerUpdate; + } + + public trigger(context?: ProblemTriggerContext): void { + if (context?.cameraID) { + this._cameraIDsWithErrors.add(context.cameraID); + } + } + + public detectDynamic(context: ProblemDynamicContext): void { + if (context.view !== 'live') { + this._deactivate(); + return; + } + + if (context.mediaLoaded) { + this._handleStreamLoaded(context.cameraID); + } else { + this._handleStreamNotLoaded(context.cameraID); + } + } + + // Stream loaded successfully. Deactivate and clear any prior provider error + // for this camera so it won't re-trigger on the next evaluation. + private _handleStreamLoaded(cameraID?: string): void { + this._deactivate(); + if (cameraID) { + this._cameraIDsWithErrors.delete(cameraID); + } + } + + // Stream not yet loaded. Activate immediately if this camera has a known + // provider error, otherwise start a timeout to detect slow loads. + private _handleStreamNotLoaded(cameraID?: string): void { + if (this._hasCameraError(cameraID)) { + this._activate(); + } else if (!this._problemActive) { + // Restart the timer when the selected camera changes so each camera + // gets its own timeout window. + if (!this._timer.isRunning() || this._timerCameraID !== (cameraID ?? null)) { + this._timerCameraID = cameraID ?? null; + this._timer.start(STREAM_LOADING_TIMEOUT_SECONDS, () => { + this._activate(); + this._triggerUpdate(); + }); + } + } + } + + public hasResult(): boolean { + return this._problemActive; + } + + public getNotification(): Notification { + return { + heading: { + text: localize('problems.stream_not_loading.heading'), + icon: 'mdi:cctv-off', + severity: 'high', + }, + text: localize('problems.stream_not_loading.text'), + link: { + url: TROUBLESHOOTING_STREAM_URL, + title: localize('problems.troubleshooting_guide'), + }, + }; + } + + public getResult(): ProblemResult | null { + if (!this._problemActive) { + return null; + } + return { + icon: 'mdi:cctv-off', + severity: 'high', + notification: this.getNotification(), + }; + } + + public destroy(): void { + this._deactivate(); + this._cameraIDsWithErrors.clear(); + } + + private _activate(): void { + this._timer.stop(); + this._problemActive = true; + } + + private _deactivate(): void { + this._timer.stop(); + this._timerCameraID = null; + this._problemActive = false; + } + + private _hasCameraError(camera?: string): boolean { + return !!camera && this._cameraIDsWithErrors.has(camera); + } +} diff --git a/src/card-controller/problems/types.ts b/src/card-controller/problems/types.ts new file mode 100644 index 00000000..5e8a001a --- /dev/null +++ b/src/card-controller/problems/types.ts @@ -0,0 +1,57 @@ +import { Notification } from '../../config/schema/actions/types'; +import { AdvancedCameraCardView } from '../../config/schema/common/const'; +import { HomeAssistant } from '../../ha/types'; +import { Severity } from '../../severity'; + +export type ProblemKey = 'config_upgrade' | 'legacy_resource' | 'stream_not_loading'; + +export interface ProblemResult { + icon: string; + severity: Severity; + notification: Notification; +} + +export interface KeyedProblemResult { + key: ProblemKey; + problem: ProblemResult; +} + +export type ProblemPresence = Partial>; + +export interface ProblemDynamicContext { + cameraID?: string; + view?: AdvancedCameraCardView; + mediaLoaded: boolean; +} + +export interface ProblemTriggerContext { + cameraID?: string; +} + +export type ProblemTriggerEventData = { key: ProblemKey } & ProblemTriggerContext; + +export interface Problem { + readonly key: ProblemKey; + + // One-time async detection (WS calls, config checks). + detectStatic?(hass?: HomeAssistant): Promise; + + // Ongoing sync evaluation, called on state changes. + detectDynamic?(context: ProblemDynamicContext): void; + + // Explicitly trigger this problem. + trigger?(context?: ProblemTriggerContext): void; + + hasResult(): boolean; + getResult(): ProblemResult | null; + + // Return notification content regardless of active state, for + // user-initiated queries (e.g. clicking a loading icon). + getNotification?(): Notification | null; + + // Optional automatic fixing. + fix?(hass: HomeAssistant): Promise; + + // Cleanup. + destroy?(): void; +} diff --git a/src/card-controller/status-bar-item-manager.ts b/src/card-controller/status-bar-item-manager.ts index ead32196..4e5ebf4e 100644 --- a/src/card-controller/status-bar-item-manager.ts +++ b/src/card-controller/status-bar-item-manager.ts @@ -2,14 +2,18 @@ import { isEqual } from 'lodash-es'; import { CameraManager } from '../camera-manager/manager'; import { StatusBarItem } from '../config/schema/actions/types'; import { StatusBarConfig } from '../config/schema/status-bar'; -import { localize } from '../localize/localize'; import { MediaLoadedInfo } from '../types'; import { createNotificationAction } from '../utils/action'; import { View } from '../view/view'; +import { KeyedProblemResult, ProblemKey } from './problems/types'; import { CardStatusBarAPI } from './types'; const RESOLUTION_TOLERANCE_PCT = 0.01; +const problemKeyToStatusBarKey = (key: ProblemKey): keyof StatusBarConfig['items'] => { + return `problem_${key}`; +}; + export class StatusBarItemManager { private _api: CardStatusBarAPI; @@ -44,7 +48,7 @@ export class StatusBarItemManager { cameraManager?: CameraManager | null; view?: View | null; mediaLoadedInfo?: MediaLoadedInfo | null; - isUpgradeable?: boolean; + problems?: KeyedProblemResult[] | null; }): StatusBarItem[] { const cameraMetadata = options?.view?.camera ? options?.cameraManager?.getCameraMetadata(options.view.camera) @@ -128,26 +132,22 @@ export class StatusBarItemManager { ] : []), - ...(options?.isUpgradeable - ? [ - { - type: 'custom:advanced-camera-card-status-bar-icon' as const, - icon: 'mdi:update', - severity: 'medium' as const, - actions: { - tap_action: createNotificationAction({ - heading: { - text: localize('notification.upgrade.heading'), - icon: 'mdi:update', - severity: 'medium', - }, - text: localize('notification.upgrade.text'), - }), - }, - ...options?.statusConfig?.items.upgrade, - }, - ] - : []), + ...(options?.problems ?? []) + .filter( + ({ key }) => + options?.statusConfig?.items[problemKeyToStatusBarKey(key)]?.enabled !== + false, + ) + .map(({ key, problem }) => ({ + type: 'custom:advanced-camera-card-status-bar-icon' as const, + icon: problem.icon, + severity: problem.severity, + title: problem.notification.heading?.text, + actions: { + tap_action: createNotificationAction(problem.notification), + }, + ...options?.statusConfig?.items[problemKeyToStatusBarKey(key)], + })), ...this._dynamicItems, ]; } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index ce2079c3..6b857c62 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -24,6 +24,7 @@ import type { MessageManager } from './message-manager'; import type { MicrophoneManager } from './microphone-manager'; import type { NotificationManager } from './notification-manager'; import type { PIPManager } from './pip-manager'; +import type { ProblemManager } from './problems/manager'; import type { QueryStringManager } from './query-string-manager'; import type { StatusBarItemManager } from './status-bar-item-manager'; import type { StyleManager } from './style-manager'; @@ -116,8 +117,8 @@ export interface CardConfigLoaderAPI { getAutomationsManager(): AutomationsManager; getConfigManager(): ConfigManager; getFoldersManager(): FoldersManager; - getMessageManager(): MessageManager; getHASSManager(): HASSManager; + getMessageManager(): MessageManager; } export interface CardDefaultManagerAPI { @@ -153,6 +154,7 @@ export interface CardElementAPI { getMediaPlayerManager(): MediaPlayerManager; getMicrophoneManager(): MicrophoneManager; getPIPManager(): PIPManager; + getProblemManager(): ProblemManager; getQueryStringManager(): QueryStringManager; getViewManager(): ViewManager; } @@ -213,6 +215,7 @@ export interface CardInitializerAPI { getHASSManager(): HASSManager; getMediaPlayerManager(): MediaPlayerManager; getMessageManager(): MessageManager; + getProblemManager(): ProblemManager; getQueryStringManager(): QueryStringManager; getResolvedMediaCache(): ResolvedMediaCache; getTriggersManager(): TriggersManager; @@ -261,6 +264,13 @@ export interface CardNotificationAPI { getCardElementManager(): CardElementManager; } +export interface CardProblemAPI { + getCardElementManager(): CardElementManager; + getConditionStateManager(): ConditionStateManager; + getConfigManager(): ConfigManager; + getNotificationManager(): NotificationManager; +} + export interface CardMicrophoneAPI { getCardElementManager(): CardElementManager; getConditionStateManager(): ConditionStateManager; diff --git a/src/card.ts b/src/card.ts index 66b30d04..181e106e 100644 --- a/src/card.ts +++ b/src/card.ts @@ -7,6 +7,10 @@ import { styleMap } from 'lit/directives/style-map.js'; import 'web-dialog'; import { actionHandler } from './action-handler-directive.js'; import { CardController } from './card-controller/controller'; +import type { + ProblemKey, + ProblemTriggerEventData, +} from './card-controller/problems/types.js'; import { MenuButtonController } from './components-lib/menu-button-controller'; import './components/effects/effects'; import './components/elements.js'; @@ -170,7 +174,9 @@ class AdvancedCameraCard extends LitElement { } if (!this._controller.getInitializationManager().isInitializedMandatory()) { - this._controller.getInitializationManager().initializeMandatory(); + /* async */ this._controller.getInitializationManager().initializeMandatory(); + } else if (!this._controller.getInitializationManager().isInitializedBackground()) { + /* async */ this._controller.getInitializationManager().initializeBackground(); } return true; } @@ -288,7 +294,7 @@ class AdvancedCameraCard extends LitElement { cameraManager: this._controller.getCameraManager(), view: this._controller.getViewManager().getView(), mediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(), - isUpgradeable: this._controller.getConfigManager().isUpgradeable(), + problems: this._controller.getProblemManager().getProblemResults(), })} .config=${this._config.status_bar} > @@ -365,6 +371,12 @@ class AdvancedCameraCard extends LitElement { }} @advanced-camera-card:media:unloaded=${() => this._controller.getMediaLoadedInfoManager().clear()} + @advanced-camera-card:problem:notify=${(ev: CustomEvent) => + this._controller.getProblemManager().forceNotify(ev.detail)} + @advanced-camera-card:problem:trigger=${({ + detail: { key, ...context }, + }: CustomEvent) => + this._controller.getProblemManager().trigger(key, context)} @advanced-camera-card:media:volumechange=${ () => this.requestUpdate() /* Refresh mute menu button */ } @@ -411,6 +423,7 @@ class AdvancedCameraCard extends LitElement { ? this._controller.getTriggersManager().getTriggeredCameraIDs() : undefined} .deviceRegistryManager=${this._controller.getDeviceRegistryManager()} + .problems=${this._controller.getProblemManager().getProblemPresence()} > ${this._controller.getMessageManager().hasMessage() ? // Keep message rendering to last to show messages that may have been diff --git a/src/components-lib/message/controller.ts b/src/components-lib/message/controller.ts index 2e28de66..a55bc576 100644 --- a/src/components-lib/message/controller.ts +++ b/src/components-lib/message/controller.ts @@ -1,7 +1,8 @@ import yaml from 'js-yaml'; +import { Link } from '../../config/schema/common/link.js'; import { TROUBLESHOOTING_URL } from '../../const'; import { localize } from '../../localize/localize.js'; -import { Message, MessageURL } from '../../types'; +import { Message } from '../../types'; export class MessageController { public getMessageString(message: Message): string { @@ -21,11 +22,11 @@ export class MessageController { : 'mdi:information-outline'; } - public getURL(message: Message): MessageURL | null { - return message.url - ? message.url + public getLink(message: Message): Link | null { + return message.link + ? message.link : message.type === 'error' - ? { link: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') } + ? { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') } : null; } diff --git a/src/components/diagnostics.ts b/src/components/diagnostics.ts index dc68b406..aa01a1f0 100644 --- a/src/components/diagnostics.ts +++ b/src/components/diagnostics.ts @@ -1,6 +1,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { until } from 'lit/directives/until.js'; +import { ProblemPresence } from '../card-controller/problems/types'; import { RawAdvancedCameraCardConfig } from '../config/types'; import { DeviceRegistryManager } from '../ha/registry/device'; import { HomeAssistant } from '../ha/types'; @@ -20,11 +21,15 @@ export class AdvancedCameraCardDiagnostics extends LitElement { @property({ attribute: false }) public rawConfig?: RawAdvancedCameraCardConfig; + @property({ attribute: false }) + public problems?: ProblemPresence; + private async _renderDiagnostics(): Promise { const diagnostics = await getDiagnostics( this.hass, this.deviceRegistryManager, this.rawConfig, + this.problems, ); return renderMessage({ diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 47714329..41fd5583 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -18,7 +18,6 @@ import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch import { PartialZoomSettings } from '../../components-lib/zoom/types.js'; import { LiveConfig } from '../../config/schema/live.js'; import { CardWideConfig } from '../../config/schema/types.js'; -import { STREAM_TROUBLESHOOTING_URL } from '../../const.js'; import { HomeAssistant } from '../../ha/types.js'; import { localize } from '../../localize/localize.js'; import liveProviderStyle from '../../scss/live-provider.scss'; @@ -28,6 +27,7 @@ import { MediaPlayerController, MediaPlayerElement, } from '../../types.js'; +import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event.js'; import { getResolvedLiveProvider } from '../../utils/live-provider.js'; import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js'; import '../icon.js'; @@ -73,9 +73,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP @state() private _hasProviderError = false; - @state() - private _showStreamTroubleshooting = false; - private _refProvider: Ref = createRef(); private _lazyLoadController: LazyLoadController = new LazyLoadController(this); @@ -118,8 +115,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP !!this.camera?.getConfig()?.camera_entity && !!this.hass && !!this.liveConfig?.show_image_during_load && - !this._showStreamTroubleshooting && - // Do not continue to show image during loading if an error has occurred. !this._hasProviderError ); } @@ -131,11 +126,19 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP private _videoMediaShowHandler(): void { this._isVideoMediaLoaded = true; - this._showStreamTroubleshooting = false; } - private _providerErrorHandler(): void { + private _providerErrorHandler(ev: Event): void { + ev.stopPropagation(); this._hasProviderError = true; + + const cameraID = this.camera?.getID(); + if (cameraID) { + fireAdvancedCameraCardEvent(this, 'problem:trigger', { + key: 'stream_not_loading' as const, + cameraID, + }); + } } protected willUpdate(changedProps: PropertyValues): void { @@ -305,7 +308,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP // so it should not be hidden. hidden: false, })} - @advanced-camera-card:live:error=${() => this._providerErrorHandler()} + @advanced-camera-card:live:error=${(ev: Event) => + this._providerErrorHandler(ev)} @advanced-camera-card:media:loaded=${(ev: CustomEvent) => { ev.detail.placeholder = provider !== 'image'; }} @@ -319,7 +323,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP .hass=${this.hass} .cameraConfig=${cameraConfig} ?controls=${this._getEffectiveBuiltinControls()} - @advanced-camera-card:live:error=${() => this._providerErrorHandler()} + @advanced-camera-card:live:error=${(ev: Event) => + this._providerErrorHandler(ev)} > ` : provider === 'go2rtc' @@ -332,7 +337,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP .microphoneState=${this.microphoneState} .microphoneConfig=${this.liveConfig.microphone} ?controls=${this._getEffectiveBuiltinControls()} - @advanced-camera-card:live:error=${() => this._providerErrorHandler()} + @advanced-camera-card:live:error=${(ev: Event) => + this._providerErrorHandler(ev)} > ` : provider === 'webrtc-card' @@ -344,7 +350,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP .cameraEndpoints=${this.cameraEndpoints} .cardWideConfig=${this.cardWideConfig} ?controls=${this._getEffectiveBuiltinControls()} - @advanced-camera-card:live:error=${() => this._providerErrorHandler()} + @advanced-camera-card:live:error=${(ev: Event) => + this._providerErrorHandler(ev)} > ` : provider === 'jsmpeg' @@ -355,7 +362,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP .cameraConfig=${cameraConfig} .cameraEndpoints=${this.cameraEndpoints} .cardWideConfig=${this.cardWideConfig} - @advanced-camera-card:live:error=${() => this._providerErrorHandler()} + @advanced-camera-card:live:error=${(ev: Event) => + this._providerErrorHandler(ev)} > ` : html``} @@ -364,24 +372,9 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP ? html` { - this._showStreamTroubleshooting = !this._showStreamTroubleshooting; - }} + @click=${() => + fireAdvancedCameraCardEvent(this, 'problem:notify', 'stream_not_loading')} >` - : ''} - ${this._showStreamTroubleshooting - ? renderMessage( - { - type: 'error', - icon: 'mdi:camera-off', - message: localize('error.stream_not_loading'), - url: { - link: STREAM_TROUBLESHOOTING_URL, - title: localize('error.troubleshooting'), - }, - }, - { overlay: true }, - ) : ''}`; } diff --git a/src/components/message.ts b/src/components/message.ts index d98007fa..b54192e1 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -32,10 +32,10 @@ export class AdvancedCameraCardMessage extends LitElement { return; } - const url = this._controller.getURL(this.message); + const link = this._controller.getLink(this.message); const messageTemplate = html` ${this._controller.getMessageString(this.message)} - ${url ? html`. ${url.title}` : ''} + ${link ? html`. ${link.title}` : ''} `; const icon = this._controller.getIcon(this.message); diff --git a/src/components/notification.ts b/src/components/notification.ts index 7b892c40..c74c338b 100644 --- a/src/components/notification.ts +++ b/src/components/notification.ts @@ -61,6 +61,17 @@ export class AdvancedCameraCardNotification extends LitElement { ${heading ? this._renderDetail(heading, true) : ''} ${details.map((detail) => this._renderDetail(detail))} ${text ? html`
${text}
` : ''} + ${this.notification.link + ? html`` + : ''} ${controls.length ? html`
diff --git a/src/components/status-bar.ts b/src/components/status-bar.ts index eb457203..190fc36e 100644 --- a/src/components/status-bar.ts +++ b/src/components/status-bar.ts @@ -4,6 +4,7 @@ import { PropertyValues, TemplateResult, html, + nothing, unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; @@ -101,6 +102,7 @@ export class AdvancedCameraCardStatusBar extends LitElement { return html`
this._controller.actionHandler(ev, item.actions)} > @@ -111,6 +113,7 @@ export class AdvancedCameraCardStatusBar extends LitElement { .actionHandler=${handler} .icon=${{ icon: item.icon }} class="${classes}" + title=${item.title ?? nothing} data-severity=${item.severity ?? ''} @action=${(ev) => this._controller.actionHandler(ev, item.actions)} >`; @@ -118,6 +121,7 @@ export class AdvancedCameraCardStatusBar extends LitElement { return html` this._controller.actionHandler(ev, item.actions)} diff --git a/src/components/views.ts b/src/components/views.ts index e77ed1a3..215cc9d7 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -10,6 +10,7 @@ import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { CameraManager } from '../camera-manager/manager.js'; import { FoldersManager } from '../card-controller/folders/manager.js'; +import { ProblemPresence } from '../card-controller/problems/types.js'; import { MicrophoneState } from '../card-controller/types.js'; import { ViewItemManager } from '../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js'; @@ -68,6 +69,9 @@ export class AdvancedCameraCardViews extends LitElement { @property({ attribute: false }) public deviceRegistryManager?: DeviceRegistryManager; + @property({ attribute: false }) + public problems?: ProblemPresence; + @property({ attribute: false }) public conditionStateManager?: ConditionStateManagerReadonlyInterface; @@ -211,6 +215,7 @@ export class AdvancedCameraCardViews extends LitElement { .hass=${this.hass} .rawConfig=${this.rawConfig} .deviceRegistryManager=${this.deviceRegistryManager} + .problems=${this.problems} > ` : ``} diff --git a/src/conditions/types.ts b/src/conditions/types.ts index 390a4500..54f707fa 100644 --- a/src/conditions/types.ts +++ b/src/conditions/types.ts @@ -1,4 +1,5 @@ import { KeysState, MicrophoneState } from '../card-controller/types'; +import { AdvancedCameraCardView } from '../config/schema/common/const'; import { ViewDisplayMode } from '../config/schema/common/display'; import { AdvancedCameraCardConfig } from '../config/schema/types'; import { HomeAssistant } from '../ha/types'; @@ -19,7 +20,7 @@ export interface ConditionState { hass?: HomeAssistant; triggered?: Set; userAgent?: string; - view?: string; + view?: AdvancedCameraCardView; } export interface ConditionStateChange { diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index 2657fd0a..a6d60410 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { linkSchema } from '../common/link'; import { severitySchema } from '../common/severity'; import { statusBarItemBaseSchema } from '../common/status-bar'; import { advancedCameraCardCustomActionsBaseSchema } from './custom/base'; @@ -138,6 +139,7 @@ const notificationSchema = z.object({ controls: notificationControlSchema.array().optional(), details: notificationDetailSchema.array().optional(), text: z.string().optional(), + link: linkSchema.optional(), }); export type Notification = z.infer; @@ -157,6 +159,7 @@ const statusBarItemElementsBaseSchema = statusBarItemBaseSchema.extend({ exclusive: z.boolean().default(false).optional(), expand: z.boolean().default(false).optional(), severity: severitySchema.optional(), + title: z.string().optional(), actions: actionsBaseSchema.optional(), }); diff --git a/src/config/schema/common/link.ts b/src/config/schema/common/link.ts new file mode 100644 index 00000000..b7c34136 --- /dev/null +++ b/src/config/schema/common/link.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const linkSchema = z.object({ + url: z.string(), + title: z.string(), +}); +export type Link = z.infer; diff --git a/src/config/schema/status-bar.ts b/src/config/schema/status-bar.ts index 41de0825..d1de2bb6 100644 --- a/src/config/schema/status-bar.ts +++ b/src/config/schema/status-bar.ts @@ -26,7 +26,11 @@ export const statusBarConfigDefault = { severity: statusBarItemDefault, technology: statusBarItemDefault, title: statusBarItemDefault, - upgrade: statusBarItemDefault, + + // Problems. + problem_config_upgrade: statusBarItemDefault, + problem_legacy_resource: statusBarItemDefault, + problem_stream_not_loading: statusBarItemDefault, }, position: 'bottom' as const, style: 'popup' as const, @@ -46,15 +50,25 @@ export const statusBarConfigSchema = z items: z .object({ engine: statusBarItemBaseSchema.default(statusBarConfigDefault.items.engine), - technology: statusBarItemBaseSchema.default( - statusBarConfigDefault.items.technology, - ), resolution: statusBarItemBaseSchema.default( statusBarConfigDefault.items.resolution, ), severity: statusBarItemBaseSchema.default(statusBarConfigDefault.items.severity), + technology: statusBarItemBaseSchema.default( + statusBarConfigDefault.items.technology, + ), title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title), - upgrade: statusBarItemBaseSchema.default(statusBarConfigDefault.items.upgrade), + + // Problems. + problem_config_upgrade: statusBarItemBaseSchema.default( + statusBarConfigDefault.items.problem_config_upgrade, + ), + problem_legacy_resource: statusBarItemBaseSchema.default( + statusBarConfigDefault.items.problem_legacy_resource, + ), + problem_stream_not_loading: statusBarItemBaseSchema.default( + statusBarConfigDefault.items.problem_stream_not_loading, + ), }) .default(statusBarConfigDefault.items), }) diff --git a/src/const.ts b/src/const.ts index 3a4f020c..76af50cf 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,7 +1,11 @@ export const REPO_URL = 'https://github.com/dermotduffy/advanced-camera-card' as const; export const DOCS_URL = 'https://card.camera' as const; export const TROUBLESHOOTING_URL = `${DOCS_URL}/#/troubleshooting` as const; -export const STREAM_TROUBLESHOOTING_URL = +export const TROUBLESHOOTING_CONFIG_UPGRADE_URL = + `${TROUBLESHOOTING_URL}?id=configuration-upgrade-available` as const; +export const TROUBLESHOOTING_LEGACY_RESOURCE_URL = + `${TROUBLESHOOTING_URL}?id=legacy-dashboard-resource-detected` as const; +export const TROUBLESHOOTING_STREAM_URL = `${TROUBLESHOOTING_URL}?id=stream-does-not-load` as const; const CONFIGURATION_URL = `${DOCS_URL}/#/configuration`; export const FOLDERS_CONFIGURATION_URL = `${CONFIGURATION_URL}/folders`; diff --git a/src/editor.ts b/src/editor.ts index 7e125cfe..ee65f1de 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -2281,8 +2281,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${renderMessage({ message: localize('config.folders.ha.path_info'), icon: 'mdi:information-outline', - url: { - link: FOLDERS_CONFIGURATION_URL, + link: { + url: FOLDERS_CONFIGURATION_URL, title: localize('error.configuration'), }, })} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index c9af10d8..4fc589a5 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -767,7 +767,6 @@ "no_supported_camera": "The selected camera or media does not support this view", "no_supported_cameras": "No cameras support this view", "reconnecting": "Reconnecting", - "stream_not_loading": "The video stream has not yet loaded. This is could for any number of reasons. If configured (and by default), there will be an image refreshing every second until the stream loads correctly", "too_many_automations": "Too many nested automation calls, please check your configuration for loops", "troubleshooting": "Check troubleshooting", "unknown": "Unknown error", @@ -823,10 +822,21 @@ "media_viewer": { "unseekable": "Seek time not found in media" }, - "notification": { - "upgrade": { + "problems": { + "troubleshooting_guide": "Troubleshooting guide", + "config_upgrade": { "heading": "Configuration upgrade available", "text": "A configuration upgrade is available. To upgrade, edit this card (Dashboard pencil icon \u2192 Three-dot menu \u2192 Edit) and click the 'Automatic Upgrade' button in the card editor." + }, + "legacy_resource": { + "heading": "Legacy dashboard resource detected", + "text_both": "The legacy 'frigate-hass-card.js' resource is still registered, please either manually remove it or click the delete icon to automatically remove it. It will be removed in a future release.", + "text_only_legacy": "The legacy 'frigate-hass-card.js' resource must be replaced with 'advanced-camera-card.js'. It will be removed in a future release.", + "remove": "Remove legacy resource" + }, + "stream_not_loading": { + "heading": "Live stream not loading", + "text": "The video stream has not yet loaded. This could be for any number of reasons. If configured (and by default), there will be an image refreshing every second until the stream loads correctly" } }, "thumbnail": { diff --git a/src/scss/notification.scss b/src/scss/notification.scss index 05292864..d0d12e59 100644 --- a/src/scss/notification.scss +++ b/src/scss/notification.scss @@ -290,3 +290,21 @@ white-space: pre-wrap; word-break: break-word; } + +.url { + margin-top: 8px; + width: 100%; + + a { + color: var(--advanced-camera-card-notification-text-color); + font-size: 13px; + opacity: 0.8; + text-decoration: underline; + cursor: pointer; + transition: opacity 0.15s ease; + + &:hover { + opacity: 1; + } + } +} diff --git a/src/types.ts b/src/types.ts index 116db2a1..c70d388d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import type { EffectOptions } from './card-controller/effects/types'; +import type { Link } from './config/schema/common/link'; import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types'; // UI-facing media types for galleries and views. @@ -49,10 +50,6 @@ export interface MediaLoadedInfo { } export type MessageType = 'info' | 'error' | 'connection' | 'diagnostics'; -export interface MessageURL { - link: string; - title: string; -} export interface Message { message: string; @@ -60,7 +57,7 @@ export interface Message { icon?: string; context?: unknown; dotdotdot?: boolean; - url?: MessageURL; + link?: Link; } export type WebkitHTMLVideoElement = HTMLVideoElement & { diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts index 39f15aad..530f6c7f 100644 --- a/src/utils/diagnostics.ts +++ b/src/utils/diagnostics.ts @@ -1,4 +1,5 @@ import pkg from '../../package.json'; +import { ProblemPresence } from '../card-controller/problems/types'; import { RawAdvancedCameraCardConfig } from '../config/types'; import { getIntegrationManifest } from '../ha/integration'; import { IntegrationManifest } from '../ha/integration/types'; @@ -46,6 +47,7 @@ interface Diagnostics { ha_version?: string; config?: RawAdvancedCameraCardConfig; + problems?: ProblemPresence; custom_integrations: { frigate: IntegrationDiagnostics & { @@ -80,6 +82,7 @@ export const getDiagnostics = async ( hass?: HomeAssistant, deviceRegistryManager?: DeviceRegistryManager, rawConfig?: RawAdvancedCameraCardConfig, + problems?: ProblemPresence, ): Promise => { // Get the Frigate devices in order to extract the Frigate integration and // server version numbers. @@ -121,6 +124,7 @@ export const getDiagnostics = async ( }, hass_web_proxy: await getIntegrationDiagnostics(HASS_WEB_PROXY_DOMAIN, hass), }, + ...(problems && { problems }), ...(rawConfig && { config: rawConfig }), }; }; diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index 454a23d6..4964a97c 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -123,27 +123,6 @@ describe('ConfigManager', () => { expect(manager.getConfig()).toBeNull(); expect(manager.getNonOverriddenConfig()).toBeNull(); expect(manager.getRawConfig()).toBeNull(); - expect(manager.isUpgradeable()).toBe(false); - }); - - describe('isUpgradeable', () => { - it('should return true for upgradeable config', () => { - const manager = new ConfigManager(createCardAPI()); - manager.setConfig({ - type: 'custom:frigate-card', - cameras: [TEST_CAMERAS.OFFICE], - }); - expect(manager.isUpgradeable()).toBe(true); - }); - - it('should return false for non-upgradeable config', () => { - const manager = new ConfigManager(createCardAPI()); - manager.setConfig({ - type: 'custom:advanced-camera-card', - cameras: [TEST_CAMERAS.OFFICE], - }); - expect(manager.isUpgradeable()).toBe(false); - }); }); it('should successfully parse basic config', () => { diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index 6cf480f4..97bc9939 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -23,6 +23,7 @@ import { MessageManager } from '../../src/card-controller/message-manager'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; import { NotificationManager } from '../../src/card-controller/notification-manager'; import { PIPManager } from '../../src/card-controller/pip-manager'; +import { ProblemManager } from '../../src/card-controller/problems/manager'; import { QueryStringManager } from '../../src/card-controller/query-string-manager'; import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager'; import { StyleManager } from '../../src/card-controller/style-manager'; @@ -56,6 +57,7 @@ vi.mock('../../src/card-controller/message-manager'); vi.mock('../../src/card-controller/microphone-manager'); vi.mock('../../src/card-controller/notification-manager'); vi.mock('../../src/card-controller/pip-manager'); +vi.mock('../../src/card-controller/problems/manager'); vi.mock('../../src/card-controller/query-string-manager'); vi.mock('../../src/card-controller/status-bar-item-manager'); vi.mock('../../src/card-controller/style-manager'); @@ -238,6 +240,12 @@ describe('CardController', () => { ); }); + it('getProblemManager', () => { + expect(createController().getProblemManager()).toBe( + vi.mocked(ProblemManager).mock.instances[0], + ); + }); + it('getMicrophoneManager', () => { expect(createController().getMicrophoneManager()).toBe( vi.mocked(MicrophoneManager).mock.instances[0], diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index cd312207..fb36eb8e 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -215,6 +215,65 @@ describe('InitializationManager', () => { }); }); + it('should report background initialization status', () => { + const initializer = mock(); + const manager = new InitializationManager(createCardAPI(), initializer); + + initializer.isInitialized.mockReturnValue(false); + expect(manager.isInitializedBackground()).toBe(false); + + initializer.isInitialized.mockReturnValue(true); + expect(manager.isInitializedBackground()).toBe(true); + + expect(initializer.isInitialized).toBeCalledWith(InitializationAspect.PROBLEMS); + }); + + describe('should initialize background', () => { + it('without hass', async () => { + const api = createCardAPI(); + const initializer = mock(); + const manager = new InitializationManager(api, initializer); + + await manager.initializeBackground(); + + expect(initializer.initializeIfNecessary).not.toBeCalled(); + }); + + it('successfully', async () => { + const api = createCardAPI(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const initializer = mock(); + initializer.initializeIfNecessary.mockResolvedValue(true); + const manager = new InitializationManager(api, initializer); + + await manager.initializeBackground(); + + expect(initializer.initializeIfNecessary).toBeCalledWith( + InitializationAspect.PROBLEMS, + expect.any(Function), + ); + }); + + it('should call detectStatic on problem manager', async () => { + const api = createCardAPI(); + const hass = createHASS(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + const initializer = mock(); + initializer.initializeIfNecessary.mockImplementation(async (_aspect, callback) => { + return callback ? await callback() : true; + }); + + const manager = new InitializationManager(api, initializer); + + await manager.initializeBackground(); + + expect(api.getProblemManager().detectStatic).toBeCalledWith(hass); + }); + }); + it('should uninitialize', () => { const initializer = mock(); const manager = new InitializationManager(createCardAPI(), initializer); diff --git a/tests/card-controller/problems/manager.test.ts b/tests/card-controller/problems/manager.test.ts new file mode 100644 index 00000000..cbee5e75 --- /dev/null +++ b/tests/card-controller/problems/manager.test.ts @@ -0,0 +1,299 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { ProblemManager } from '../../../src/card-controller/problems/manager'; +import { ConfigUpgradeProblem } from '../../../src/card-controller/problems/problems/config-upgrade'; +import { LegacyResourceProblem } from '../../../src/card-controller/problems/problems/legacy-resource'; +import { StreamNotLoadingProblem } from '../../../src/card-controller/problems/problems/stream-not-loading'; +import { Problem, ProblemResult } from '../../../src/card-controller/problems/types'; +import { ConditionStateManager } from '../../../src/conditions/state-manager'; +import { createCardAPI, createHASS } from '../../test-utils'; + +vi.mock('../../../src/card-controller/problems/problems/config-upgrade'); +vi.mock('../../../src/card-controller/problems/problems/legacy-resource'); +vi.mock('../../../src/card-controller/problems/problems/stream-not-loading'); + +const createProblemResult = (overrides?: Partial): ProblemResult => ({ + icon: 'mdi:test', + severity: 'high', + notification: { + heading: { + text: 'Test heading', + icon: 'mdi:test', + severity: 'high', + }, + text: 'Test text', + }, + ...overrides, +}); + +// @vitest-environment jsdom +describe('ProblemManager', () => { + let mockConfigUpgrade: Problem; + let mockLegacyResource: Problem; + let mockStreamNotLoading: Problem; + + beforeEach(() => { + vi.resetAllMocks(); + + mockConfigUpgrade = mock({ key: 'config_upgrade' }); + mockLegacyResource = mock({ key: 'legacy_resource' }); + mockStreamNotLoading = mock({ key: 'stream_not_loading' }); + + vi.mocked(ConfigUpgradeProblem).mockImplementation( + () => mockConfigUpgrade as unknown as ConfigUpgradeProblem, + ); + vi.mocked(LegacyResourceProblem).mockImplementation( + () => mockLegacyResource as unknown as LegacyResourceProblem, + ); + vi.mocked(StreamNotLoadingProblem).mockImplementation( + () => mockStreamNotLoading as unknown as StreamNotLoadingProblem, + ); + }); + + it('should pass config getter to ConfigUpgradeProblem', () => { + const api = createCardAPI(); + new ProblemManager(api); + + const callback = vi.mocked(ConfigUpgradeProblem).mock.calls[0][0]; + callback(); + + expect(api.getConfigManager().getRawConfig).toBeCalled(); + }); + + it('should pass update callback to LegacyResourceProblem', () => { + const api = createCardAPI(); + new ProblemManager(api); + + const callback = vi.mocked(LegacyResourceProblem).mock.calls[0][0]; + callback(); + + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should pass update callback to StreamNotLoadingProblem', () => { + const api = createCardAPI(); + new ProblemManager(api); + + const callback = vi.mocked(StreamNotLoadingProblem).mock.calls[0][0]; + callback(); + + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should register all built-in problems on construction', () => { + const api = createCardAPI(); + const manager = new ProblemManager(api); + const presence = manager.getProblemPresence(); + + expect('config_upgrade' in presence).toBe(true); + expect('legacy_resource' in presence).toBe(true); + expect('stream_not_loading' in presence).toBe(true); + }); + + describe('detectStatic', () => { + it('should call detectStatic on all problems', async () => { + const api = createCardAPI(); + const manager = new ProblemManager(api); + const hass = createHASS(); + + await manager.detectStatic(hass); + + expect(mockConfigUpgrade.detectStatic).toBeCalledWith(hass); + expect(mockLegacyResource.detectStatic).toBeCalledWith(hass); + expect(mockStreamNotLoading.detectStatic).toBeCalledWith(hass); + expect(api.getCardElementManager().update).toBeCalled(); + }); + }); + + describe('trigger', () => { + it('should trigger a problem and update when state changes', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + const manager = new ProblemManager(api); + vi.mocked(mockStreamNotLoading.hasResult) + .mockReturnValueOnce(false) + .mockReturnValue(true); + + manager.trigger('stream_not_loading'); + + expect(mockStreamNotLoading.trigger).toBeCalled(); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should do nothing for unknown key', () => { + const api = createCardAPI(); + const manager = new ProblemManager(api); + + manager.trigger(('stream_not_loading' + '_unknown') as never); + + expect(mockStreamNotLoading.trigger).not.toBeCalled(); + }); + + it('should not update when trigger does not change state', () => { + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager().getState).mockReturnValue({}); + const manager = new ProblemManager(api); + vi.mocked(mockStreamNotLoading.hasResult).mockReturnValue(false); + + manager.trigger('stream_not_loading'); + + expect(mockStreamNotLoading.trigger).toBeCalled(); + expect(api.getCardElementManager().update).not.toBeCalled(); + }); + }); + + describe('forceNotify', () => { + it('should show notification from getNotification', () => { + const api = createCardAPI(); + const manager = new ProblemManager(api); + const notification = { text: 'from getNotification' }; + mockStreamNotLoading.getNotification = vi.fn().mockReturnValue(notification); + + manager.forceNotify('stream_not_loading'); + + expect(api.getNotificationManager().setNotification).toBeCalledWith(notification); + }); + + it('should not show notification when getNotification returns null', () => { + const api = createCardAPI(); + const manager = new ProblemManager(api); + + manager.forceNotify('config_upgrade'); + + expect(api.getNotificationManager().setNotification).not.toBeCalled(); + }); + }); + + describe('getProblemResults', () => { + it('should return results for active problems', () => { + const api = createCardAPI(); + const result = createProblemResult(); + vi.mocked(mockConfigUpgrade.getResult).mockReturnValue(result); + + const manager = new ProblemManager(api); + + expect(manager.getProblemResults()).toEqual([ + { key: 'config_upgrade', problem: result }, + ]); + }); + + it('should return empty array when no problems active', () => { + const api = createCardAPI(); + const manager = new ProblemManager(api); + + expect(manager.getProblemResults()).toEqual([]); + }); + }); + + describe('getProblemPresence', () => { + it('should return presence map', () => { + const api = createCardAPI(); + vi.mocked(mockConfigUpgrade.hasResult).mockReturnValue(true); + vi.mocked(mockLegacyResource.hasResult).mockReturnValue(false); + + const manager = new ProblemManager(api); + + expect(manager.getProblemPresence()).toMatchObject({ + ['config_upgrade']: true, + ['legacy_resource']: false, + }); + }); + }); + + describe('state change handling', () => { + it('should detect dynamic problems on view change', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new ProblemManager(api); + vi.mocked(mockStreamNotLoading.hasResult) + .mockReturnValueOnce(false) + .mockReturnValue(true); + + manager.initialize(); + + stateManager.setState({ view: 'live' }); + + expect(mockStreamNotLoading.detectDynamic).toBeCalledWith({ + view: 'live', + mediaLoaded: false, + }); + expect(api.getCardElementManager().update).toBeCalled(); + }); + + it('should detect dynamic problems on mediaLoadedInfo change', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new ProblemManager(api); + + manager.initialize(); + + stateManager.setState({ + mediaLoadedInfo: { width: 1920, height: 1080 }, + }); + + expect(mockStreamNotLoading.detectDynamic).toBeCalledWith( + expect.objectContaining({ mediaLoaded: true }), + ); + }); + + it('should not update when dynamic detection does not change state', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new ProblemManager(api); + vi.mocked(mockStreamNotLoading.hasResult).mockReturnValue(false); + + manager.initialize(); + + stateManager.setState({ view: 'live' }); + + expect(mockStreamNotLoading.detectDynamic).toBeCalled(); + expect(api.getCardElementManager().update).not.toBeCalled(); + }); + }); + + describe('uninitialize', () => { + it('should remove state listener', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new ProblemManager(api); + + manager.initialize(); + manager.uninitialize(); + + stateManager.setState({ view: 'live' }); + + expect(mockStreamNotLoading.detectDynamic).not.toBeCalled(); + }); + }); + + describe('destroy', () => { + it('should destroy all problems and clear', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new ProblemManager(api); + + manager.initialize(); + manager.destroy(); + + expect(mockConfigUpgrade.destroy).toBeCalled(); + expect(mockLegacyResource.destroy).toBeCalled(); + expect(mockStreamNotLoading.destroy).toBeCalled(); + expect(manager.getProblemPresence()).toEqual({}); + + // State changes after destroy should not trigger detection. + stateManager.setState({ view: 'live' }); + + expect(mockStreamNotLoading.detectDynamic).not.toBeCalled(); + }); + }); +}); diff --git a/tests/card-controller/problems/problems/config-upgrade.test.ts b/tests/card-controller/problems/problems/config-upgrade.test.ts new file mode 100644 index 00000000..7d7b9300 --- /dev/null +++ b/tests/card-controller/problems/problems/config-upgrade.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ConfigUpgradeProblem } from '../../../../src/card-controller/problems/problems/config-upgrade'; +import { isConfigUpgradeable } from '../../../../src/config/management'; + +vi.mock('../../../../src/config/management.js'); + +describe('ConfigUpgradeProblem', () => { + it('should have correct key', () => { + const problem = new ConfigUpgradeProblem(() => null); + expect(problem.key).toBe('config_upgrade'); + }); + + it('should detect upgradeable config', async () => { + vi.mocked(isConfigUpgradeable).mockReturnValue(true); + const rawConfig = { type: 'custom:frigate-card' }; + const problem = new ConfigUpgradeProblem(() => rawConfig); + + await problem.detectStatic(); + + expect(problem.hasResult()).toBe(true); + expect(isConfigUpgradeable).toBeCalledWith(rawConfig); + }); + + it('should detect non-upgradeable config', async () => { + vi.mocked(isConfigUpgradeable).mockReturnValue(false); + const rawConfig = { type: 'custom:advanced-camera-card' }; + const problem = new ConfigUpgradeProblem(() => rawConfig); + + await problem.detectStatic(); + + expect(problem.hasResult()).toBe(false); + }); + + it('should handle null raw config', async () => { + const problem = new ConfigUpgradeProblem(() => null); + + await problem.detectStatic(); + + expect(problem.hasResult()).toBe(false); + expect(problem.getResult()).toBeNull(); + }); + + it('should return result when upgradeable', async () => { + vi.mocked(isConfigUpgradeable).mockReturnValue(true); + const problem = new ConfigUpgradeProblem(() => ({ type: 'custom:frigate-card' })); + + await problem.detectStatic(); + + const result = problem.getResult(); + expect(result).toEqual( + expect.objectContaining({ + icon: 'mdi:update', + severity: 'medium', + notification: expect.objectContaining({ + heading: expect.objectContaining({ + icon: 'mdi:update', + severity: 'medium', + }), + }), + }), + ); + }); +}); diff --git a/tests/card-controller/problems/problems/legacy-resource.test.ts b/tests/card-controller/problems/problems/legacy-resource.test.ts new file mode 100644 index 00000000..0321f040 --- /dev/null +++ b/tests/card-controller/problems/problems/legacy-resource.test.ts @@ -0,0 +1,440 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LegacyResourceProblem } from '../../../../src/card-controller/problems/problems/legacy-resource'; +import { HomeAssistant } from '../../../../src/ha/types'; +import { createCardAPI, createHASS, createUser } from '../../../test-utils'; + +const setupHASSResources = ( + hass: HomeAssistant, + resources: { id: string; type: string; url: string }[], +): void => { + vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); + vi.mocked(hass.callWS).mockResolvedValue(resources); +}; + +describe('LegacyResourceProblem', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should have correct key', () => { + const problem = new LegacyResourceProblem(vi.fn()); + expect(problem.key).toBe('legacy_resource'); + }); + + describe('detectStatic', () => { + it('should skip non-admin users', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: false })); + + await problem.detectStatic(hass); + + expect(problem.hasResult()).toBe(false); + }); + + it('should detect legacy resource regardless of directory', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: true })); + setupHASSResources(hass, [ + { + id: '1', + type: 'module', + url: '/some/arbitrary/path/frigate-hass-card.js?v=1', + }, + ]); + + await problem.detectStatic(hass); + + expect(problem.hasResult()).toBe(true); + }); + + it('should not detect when only advanced-camera-card exists', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: true })); + setupHASSResources(hass, [ + { + id: '1', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + + await problem.detectStatic(hass); + + expect(problem.hasResult()).toBe(false); + }); + + it('should handle invalid resource data', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: true })); + vi.mocked(hass.callWS).mockResolvedValue('not-an-array'); + + await problem.detectStatic(hass); + + expect(problem.hasResult()).toBe(false); + }); + + it('should handle websocket failure', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: true })); + vi.mocked(hass.callWS).mockRejectedValue(new Error('connection lost')); + + await problem.detectStatic(hass); + + expect(problem.hasResult()).toBe(false); + }); + + it('should handle missing user', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(); + Object.defineProperty(hass, 'user', { value: undefined }); + + await problem.detectStatic(hass); + + expect(problem.hasResult()).toBe(false); + }); + }); + + describe('getResult', () => { + it('should return controls and link when both resources exist', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: true })); + setupHASSResources(hass, [ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js', + }, + { + id: '2', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + + await problem.detectStatic(hass); + + const result = problem.getResult(); + expect(result).not.toBeNull(); + expect(result?.notification.controls).toHaveLength(1); + expect(result?.notification.link).toBeDefined(); + }); + + it('should return link without controls when only legacy exists', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: true })); + setupHASSResources(hass, [ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js', + }, + ]); + + await problem.detectStatic(hass); + + const result = problem.getResult(); + expect(result).not.toBeNull(); + expect(result?.notification.link).toBeDefined(); + expect(result?.notification.controls).toBeUndefined(); + }); + + it('should return null when no result', () => { + const problem = new LegacyResourceProblem(vi.fn()); + expect(problem.getResult()).toBeNull(); + }); + }); + + describe('fix', () => { + it('should remove legacy resources when correct resource exists', async () => { + const triggerUpdate = vi.fn(); + const problem = new LegacyResourceProblem(triggerUpdate); + const hass = createHASS(undefined, createUser({ is_admin: true })); + vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); + + vi.mocked(hass.callWS).mockResolvedValueOnce([ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js', + }, + { + id: '2', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + await problem.detectStatic(hass); + + vi.mocked(hass.callWS) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([ + { + id: '2', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + + const result = await problem.fix(hass); + + expect(result).toBe(true); + expect(hass.callWS).toBeCalledWith( + expect.objectContaining({ + type: 'lovelace/resources/delete', + resource_id: '1', + }), + ); + expect(problem.hasResult()).toBe(false); + expect(triggerUpdate).toBeCalled(); + }); + + it('should not fix when only legacy resource exists', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: true })); + setupHASSResources(hass, [ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js', + }, + ]); + + await problem.detectStatic(hass); + + const result = await problem.fix(hass); + expect(result).toBe(false); + }); + + it('should not fix for non-admin', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: false })); + + const result = await problem.fix(hass); + expect(result).toBe(false); + }); + + it('should return false on websocket failure during fix', async () => { + const triggerUpdate = vi.fn(); + const problem = new LegacyResourceProblem(triggerUpdate); + const hass = createHASS(undefined, createUser({ is_admin: true })); + vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); + vi.mocked(hass.callWS).mockResolvedValueOnce([ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js', + }, + { + id: '2', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + + await problem.detectStatic(hass); + + vi.mocked(hass.callWS).mockRejectedValue(new Error('connection lost')); + + const result = await problem.fix(hass); + expect(result).toBe(false); + expect(triggerUpdate).not.toBeCalled(); + }); + + it('should return false when re-detection still finds legacy resource', async () => { + const triggerUpdate = vi.fn(); + const problem = new LegacyResourceProblem(triggerUpdate); + const hass = createHASS(undefined, createUser({ is_admin: true })); + vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); + + vi.mocked(hass.callWS).mockResolvedValueOnce([ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js', + }, + { + id: '2', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + await problem.detectStatic(hass); + + // Delete succeeds, but re-detection still finds the legacy resource. + vi.mocked(hass.callWS) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js', + }, + { + id: '2', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + + const result = await problem.fix(hass); + + expect(result).toBe(false); + expect(triggerUpdate).not.toBeCalled(); + }); + + it('should fix multiple legacy resources', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: true })); + vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); + vi.mocked(hass.callWS).mockResolvedValueOnce([ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js', + }, + { id: '3', type: 'module', url: '/local/frigate-hass-card.js' }, + { + id: '2', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + + await problem.detectStatic(hass); + + vi.mocked(hass.callWS) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([ + { + id: '2', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + + expect(await problem.fix(hass)).toBe(true); + }); + }); + + describe('getResourcePath fallback', () => { + it('should handle invalid URLs by stripping query string', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: true })); + vi.mocked(hass.hassUrl).mockReturnValue('not-a-valid-url'); + vi.mocked(hass.callWS).mockResolvedValue([ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js?v=1', + }, + ]); + + await problem.detectStatic(hass); + + expect(problem.hasResult()).toBe(true); + }); + + it('should handle invalid URLs without query string', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: true })); + vi.mocked(hass.hassUrl).mockReturnValue('not-a-valid-url'); + vi.mocked(hass.callWS).mockResolvedValue([ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js', + }, + ]); + + await problem.detectStatic(hass); + + expect(problem.hasResult()).toBe(true); + }); + }); + + describe('callback action', () => { + const getCallback = ( + problem: LegacyResourceProblem, + ): ((api: unknown) => Promise) | null => { + const result = problem.getResult(); + const action = result?.notification.controls?.[0]?.actions?.tap_action; + if (action && 'callback' in action) { + return (action as { callback: (api: unknown) => Promise }).callback; + } + return null; + }; + + it('should call fix via the notification control action', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: true })); + vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); + vi.mocked(hass.callWS).mockResolvedValueOnce([ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js', + }, + { + id: '2', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + + await problem.detectStatic(hass); + + const callback = getCallback(problem); + expect(callback).toBeDefined(); + + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + + vi.mocked(hass.callWS) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce([ + { + id: '2', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + + await callback?.(api); + + expect(hass.callWS).toBeCalledWith( + expect.objectContaining({ + type: 'lovelace/resources/delete', + }), + ); + }); + + it('should handle missing hass in callback', async () => { + const problem = new LegacyResourceProblem(vi.fn()); + const hass = createHASS(undefined, createUser({ is_admin: true })); + vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123'); + vi.mocked(hass.callWS).mockResolvedValueOnce([ + { + id: '1', + type: 'module', + url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js', + }, + { + id: '2', + type: 'module', + url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js', + }, + ]); + + await problem.detectStatic(hass); + + const callback = getCallback(problem); + expect(callback).toBeDefined(); + + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); + + await callback?.(api); + }); + }); +}); diff --git a/tests/card-controller/problems/problems/stream-not-loading.test.ts b/tests/card-controller/problems/problems/stream-not-loading.test.ts new file mode 100644 index 00000000..ca415113 --- /dev/null +++ b/tests/card-controller/problems/problems/stream-not-loading.test.ts @@ -0,0 +1,311 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { StreamNotLoadingProblem } from '../../../../src/card-controller/problems/problems/stream-not-loading'; + +// @vitest-environment jsdom +describe('StreamNotLoadingProblem', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should have correct key', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + expect(problem.key).toBe('stream_not_loading'); + }); + + describe('detectDynamic', () => { + it('should start timer when live and not loaded', () => { + const triggerUpdate = vi.fn(); + const problem = new StreamNotLoadingProblem(triggerUpdate); + + problem.detectDynamic({ view: 'live', mediaLoaded: false }); + + expect(problem.hasResult()).toBe(false); + + vi.advanceTimersByTime(10000); + + expect(problem.hasResult()).toBe(true); + expect(triggerUpdate).toBeCalled(); + }); + + it('should not start timer when not live', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + problem.detectDynamic({ view: 'media', mediaLoaded: false }); + + vi.advanceTimersByTime(10000); + + expect(problem.hasResult()).toBe(false); + }); + + it('should not start timer when media is loaded', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + problem.detectDynamic({ view: 'live', mediaLoaded: true }); + + vi.advanceTimersByTime(10000); + + expect(problem.hasResult()).toBe(false); + }); + + it('should clear timeout when media loads', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + problem.detectDynamic({ view: 'live', mediaLoaded: false }); + vi.advanceTimersByTime(5000); + + problem.detectDynamic({ view: 'live', mediaLoaded: true }); + + vi.advanceTimersByTime(5000); + + expect(problem.hasResult()).toBe(false); + }); + + it('should clear timeout when view changes away from live', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + problem.detectDynamic({ view: 'live', mediaLoaded: false }); + vi.advanceTimersByTime(5000); + + problem.detectDynamic({ view: 'media', mediaLoaded: false }); + + vi.advanceTimersByTime(5000); + + expect(problem.hasResult()).toBe(false); + }); + + it('should clear timed-out state when media loads', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + problem.detectDynamic({ view: 'live', mediaLoaded: false }); + vi.advanceTimersByTime(10000); + expect(problem.hasResult()).toBe(true); + + problem.detectDynamic({ view: 'live', mediaLoaded: true }); + expect(problem.hasResult()).toBe(false); + }); + + it('should restart timer when camera changes', () => { + const triggerUpdate = vi.fn(); + const problem = new StreamNotLoadingProblem(triggerUpdate); + + problem.detectDynamic({ + cameraID: 'camera-1', + view: 'live', + mediaLoaded: false, + }); + vi.advanceTimersByTime(5000); + + // Switch to camera-2: timer restarts from 0 for the new camera. + problem.detectDynamic({ + cameraID: 'camera-2', + view: 'live', + mediaLoaded: false, + }); + + // 5 more seconds is not enough for the new 10s timer. + vi.advanceTimersByTime(5000); + expect(problem.hasResult()).toBe(false); + + // Full 10s from camera-2's timer start. + vi.advanceTimersByTime(5000); + expect(problem.hasResult()).toBe(true); + expect(triggerUpdate).toBeCalledTimes(1); + }); + + it('should not restart timer for same camera while running', () => { + const triggerUpdate = vi.fn(); + const problem = new StreamNotLoadingProblem(triggerUpdate); + + problem.detectDynamic({ + cameraID: 'camera-1', + view: 'live', + mediaLoaded: false, + }); + vi.advanceTimersByTime(5000); + + // Same camera again: timer should continue, not restart. + problem.detectDynamic({ + cameraID: 'camera-1', + view: 'live', + mediaLoaded: false, + }); + + // 5 more seconds completes the original 10s timer. + vi.advanceTimersByTime(5000); + expect(problem.hasResult()).toBe(true); + expect(triggerUpdate).toBeCalledTimes(1); + }); + + it('should not restart timer when cameraID is undefined and matches', () => { + const triggerUpdate = vi.fn(); + const problem = new StreamNotLoadingProblem(triggerUpdate); + + problem.detectDynamic({ view: 'live', mediaLoaded: false }); + vi.advanceTimersByTime(5000); + + // Same undefined cameraID: timer should continue. + problem.detectDynamic({ view: 'live', mediaLoaded: false }); + + vi.advanceTimersByTime(5000); + expect(problem.hasResult()).toBe(true); + expect(triggerUpdate).toBeCalledTimes(1); + }); + + it('should not restart timer if already timed out', () => { + const triggerUpdate = vi.fn(); + const problem = new StreamNotLoadingProblem(triggerUpdate); + + problem.detectDynamic({ view: 'live', mediaLoaded: false }); + vi.advanceTimersByTime(10000); + expect(triggerUpdate).toBeCalledTimes(1); + + // Calling detectDynamic again should not restart timer. + problem.detectDynamic({ view: 'live', mediaLoaded: false }); + vi.advanceTimersByTime(10000); + expect(triggerUpdate).toBeCalledTimes(1); + }); + }); + + describe('trigger', () => { + it('should activate immediately when camera has error and view is live', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + problem.trigger({ cameraID: 'camera-1' }); + problem.detectDynamic({ + cameraID: 'camera-1', + view: 'live', + mediaLoaded: false, + }); + + expect(problem.hasResult()).toBe(true); + }); + + it('should not activate with only a trigger', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + problem.trigger({ cameraID: 'camera-1' }); + + expect(problem.hasResult()).toBe(false); + }); + + it('should ignore trigger without cameraID', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + problem.trigger(); + problem.detectDynamic({ view: 'live', mediaLoaded: false }); + + // No camera error recorded, so falls back to timeout behavior. + expect(problem.hasResult()).toBe(false); + }); + + it('should clear camera error when stream loads', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + problem.trigger({ cameraID: 'camera-1' }); + problem.detectDynamic({ + cameraID: 'camera-1', + view: 'live', + mediaLoaded: false, + }); + + expect(problem.hasResult()).toBe(true); + + // Stream loaded clears the error for this camera. + problem.detectDynamic({ + cameraID: 'camera-1', + view: 'live', + mediaLoaded: true, + }); + + // Camera error was cleared by the successful load, so this unloaded state + // falls back to the timer (problem would not activate until after the + // timer is reached). + problem.detectDynamic({ + cameraID: 'camera-1', + view: 'live', + mediaLoaded: false, + }); + + expect(problem.hasResult()).toBe(false); + }); + + it('should not activate for a different camera', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + problem.trigger({ cameraID: 'camera-1' }); + problem.detectDynamic({ + cameraID: 'camera-2', + view: 'live', + mediaLoaded: false, + }); + + // camera-2 has no error, so it falls back to timeout behavior. + expect(problem.hasResult()).toBe(false); + }); + }); + + describe('getNotification', () => { + it('should return notification regardless of active state', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + const notification = problem.getNotification(); + expect(notification).toEqual( + expect.objectContaining({ + heading: expect.objectContaining({ + text: expect.any(String), + }), + link: expect.objectContaining({ + url: expect.any(String), + }), + }), + ); + }); + }); + + describe('getResult', () => { + it('should return result when timed out', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + problem.detectDynamic({ view: 'live', mediaLoaded: false }); + vi.advanceTimersByTime(10000); + + const result = problem.getResult(); + expect(result).toEqual( + expect.objectContaining({ + icon: 'mdi:cctv-off', + severity: 'high', + notification: expect.objectContaining({ + link: expect.objectContaining({ + url: expect.any(String), + }), + }), + }), + ); + }); + + it('should return null when not timed out', () => { + const problem = new StreamNotLoadingProblem(vi.fn()); + + expect(problem.getResult()).toBeNull(); + }); + }); + + describe('destroy', () => { + it('should stop timer', () => { + const triggerUpdate = vi.fn(); + const problem = new StreamNotLoadingProblem(triggerUpdate); + + problem.detectDynamic({ view: 'live', mediaLoaded: false }); + problem.destroy(); + + vi.advanceTimersByTime(10000); + + expect(problem.hasResult()).toBe(false); + expect(triggerUpdate).not.toBeCalled(); + }); + }); +}); diff --git a/tests/card-controller/status-bar-item-manager.test.ts b/tests/card-controller/status-bar-item-manager.test.ts index 24cdc587..9574ac15 100644 --- a/tests/card-controller/status-bar-item-manager.test.ts +++ b/tests/card-controller/status-bar-item-manager.test.ts @@ -241,12 +241,28 @@ describe('StatusBarItemManager', () => { }); }); - describe('upgrade', () => { - it('should show upgrade item when upgradeable', () => { + describe('problems', () => { + it('should show problem items', () => { const manager = new StatusBarItemManager(createCardAPI()); const items = manager.calculateItems({ - isUpgradeable: true, + problems: [ + { + key: 'config_upgrade', + problem: { + icon: 'mdi:update', + severity: 'medium', + notification: { + heading: { + text: 'Upgrade available', + icon: 'mdi:update', + severity: 'medium', + }, + text: 'Upgrade text', + }, + }, + }, + ], }); expect(items).toContainEqual( @@ -254,6 +270,7 @@ describe('StatusBarItemManager', () => { type: 'custom:advanced-camera-card-status-bar-icon' as const, icon: 'mdi:update', severity: 'medium', + title: 'Upgrade available', actions: expect.objectContaining({ tap_action: expect.objectContaining({ action: 'fire-dom-event', @@ -264,11 +281,11 @@ describe('StatusBarItemManager', () => { ); }); - it('should not show upgrade item when not upgradeable', () => { + it('should not show problem items when empty', () => { const manager = new StatusBarItemManager(createCardAPI()); const items = manager.calculateItems({ - isUpgradeable: false, + problems: [], }); expect(items).not.toContainEqual( @@ -278,7 +295,7 @@ describe('StatusBarItemManager', () => { ); }); - it('should not show upgrade item by default', () => { + it('should not show problem items by default', () => { const manager = new StatusBarItemManager(createCardAPI()); const items = manager.calculateItems(); @@ -289,6 +306,99 @@ describe('StatusBarItemManager', () => { }), ); }); + + it('should filter out disabled problems', () => { + const manager = new StatusBarItemManager(createCardAPI()); + + const items = manager.calculateItems({ + statusConfig: { + position: 'bottom', + style: 'popup', + popup_seconds: 3, + height: 40, + items: { + engine: { enabled: true, priority: 50 }, + resolution: { enabled: true, priority: 50 }, + severity: { enabled: true, priority: 50 }, + technology: { enabled: true, priority: 50 }, + title: { enabled: true, priority: 50 }, + problem_config_upgrade: { enabled: false, priority: 50 }, + problem_legacy_resource: { enabled: true, priority: 50 }, + problem_stream_not_loading: { enabled: true, priority: 50 }, + }, + }, + problems: [ + { + key: 'config_upgrade', + problem: { + icon: 'mdi:update', + severity: 'medium', + notification: { + heading: { + text: 'Upgrade available', + icon: 'mdi:update', + severity: 'medium', + }, + text: 'Upgrade text', + }, + }, + }, + ], + }); + + expect(items).not.toContainEqual( + expect.objectContaining({ + icon: 'mdi:update', + }), + ); + }); + + it('should apply config overrides to problem items', () => { + const manager = new StatusBarItemManager(createCardAPI()); + + const items = manager.calculateItems({ + statusConfig: { + position: 'bottom', + style: 'popup', + popup_seconds: 3, + height: 40, + items: { + engine: { enabled: true, priority: 50 }, + resolution: { enabled: true, priority: 50 }, + severity: { enabled: true, priority: 50 }, + technology: { enabled: true, priority: 50 }, + title: { enabled: true, priority: 50 }, + problem_config_upgrade: { enabled: true, priority: 90 }, + problem_legacy_resource: { enabled: true, priority: 50 }, + problem_stream_not_loading: { enabled: true, priority: 50 }, + }, + }, + problems: [ + { + key: 'config_upgrade', + problem: { + icon: 'mdi:update', + severity: 'medium', + notification: { + heading: { + text: 'Upgrade available', + icon: 'mdi:update', + severity: 'medium', + }, + text: 'Upgrade text', + }, + }, + }, + ], + }); + + expect(items).toContainEqual( + expect.objectContaining({ + icon: 'mdi:update', + priority: 90, + }), + ); + }); }); describe('severity', () => { diff --git a/tests/components-lib/message/controller.test.ts b/tests/components-lib/message/controller.test.ts index 066db926..ff740dc0 100644 --- a/tests/components-lib/message/controller.test.ts +++ b/tests/components-lib/message/controller.test.ts @@ -1,9 +1,10 @@ import yaml from 'js-yaml'; import { describe, expect, it } from 'vitest'; import { MessageController } from '../../../src/components-lib/message/controller'; +import { Link } from '../../../src/config/schema/common/link'; import { TROUBLESHOOTING_URL } from '../../../src/const'; import { localize } from '../../../src/localize/localize'; -import { Message, MessageType, MessageURL } from '../../../src/types'; +import { Message, MessageType } from '../../../src/types'; describe('MessageController', () => { describe('should return the correct message string', () => { @@ -65,8 +66,8 @@ describe('MessageController', () => { it('should show for errors', () => { const controller = new MessageController(); const message: Message = { message: 'Error message', type: 'error' }; - expect(controller.getURL(message)).toEqual({ - link: TROUBLESHOOTING_URL, + expect(controller.getLink(message)).toEqual({ + url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting'), }); }); @@ -81,7 +82,7 @@ describe('MessageController', () => { icon: 'mdi:car', type, }; - expect(controller.getURL(message)).toBeNull(); + expect(controller.getLink(message)).toBeNull(); }, ); }); @@ -90,21 +91,21 @@ describe('MessageController', () => { it('by default', () => { const controller = new MessageController(); const message: Message = { message: 'Error message', type: 'error' }; - expect(controller.getURL(message)?.link).toBe(TROUBLESHOOTING_URL); + expect(controller.getLink(message)?.url).toBe(TROUBLESHOOTING_URL); }); it('when specified', () => { const controller = new MessageController(); - const url: MessageURL = { - link: 'link', + const url: Link = { + url: 'link', title: 'title', }; const message: Message = { message: 'Error message', type: 'error', - url, + link: url, }; - expect(controller.getURL(message)).toBe(url); + expect(controller.getLink(message)).toBe(url); }); }); }); diff --git a/tests/conditions/conditions-manager.test.ts b/tests/conditions/conditions-manager.test.ts index e5cd6d27..11abc8c8 100644 --- a/tests/conditions/conditions-manager.test.ts +++ b/tests/conditions/conditions-manager.test.ts @@ -23,12 +23,12 @@ describe('ConditionsManager', () => { it('should match named view change', () => { const stateManager = new ConditionStateManager(); const manager = new ConditionsManager( - [{ condition: 'view' as const, views: ['foo'] }], + [{ condition: 'view' as const, views: ['live'] }], stateManager, ); expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ view: 'foo' }); + stateManager.setState({ view: 'live' }); expect(manager.getEvaluation().result).toBeTruthy(); }); @@ -1211,13 +1211,13 @@ describe('ConditionsManager', () => { camera: { to: 'camera-1' }, }); - stateManager.setState({ view: 'view-1' }); + stateManager.setState({ view: 'live' }); expect(manager.getEvaluation().result).toBeTruthy(); expect(manager.getEvaluation().triggerData).toEqual({ - view: { to: 'view-1' }, + view: { to: 'live' }, }); - stateManager.setState({ camera: 'camera-2', view: 'view-2' }); + stateManager.setState({ camera: 'camera-2', view: 'clip' }); expect(manager.getEvaluation().result).toBeTruthy(); expect(manager.getEvaluation().triggerData).toEqual({ camera: { to: 'camera-2', from: 'camera-1' }, @@ -1282,17 +1282,17 @@ describe('ConditionsManager', () => { stateManager.setState({ camera: 'camera-1' }); expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ view: 'view-1' }); + stateManager.setState({ view: 'live' }); expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ camera: 'camera-2', view: 'view-2' }); + stateManager.setState({ camera: 'camera-2', view: 'clip' }); expect(manager.getEvaluation().result).toBeTruthy(); expect(manager.getEvaluation().triggerData).toEqual({ camera: { from: 'camera-1', to: 'camera-2' }, - view: { from: 'view-1', to: 'view-2' }, + view: { from: 'live', to: 'clip' }, }); - stateManager.setState({ view: 'view-3' }); + stateManager.setState({ view: 'snapshot' }); expect(manager.getEvaluation().result).toBeFalsy(); }); @@ -1404,26 +1404,26 @@ describe('ConditionsManager', () => { it('with not call listeners when condition result does not change', () => { const stateManager = new ConditionStateManager(); const manager = new ConditionsManager( - [{ condition: 'view' as const, views: ['foo'] }], + [{ condition: 'view' as const, views: ['live'] }], stateManager, ); const listener = vi.fn(); manager.addListener(listener); - stateManager.setState({ view: 'foo' }); + stateManager.setState({ view: 'live' }); expect(listener).toBeCalledTimes(1); - stateManager.setState({ view: 'bar' }); + stateManager.setState({ view: 'clip' }); expect(listener).toBeCalledTimes(2); - stateManager.setState({ view: 'bar' }); + stateManager.setState({ view: 'clip' }); expect(listener).toBeCalledTimes(2); - stateManager.setState({ view: 'foo' }); + stateManager.setState({ view: 'live' }); expect(listener).toBeCalledTimes(3); - stateManager.setState({ view: 'foo' }); + stateManager.setState({ view: 'live' }); expect(listener).toBeCalledTimes(3); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 02953e9c..8673a05c 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -441,7 +441,15 @@ describe('config defaults', () => { enabled: true, priority: 50, }, - upgrade: { + problem_config_upgrade: { + enabled: true, + priority: 50, + }, + problem_legacy_resource: { + enabled: true, + priority: 50, + }, + problem_stream_not_loading: { enabled: true, priority: 50, }, diff --git a/tests/templates/index.test.ts b/tests/templates/index.test.ts index 226fd84a..105ce572 100644 --- a/tests/templates/index.test.ts +++ b/tests/templates/index.test.ts @@ -44,7 +44,7 @@ describe('TemplateRenderer', () => { it('should include triggers', () => { const conditionState: ConditionState = { camera: 'camera', - view: 'view', + view: 'live', }; const triggerData: ConditionsTriggerData = { camera: { @@ -70,7 +70,7 @@ describe('TemplateRenderer', () => { expect(renderTemplate).toHaveBeenCalledWith(hass, 'value', { acc: { camera: 'camera', - view: 'view', + view: 'live', trigger: { camera: { to: 'camera', @@ -80,7 +80,7 @@ describe('TemplateRenderer', () => { }, advanced_camera_card: { camera: 'camera', - view: 'view', + view: 'live', trigger: { camera: { to: 'camera', diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 57574008..e964f207 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -47,6 +47,7 @@ import { MessageManager } from '../src/card-controller/message-manager'; import { MicrophoneManager } from '../src/card-controller/microphone-manager'; import { NotificationManager } from '../src/card-controller/notification-manager'; import { PIPManager } from '../src/card-controller/pip-manager'; +import { ProblemManager } from '../src/card-controller/problems/manager'; import { QueryStringManager } from '../src/card-controller/query-string-manager'; import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager'; import { StyleManager } from '../src/card-controller/style-manager'; @@ -680,6 +681,7 @@ export const createCardAPI = (): CardController => { api.getMicrophoneManager.mockReturnValue(mock()); api.getNotificationManager.mockReturnValue(mock()); api.getPIPManager.mockReturnValue(mock()); + api.getProblemManager.mockReturnValue(mock()); api.getQueryStringManager.mockReturnValue(mock()); api.getStatusBarItemManager.mockReturnValue(mock()); api.getStyleManager.mockReturnValue(mock()); diff --git a/tests/utils/diagnostics.test.ts b/tests/utils/diagnostics.test.ts index 41d5c16d..656fef6a 100644 --- a/tests/utils/diagnostics.test.ts +++ b/tests/utils/diagnostics.test.ts @@ -151,6 +151,25 @@ describe('getDiagnostics', () => { }); }); + it('should include problems in diagnostics', async () => { + const deviceRegistryManager = mock(); + deviceRegistryManager.getMatchingDevices.mockResolvedValue([]); + + const problems = { + config_upgrade: true, + legacy_resource: false, + }; + + const result = await getDiagnostics( + hass, + deviceRegistryManager, + { cameras: [{ camera_entity: 'camera.office' }] }, + problems, + ); + + expect(result.problems).toEqual(problems); + }); + it('should fetch diagnostics without device model', async () => { const deviceRegistryManager = mock(); deviceRegistryManager.getMatchingDevices.mockResolvedValue([]);