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:
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user