Files
advanced-camera-card/src/card-controller/issues/issue-manager.ts
T

255 lines
9.2 KiB
TypeScript

import type { IssueTriggerContext } from 'issue';
import type { ConditionStateChange } from '../../condition-trigger/conditions/types';
import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode';
import { RetryTimer } from '../../utils/retry-timer';
import type { CardIssueManagerAPI } from '../types';
import { IssueStateManager } from './state-manager';
import type {
Issue,
IssueKey,
IssueReadOnlyState,
IssueTriggerContextKey,
} from './types';
// Exponential backoff schedule for 'auto' retry. The base is set above the
// per-media retry threshold (~10s) so the issue-level backoff kicks in *after*
// lower-level recovery has had a chance to work, not in parallel with it.
export const RETRY_EXPONENTIAL_BASE_SECONDS = 30;
export const RETRY_EXPONENTIAL_MAX_SECONDS = 600;
// Wraps the passive IssueStateManager with reaction logic. A single
// condition-state listener drives everything: it runs one-shot static detection
// when mandatory-init completes (`initialized` transitions to true), then
// evaluates dynamic issues on every subsequent state change, schedules retries,
// and updates the card. Full-card issues are rendered by card.ts via
// getStateManager().getFullCardIssue(). Non-full-card issue notifications are
// shown on demand via showNotification().
export class IssueManager {
private _api: CardIssueManagerAPI;
private _stateManager = new IssueStateManager();
private _retryTimer = new RetryTimer({
baseSeconds: RETRY_EXPONENTIAL_BASE_SECONDS,
maxSeconds: RETRY_EXPONENTIAL_MAX_SECONDS,
});
private _suspended = false;
// Reentrancy guard: evaluate() calls setState() on the condition state
// manager, which fires listeners synchronously -- including the one
// registered in this constructor. Without this guard, detectDynamic()
// and presence computation would run twice per evaluation.
private _evaluating = false;
constructor(api: CardIssueManagerAPI) {
this._api = api;
api.getConditionStateManager().addListener((change) => this._onStateChange(change));
}
// =========================================================================
// Setup.
// =========================================================================
public addIssue(issue: Issue): void {
this._stateManager.addIssue(issue);
}
public getStateManager(): IssueReadOnlyState {
return this._stateManager;
}
// =========================================================================
// Detection & reaction.
// =========================================================================
// Called by components that detect an issue directly (e.g. a provider
// error event), bypassing the condition-state polling loop.
public trigger<K extends IssueTriggerContextKey>(
key: K,
context: IssueTriggerContext[K],
): void {
this._stateManager.trigger(key, context);
this.evaluate();
}
// Evaluate all dynamic issues against current state, then react to any
// changes: notify, update condition state, and schedule retries.
//
// Detection of "anything changed" is delegated to the condition state
// manager: IssuePresence is a Map<IssueKey, IssueDescription>, so its
// deep equality check naturally catches both presence-set churn (issues
// appearing/disappearing) and content-level churn (an issue swapping
// sub-states without changing its key, e.g. ConnectionIssue going from
// 'lost' to 'starting').
public evaluate(): void {
if (this._suspended || this._evaluating) {
return;
}
this._evaluating = true;
try {
const state = this._api.getConditionStateManager().getState();
this._stateManager.detectDynamic(state);
if (
this._api.getConditionStateManager().setState({
issues: this._stateManager.getIssuePresence(),
})
) {
// Re-render to show the change. The re-render also re-attempts
// initialization, which matters when a blocking notice like "Home
// Assistant is starting" clears and the card can finally initialize.
this._api.getCardElementManager().update();
}
this._scheduleRetryIfNeeded();
} finally {
this._evaluating = false;
}
}
// Attempts a retry for the given issue. Pass `force = true` for user-
// initiated retries (e.g. clicking the retry button on a notification): it
// bypasses the `needsRetry()` gate that scheduled auto-retries must
// respect, so even an issue that doesn't currently want a retry will run
// its `retry()` method. Also stops the pending auto-retry timer so the
// user action resets the backoff schedule.
public retry(key: IssueKey, force?: boolean): void {
this._stateManager.retry(key, force);
this._retryTimer.reset();
this.evaluate();
}
// Show the notification for an issue on demand (e.g. user clicks a loading
// icon) regardless of whether the issue is currently active.
public showNotification(key: IssueKey): void {
const notification = this._stateManager.getNotification(key);
if (notification) {
this._api.getNotificationManager().setNotification(notification);
}
}
// =========================================================================
// Lifecycle.
// =========================================================================
public reset(key?: IssueKey): void {
// When resetting a specific key that has no active issue, skip the
// reset+evaluate cycle entirely to avoid unnecessary work.
if (key && !this._stateManager.getIssuePresence().has(key)) {
return;
}
this._stateManager.reset(key);
this.evaluate();
}
// Gate evaluation while the card is detached so timers don't arm or mature
// offscreen. Issue state is preserved (including full-card issues like
// config_error). Issue-internal timers are stopped via Issue.suspend so
// offscreen time doesn't count against age-based thresholds (e.g. media
// loading timeout). Evaluation resumes on resume().
public suspend(): void {
this._suspended = true;
this._retryTimer.cancel();
this._stateManager.suspend();
}
public resume(): void {
this._suspended = false;
this.evaluate();
}
public destroy(): void {
this._retryTimer.cancel();
this._stateManager.destroy();
}
// =========================================================================
// Private helpers.
// =========================================================================
// Drives both one-shot static detection (on mandatory-init completion) and
// normal re-evaluation (on any condition-state change).
//
// `initialized: true` in the change payload means mandatory initialization
// just finished -- see InitializationManager._initializeMandatory. That's
// also the earliest point at which the full HASS object is guaranteed
// ready for websocket calls (e.g. LegacyResourceIssue's lovelace/resources
// fetch). Because `initialized` is latched (its comment notes it never
// changes again), this block fires exactly once per IssueManager life.
private _onStateChange(change: ConditionStateChange): void {
if (change.change.initialized === true && change.new.hass) {
void this._stateManager.detectStatic(change.new.hass).then(() => this.evaluate());
}
this.evaluate();
}
private _scheduleRetryIfNeeded(): void {
if (!this._stateManager.needsRetry()) {
this._retryTimer.reset();
return;
}
if (this._retryTimer.isRunning()) {
return;
}
const config = this._api.getConfigManager().getConfig();
if (!config) {
this._retryTimer.reset();
return;
}
const retryConfig = config.view.issues.retry_seconds;
if (retryConfig === 0) {
this._retryTimer.reset();
return;
}
this._retryTimer.setOptions(
retryConfig === 'auto'
? {
baseSeconds: RETRY_EXPONENTIAL_BASE_SECONDS,
maxSeconds: RETRY_EXPONENTIAL_MAX_SECONDS,
}
: retryConfig,
);
// Schedule without advancing: the backoff only escalates if the retry
// actually runs (via the explicit advance() below), not when it's gated.
this._retryTimer.schedule(
() => {
if (!this._stateManager.needsRetry()) {
this._retryTimer.reset();
return;
}
if (this._isScheduledRetryAllowed()) {
this._stateManager.retry();
// This attempt counts: advance the backoff so the next schedule
// (re-armed by evaluate() via _scheduleRetryIfNeeded) uses a longer
// delay. For static-delay mode (base = max, no jitter) advancing is
// observable in `getAttempts()` but doesn't change the next delay.
this._retryTimer.advance();
this.evaluate();
} else {
// Retry was gated (e.g. user interaction). Not a failed attempt; the
// backoff stays put and we re-arm at the same delay.
this._scheduleRetryIfNeeded();
}
},
{ advance: false },
);
}
private _isScheduledRetryAllowed(): boolean {
const interactionMode = this._api.getConfigManager().getConfig()?.view
.issues.interaction_mode;
return (
!!interactionMode &&
isActionAllowedBasedOnInteractionState(
interactionMode,
this._api.getInteractionManager().hasInteraction(),
)
);
}
}