feat: add problem detection framework for common problems (#2412)

Introduces a ProblemManager that detects and surfaces actionable issues
(stale config, legacy frigate-hass-card resources, slow/failed streams)
via status bar indicators and notification popups with fix actions.
This commit is contained in:
Dermot Duffy
2026-03-13 20:00:59 -07:00
committed by GitHub
parent 95faddd9a0
commit ab683df5e8
46 changed files with 2147 additions and 166 deletions
@@ -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
+21 -13
View File
@@ -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
```
+34 -11
View File
@@ -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
@@ -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();
+1 -7
View File
@@ -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(
+8
View File
@@ -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;
}
+25 -1
View File
@@ -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<void> {
await this._initializationQueue.add(() => this._initializeBackground());
}
private async _initializeBackground(): Promise<void> {
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);
}
+130
View File
@@ -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<ProblemKey, Problem>();
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<void> {
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();
}
}
}
@@ -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<void> {
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'),
},
},
};
}
}
@@ -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<void> {
// 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<boolean> {
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;
}
}
}
@@ -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<string>();
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);
}
}
+57
View File
@@ -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<Record<ProblemKey, boolean>>;
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<void>;
// 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<boolean>;
// Cleanup.
destroy?(): void;
}
+22 -22
View File
@@ -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,
];
}
+11 -1
View File
@@ -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;
+15 -2
View File
@@ -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}
></advanced-camera-card-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<ProblemKey>) =>
this._controller.getProblemManager().forceNotify(ev.detail)}
@advanced-camera-card:problem:trigger=${({
detail: { key, ...context },
}: CustomEvent<ProblemTriggerEventData>) =>
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()}
></advanced-camera-card-views>
${this._controller.getMessageManager().hasMessage()
? // Keep message rendering to last to show messages that may have been
+6 -5
View File
@@ -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;
}
+5
View File
@@ -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<TemplateResult> {
const diagnostics = await getDiagnostics(
this.hass,
this.deviceRegistryManager,
this.rawConfig,
this.problems,
);
return renderMessage({
+23 -30
View File
@@ -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<MediaPlayerElement> = 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<MediaLoadedInfo>) => {
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)}
>
</advanced-camera-card-live-ha>`
: 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)}
>
</advanced-camera-card-live-go2rtc>`
: 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)}
>
</advanced-camera-card-live-webrtc-card>`
: 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)}
>
</advanced-camera-card-live-jsmpeg>`
: html``}
@@ -364,24 +372,9 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
? html`<advanced-camera-card-icon
title=${localize('error.awaiting_live')}
.icon=${{ icon: 'mdi:progress-helper' }}
@click=${() => {
this._showStreamTroubleshooting = !this._showStreamTroubleshooting;
}}
@click=${() =>
fireAdvancedCameraCardEvent(this, 'problem:notify', 'stream_not_loading')}
></advanced-camera-card-icon>`
: ''}
${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 },
)
: ''}`;
}
+2 -2
View File
@@ -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`. <a href="${url.link}">${url.title}</a>` : ''}
${link ? html`. <a href="${link.url}">${link.title}</a>` : ''}
`;
const icon = this._controller.getIcon(this.message);
+11
View File
@@ -61,6 +61,17 @@ export class AdvancedCameraCardNotification extends LitElement {
${heading ? this._renderDetail(heading, true) : ''}
${details.map((detail) => this._renderDetail(detail))}
${text ? html`<div class="description">${text}</div>` : ''}
${this.notification.link
? html`<div class="url">
<a
href=${this.notification.link.url}
target="_blank"
rel="noopener noreferrer"
@click=${stopEventFromActivatingCardWideActions}
>${this.notification.link.title}</a
>
</div>`
: ''}
</div>
${controls.length
? html`<div class="controls">
+4
View File
@@ -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`<div
.actionHandler=${handler}
class="${classes}"
title=${item.title ?? nothing}
data-severity=${item.severity ?? ''}
@action=${(ev) => 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)}
></advanced-camera-card-icon>`;
@@ -118,6 +121,7 @@ export class AdvancedCameraCardStatusBar extends LitElement {
return html`<img
.actionHandler=${handler}
class="${classes}"
title=${item.title ?? nothing}
src="${item.image}"
data-severity=${item.severity ?? ''}
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
+5
View File
@@ -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}
>
</advanced-camera-card-diagnostics>`
: ``}
+2 -1
View File
@@ -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<string>;
userAgent?: string;
view?: string;
view?: AdvancedCameraCardView;
}
export interface ConditionStateChange {
+3
View File
@@ -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<typeof notificationSchema>;
@@ -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(),
});
+7
View File
@@ -0,0 +1,7 @@
import { z } from 'zod';
export const linkSchema = z.object({
url: z.string(),
title: z.string(),
});
export type Link = z.infer<typeof linkSchema>;
+19 -5
View File
@@ -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),
})
+5 -1
View File
@@ -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`;
+2 -2
View File
@@ -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'),
},
})}
+13 -3
View File
@@ -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": {
+18
View File
@@ -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;
}
}
}
+2 -5
View File
@@ -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 & {
+4
View File
@@ -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<Diagnostics> => {
// 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 }),
};
};
@@ -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', () => {
+8
View File
@@ -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],
@@ -215,6 +215,65 @@ describe('InitializationManager', () => {
});
});
it('should report background initialization status', () => {
const initializer = mock<Initializer>();
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<Initializer>();
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>();
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>();
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<Initializer>();
const manager = new InitializationManager(createCardAPI(), initializer);
@@ -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>): 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<Problem>({ key: 'config_upgrade' });
mockLegacyResource = mock<Problem>({ key: 'legacy_resource' });
mockStreamNotLoading = mock<Problem>({ 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();
});
});
});
@@ -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',
}),
}),
}),
);
});
});
@@ -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<void>) | 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<void> }).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);
});
});
});
@@ -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();
});
});
});
@@ -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', () => {
@@ -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);
});
});
});
+15 -15
View File
@@ -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);
});
});
+9 -1
View File
@@ -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,
},
+3 -3
View File
@@ -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',
+2
View File
@@ -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<MicrophoneManager>());
api.getNotificationManager.mockReturnValue(mock<NotificationManager>());
api.getPIPManager.mockReturnValue(mock<PIPManager>());
api.getProblemManager.mockReturnValue(mock<ProblemManager>());
api.getQueryStringManager.mockReturnValue(mock<QueryStringManager>());
api.getStatusBarItemManager.mockReturnValue(mock<StatusBarItemManager>());
api.getStyleManager.mockReturnValue(mock<StyleManager>());
+19
View File
@@ -151,6 +151,25 @@ describe('getDiagnostics', () => {
});
});
it('should include problems in diagnostics', async () => {
const deviceRegistryManager = mock<DeviceRegistryManager>();
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>();
deviceRegistryManager.getMatchingDevices.mockResolvedValue([]);