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
@@ -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;