feat: Add hardened error handling and retries (#2451)
- Closes #1830 - Closes #2099
This commit is contained in:
committed by
dermotduffy
parent
4bc787e2b7
commit
47bcce93d3
@@ -362,13 +362,16 @@ advanced_camera_card_action: notification
|
||||
### `notification`
|
||||
|
||||
| Parameter | Description |
|
||||
| ---------- | ------------------------------------------------------------------------------------- |
|
||||
| `heading` | An optional heading. Uses the same format as [`details`](README.md?id=details) below. |
|
||||
| `text` | An optional text string to display as the notification body. |
|
||||
| `details` | An optional list of metadata. See [`details`](README.md?id=details) below. |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `heading` | An optional heading. Uses the same format as [`metadata`](README.md?id=metadata) below. |
|
||||
| `body` | An optional body. Uses the same format as [`metadata`](README.md?id=metadata) below. |
|
||||
| `metadata` | An optional list of metadata to show with the notification. See [`metadata`](README.md?id=metadata) below. |
|
||||
| `context` | An optional list of preformatted text strings shown as a technical detail block (e.g. YAML). |
|
||||
| `link` | An optional link displayed below the body. See [`link`](#link) below. |
|
||||
| `in_progress` | If `true`, shows a loading indicator. |
|
||||
| `controls` | An optional list of controls. See [`controls`](README.md?id=controls) below. |
|
||||
|
||||
### Details
|
||||
### Metadata
|
||||
|
||||
| Parameter | Description |
|
||||
| ---------- | ------------------------------------------------------------- |
|
||||
@@ -377,6 +380,13 @@ advanced_camera_card_action: notification
|
||||
| `tooltip` | An optional tooltip string (shown on hover). |
|
||||
| `severity` | An optional severity level, one of `low`, `medium` or `high`. |
|
||||
|
||||
### Link
|
||||
|
||||
| Parameter | Description |
|
||||
| --------- | ------------------------------------ |
|
||||
| `url` | The link URL. |
|
||||
| `title` | The link text displayed to the user. |
|
||||
|
||||
### Controls
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|
||||
@@ -29,11 +29,9 @@ status_bar:
|
||||
### Available Items
|
||||
|
||||
| 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. |
|
||||
| `issues` | Indicator icons that appear while any card issue is active — e.g. configuration error, configuration upgrade available, Home Assistant connection lost, camera initialization failed, legacy `frigate-hass-card` resource detected, media (live/recorded/image) not loading, media query failed, or view cannot be resolved. See the warning below about disabling this item. |
|
||||
| `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). |
|
||||
@@ -42,10 +40,17 @@ status_bar:
|
||||
### Options for each item
|
||||
|
||||
| Option | Default | Description |
|
||||
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| ----------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `enabled` | `true` | Whether or not to show the item. |
|
||||
| `permanent` | `true` for `issues`, `false` otherwise | When `true`, the status bar stays visible in `popup` mode as long as this item is present (instead of auto-hiding after `popup_seconds`). The `issues` item defaults to `true` so errors remain visible. |
|
||||
| `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`. |
|
||||
|
||||
> [!WARNING]
|
||||
> The status bar is the only UI surface for minor issues (i.e. that don't render
|
||||
> the card entirely inoperable). If you set `status_bar.style: none` or
|
||||
> `status_bar.items.issues.enabled: false`, these issues will not be visible to
|
||||
> you, except through card diagnostics.
|
||||
|
||||
## `style`
|
||||
|
||||
This card supports several menu styles.
|
||||
@@ -75,26 +80,26 @@ status_bar:
|
||||
items:
|
||||
engine:
|
||||
enabled: true
|
||||
permanent: false
|
||||
priority: 50
|
||||
problem_config_upgrade:
|
||||
enabled: true
|
||||
priority: 50
|
||||
problem_legacy_resource:
|
||||
enabled: true
|
||||
priority: 50
|
||||
problem_stream_not_loading:
|
||||
issues:
|
||||
enabled: true
|
||||
permanent: true
|
||||
priority: 50
|
||||
resolution:
|
||||
enabled: true
|
||||
permanent: false
|
||||
priority: 50
|
||||
severity:
|
||||
enabled: true
|
||||
permanent: false
|
||||
priority: 50
|
||||
technology:
|
||||
enabled: true
|
||||
permanent: false
|
||||
priority: 50
|
||||
title:
|
||||
enabled: true
|
||||
permanent: false
|
||||
priority: 50
|
||||
```
|
||||
|
||||
@@ -15,6 +15,7 @@ view:
|
||||
| `default` | `auto` | The view to show in the card by default. If `auto`, the card will choose `live` when cameras are configured, `folders` when folders are configured, or `image` otherwise (default embedded image). The default camera is the first one listed. See [Supported Views](view.md?id=supported-views). |
|
||||
| `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See [`default_reset`](#default_reset). |
|
||||
| `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `default_reset.after_interaction` to reset the view after the interaction is complete. |
|
||||
| `issues` | | How the card handles issues and retries. See [`issues`](#issues). |
|
||||
| `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See [`keyboard_shortcuts`](#keyboard_shortcuts). |
|
||||
| `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/advanced-camera-card/issues/343)). |
|
||||
| `theme` | | How the card is themed. See [`theme`](#theme-🎨). |
|
||||
@@ -37,6 +38,23 @@ view:
|
||||
| `interaction_mode` | `inactive` | Whether the default reset should happen when the card is being interacted with. If `all`, the reset will always happen regardless. If `inactive` the reset will only be taken if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` the reset will only be happen if the card _has_ had human interaction recently. This controls resets triggered by `entities` and `every_seconds`, but not `after_interaction` which by definition requires no interaction. |
|
||||
| `every_seconds` | `0` | A number of seconds after which to automatically reset to the default view. `0` disables this functionality. |
|
||||
|
||||
## `issues`
|
||||
|
||||
Configure how the card handles errors/issues and automatic retries.
|
||||
|
||||
All configuration is under:
|
||||
|
||||
```yaml
|
||||
view:
|
||||
issues:
|
||||
# [...]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `interaction_mode` | `all` | Whether scheduled retries should happen when the card is being interacted with. If `all`, retries will always happen regardless. If `inactive` retries will only happen if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` retries will only happen if the card _has_ had human interaction recently. User-initiated retries are always allowed. |
|
||||
| `retry_seconds` | `auto` | Controls automatic retry attempts when an issue is detected (e.g. media not loading, query error). When `auto`, the card uses an exponential backoff schedule starting at ~30 seconds and capped at 10 minutes, with jitter to avoid multiple cards retrying in lockstep. A positive number sets a fixed retry interval in seconds. `0` disables automatic retries entirely. User-initiated retries (e.g. clicking a notification) always run regardless. |
|
||||
|
||||
## `keyboard_shortcuts`
|
||||
|
||||
All configuration is under:
|
||||
@@ -193,6 +211,9 @@ view:
|
||||
interaction_mode: inactive
|
||||
render_entities:
|
||||
- switch.render_card
|
||||
issues:
|
||||
interaction_mode: all
|
||||
retry_seconds: auto
|
||||
dim: false
|
||||
triggers:
|
||||
show_trigger_status: false
|
||||
|
||||
@@ -43,26 +43,27 @@ To upgrade:
|
||||
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
|
||||
### Media does not load
|
||||
|
||||
Stream not loading? Permanent "loading circle"?
|
||||
Media not loading? Permanent "loading circle"?
|
||||
|
||||
A stream not loading is a relatively common error, but can be caused by any
|
||||
Media failing to load is a relatively common error, but can be caused by any
|
||||
number of issues (e.g. installation problems, networking problems, video/codec
|
||||
problems, a Home Assistant bug or card bug).
|
||||
problems, a Home Assistant bug or card bug). This applies to live streams,
|
||||
recorded media in the viewer, and image views.
|
||||
|
||||
During the stream load, the card will show a "loading circle" icon and, for
|
||||
During a live stream load, the card will show a "loading circle" icon and, for
|
||||
cameras with a `camera_entity` configured, will show images refreshing once per
|
||||
second until the stream has fully loaded (unless `live.show_image_during_load`
|
||||
is set to false).
|
||||
|
||||
Debugging broken streams:
|
||||
Debugging steps:
|
||||
|
||||
1. If you're using the default `auto` live provider, or explicitly setting the
|
||||
`ha` live provider, try opening the `camera_entity` in Home Assistant and
|
||||
verifying whether the stream loads there. You can press the `e` key on any
|
||||
Home Assistant dashboard, choose the relevant entity, and see if the stream
|
||||
loads. If it does not, you have a upstream installation issue with your
|
||||
loads. If it does not, you have an upstream installation issue with your
|
||||
camera / the integration for the camera, and need to resolve that first.
|
||||
Your issue is not related to the card itself.
|
||||
1. Check whether any URLs specified in your card configuration are accessible
|
||||
|
||||
@@ -5,10 +5,9 @@ import { DeviceRegistryManager } from '../ha/registry/device';
|
||||
import { EntityRegistryManager } from '../ha/registry/entity/types';
|
||||
import { ResolvedMediaCache } from '../ha/resolved-media';
|
||||
import { HomeAssistant } from '../ha/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { RecordingSegmentsCache } from './cache';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CameraInitializationError } from './error';
|
||||
import { CameraNoEntityError } from './error';
|
||||
import { CameraEventCallback, CameraManagerRequestCache, Engine } from './types';
|
||||
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
|
||||
|
||||
@@ -117,10 +116,7 @@ export class CameraManagerEngineFactory {
|
||||
return Engine.Generic;
|
||||
}
|
||||
// Otherwise, it's probably a typo so throw an exception.
|
||||
throw new CameraInitializationError(
|
||||
localize('error.no_camera_entity'),
|
||||
cameraConfig,
|
||||
);
|
||||
throw new CameraNoEntityError(cameraConfig);
|
||||
}
|
||||
|
||||
switch (entity?.platform) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Entity, EntityRegistryManager } from '../ha/registry/entity/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { Camera, CameraInitializationOptions } from './camera';
|
||||
import { CameraInitializationError } from './error';
|
||||
import { CameraNoEntityError } from './error';
|
||||
import { getCameraEntityFromConfig } from './utils/camera-entity-from-config';
|
||||
|
||||
export interface EntityCameraInitializationOptions extends CameraInitializationOptions {
|
||||
@@ -19,7 +18,7 @@ export class EntityCamera extends Camera {
|
||||
: null;
|
||||
|
||||
if (!entity || !cameraEntityID) {
|
||||
throw new CameraInitializationError(localize('error.no_camera_entity'), config);
|
||||
throw new CameraNoEntityError(config);
|
||||
}
|
||||
this._entity = entity;
|
||||
return await super.initialize(options);
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { AdvancedCameraCardError } from '../types.js';
|
||||
|
||||
export class CameraInitializationError extends AdvancedCameraCardError {}
|
||||
export class CameraNoIDError extends AdvancedCameraCardError {}
|
||||
class CameraInitializationError extends AdvancedCameraCardError {}
|
||||
|
||||
export class CameraNoEngineError extends CameraInitializationError {
|
||||
constructor(context?: unknown) {
|
||||
super(localize('error.no_camera_engine'), context);
|
||||
}
|
||||
}
|
||||
|
||||
export class CameraNoIDError extends CameraInitializationError {
|
||||
constructor(context?: unknown) {
|
||||
super(localize('error.no_camera_id'), context);
|
||||
}
|
||||
}
|
||||
|
||||
export class CameraDuplicateIDError extends CameraInitializationError {
|
||||
constructor(context?: unknown) {
|
||||
super(localize('error.duplicate_camera_id'), context);
|
||||
}
|
||||
}
|
||||
|
||||
export class CameraNoEntityError extends CameraInitializationError {
|
||||
constructor(context?: unknown) {
|
||||
super(localize('error.no_camera_entity'), context);
|
||||
}
|
||||
}
|
||||
|
||||
export class ReolinkInitializationError extends CameraInitializationError {
|
||||
constructor(context?: unknown) {
|
||||
super(localize('error.camera_initialization_reolink'), context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { PTZAction, PTZActionPhase } from '../../config/schema/actions/custom/pt
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { SEVERITIES } from '../../severity';
|
||||
import {
|
||||
CapabilitiesRaw,
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
} from '../../types';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { Camera, CameraInitializationOptions } from '../camera';
|
||||
import { CameraInitializationError } from '../error';
|
||||
import { CameraNoEntityError } from '../error';
|
||||
import { CameraEndpoints, CameraEndpointsContext } from '../types';
|
||||
import { getCameraEntityFromConfig } from '../utils/camera-entity-from-config';
|
||||
import {
|
||||
@@ -123,7 +122,7 @@ export class FrigateCamera extends Camera {
|
||||
if (cameraEntity && (!hasCameraName || hasAutoTriggers)) {
|
||||
entity = await entityRegistryManager.getEntity(hass, cameraEntity);
|
||||
if (!entity) {
|
||||
throw new CameraInitializationError(localize('error.no_camera_entity'), config);
|
||||
throw new CameraNoEntityError(config);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from '../config/schema/actions/custom/ptz.js';
|
||||
import { CameraConfig, Rotation } from '../config/schema/cameras.js';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { Endpoint } from '../types.js';
|
||||
import {
|
||||
allPromises,
|
||||
@@ -28,7 +27,11 @@ import { ViewItemCapabilities } from '../view/types.js';
|
||||
import { Capabilities } from './capabilities.js';
|
||||
import { CameraManagerEngineFactory } from './engine-factory.js';
|
||||
import { CameraManagerEngine } from './engine.js';
|
||||
import { CameraInitializationError } from './error.js';
|
||||
import {
|
||||
CameraDuplicateIDError,
|
||||
CameraNoEngineError,
|
||||
CameraNoIDError,
|
||||
} from './error.js';
|
||||
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
|
||||
import {
|
||||
CameraEndpoints,
|
||||
@@ -150,12 +153,12 @@ export class CameraManager {
|
||||
this._store = options?.store ?? new CameraManagerStore();
|
||||
}
|
||||
|
||||
public async initializeCamerasFromConfig(): Promise<boolean> {
|
||||
public async initializeCamerasFromConfig(): Promise<void> {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
|
||||
if (!config || !hass) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._requestLimit.concurrency =
|
||||
@@ -169,15 +172,7 @@ export class CameraManager {
|
||||
recursivelyMergeObjectsNotArrays(config?.cameras_global, camera),
|
||||
);
|
||||
|
||||
try {
|
||||
await this._initializeCameras(cameras);
|
||||
} catch (e: unknown) {
|
||||
this._api
|
||||
.getMessageManager()
|
||||
.setErrorIfHigherPriority(e, localize('error.camera_initialization'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
@@ -214,12 +209,9 @@ export class CameraManager {
|
||||
}))
|
||||
: null;
|
||||
if (!engine || !engineType) {
|
||||
throw new CameraInitializationError(
|
||||
localize('error.no_camera_engine'),
|
||||
// Camera initialization may modify the configuration. Keep the
|
||||
// original config unchanged.
|
||||
cloneDeep(cameraConfig),
|
||||
);
|
||||
throw new CameraNoEngineError(cloneDeep(cameraConfig));
|
||||
}
|
||||
engines.set(engineType, engine);
|
||||
output.set(cameraConfig, engine);
|
||||
@@ -272,18 +264,12 @@ export class CameraManager {
|
||||
|
||||
if (!cameraID) {
|
||||
await destroyCameras();
|
||||
throw new CameraInitializationError(
|
||||
localize('error.no_camera_id'),
|
||||
camera.getConfig(),
|
||||
);
|
||||
throw new CameraNoIDError(camera.getConfig());
|
||||
}
|
||||
|
||||
if (cameraIDs.has(cameraID)) {
|
||||
await destroyCameras();
|
||||
throw new CameraInitializationError(
|
||||
localize('error.duplicate_camera_id'),
|
||||
camera.getConfig(),
|
||||
);
|
||||
throw new CameraDuplicateIDError(camera.getConfig());
|
||||
}
|
||||
|
||||
// Always ensure the actual ID used in the card is in the configuration itself.
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { DeviceRegistryManager } from '../../ha/registry/device/index';
|
||||
import { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import {
|
||||
CapabilitiesRaw,
|
||||
Endpoint,
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
import { createSelectOptionAction } from '../../utils/action.js';
|
||||
import { Camera, CameraInitializationOptions } from '../camera';
|
||||
import { EntityCamera } from '../entity-camera';
|
||||
import { CameraInitializationError } from '../error';
|
||||
import { ReolinkInitializationError } from '../error';
|
||||
import { CameraEndpointsContext, CameraProxyConfig } from '../types';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
|
||||
@@ -44,8 +43,6 @@ interface ReolinkCameraInitializationOptions extends CameraInitializationOptions
|
||||
deviceRegistryManager: DeviceRegistryManager;
|
||||
}
|
||||
|
||||
class ReolinkInitializationError extends CameraInitializationError {}
|
||||
|
||||
// Button entities for continuous PTZ movement (press to start, press stop to
|
||||
// end). Discovered from button.{name}_ptz_{action} entities. Zoom button
|
||||
// entities are disabled by default in the Reolink integration.
|
||||
@@ -153,10 +150,7 @@ export class ReolinkCamera extends EntityCamera {
|
||||
const channelOrUID = match?.groups?.channel_or_uid ?? null;
|
||||
|
||||
if (hostid === null || channelOrUID === null) {
|
||||
throw new ReolinkInitializationError(
|
||||
localize('error.camera_initialization_reolink'),
|
||||
this.getConfig(),
|
||||
);
|
||||
throw new ReolinkInitializationError(this.getConfig());
|
||||
}
|
||||
|
||||
const channelCandidate = Number(channelOrUID);
|
||||
|
||||
@@ -45,7 +45,8 @@ export class ActionsManager implements ActionsExecutor {
|
||||
*/
|
||||
public getMergedActions(): ActionsConfig {
|
||||
const view = this._api.getViewManager().getView();
|
||||
if (this._api.getMessageManager().hasMessage()) {
|
||||
// Don't apply view actions when there are full-card/serious issues.
|
||||
if (this._api.getIssueManager().getStateManager().hasFullCardIssue()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MediaDetailsController } from '../../../components-lib/media/details-controller';
|
||||
import { MediaNotificationController } from '../../../components-lib/media/notification-controller';
|
||||
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
|
||||
import { ViewItemClassifier } from '../../../view/item-classifier';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
@@ -13,11 +13,11 @@ export class InfoAction extends AdvancedCameraCardAction<GeneralActionConfig> {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new MediaDetailsController();
|
||||
controller.calculate(api.getCameraManager(), item);
|
||||
const notificationController = new MediaNotificationController();
|
||||
notificationController.calculate(api.getCameraManager(), item);
|
||||
|
||||
api.getNotificationManager().setNotification(
|
||||
controller.getNotification({
|
||||
notificationController.getNotification({
|
||||
hass: api.getHASSManager().getHASS() ?? undefined,
|
||||
viewItemManager: api.getViewItemManager(),
|
||||
viewManagerEpoch: api.getViewManager().getEpoch(),
|
||||
|
||||
@@ -50,7 +50,7 @@ export class AutomationsManager {
|
||||
!this._api.getInitializationManager().isInitializedMandatory() ||
|
||||
// Never execute automations if there's an error (as our automation loop
|
||||
// avoidance -- which shows as an error -- would not work!).
|
||||
this._api.getMessageManager().hasErrorMessage()
|
||||
this._api.getIssueManager().getStateManager().hasFullCardIssue()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -66,9 +66,12 @@ export class AutomationsManager {
|
||||
++this._nestedAutomationExecutions;
|
||||
|
||||
if (this._nestedAutomationExecutions > MAX_NESTED_AUTOMATION_EXECUTIONS) {
|
||||
this._api.getMessageManager().setMessageIfHigherPriority({
|
||||
type: 'error',
|
||||
message: localize('error.too_many_automations'),
|
||||
this._api.getNotificationManager().setNotification({
|
||||
heading: {
|
||||
text: localize('error.too_many_automations'),
|
||||
icon: 'mdi:alert',
|
||||
severity: 'high',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ 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
|
||||
@@ -159,6 +158,8 @@ export class CardElementManager {
|
||||
// disconnected/reconnected when dashboard 'tab' changes happen within HA.
|
||||
this._api.getQueryStringManager().requestExecution();
|
||||
|
||||
this._api.getIssueManager().resume();
|
||||
|
||||
// Make sure reconnections call the initialization code.
|
||||
this._element.requestUpdate();
|
||||
}
|
||||
@@ -168,20 +169,30 @@ export class CardElementManager {
|
||||
setOrRemoveAttribute(this._element, false, 'tabindex');
|
||||
setOrRemoveAttribute(this._element, false, 'casted');
|
||||
|
||||
// Suspend issue evaluation so state changes below (e.g. clearing
|
||||
// mediaLoadedInfo) don't arm timers while the card is detached. Issue state
|
||||
// is preserved; evaluation resumes via resume() on reconnect.
|
||||
this._api.getIssueManager().suspend();
|
||||
|
||||
// When the dashboard 'tab' is changed, the media is effectively unloaded.
|
||||
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.getInteractionManager().uninitialize();
|
||||
this._api.getDefaultManager().uninitialize();
|
||||
this._api.getHASSManager().getStateWatcher()?.unsubscribe(this.update);
|
||||
|
||||
// Uninitialize cameras to cause them to reinitialize on
|
||||
// Uninitialize cameras and triggers to cause them to reinitialize on
|
||||
// reconnection, to ensure the state subscription/unsubscription works
|
||||
// correctly for triggers.
|
||||
// correctly and triggers that changed while detached are picked up.
|
||||
// Reset trigger state first to stop stale timers and clear condition state.
|
||||
this._api.getTriggersManager().reset();
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
||||
this._api
|
||||
.getInitializationManager()
|
||||
.uninitialize(InitializationAspect.INITIAL_TRIGGER);
|
||||
this._api.getCameraManager().destroy();
|
||||
|
||||
this._element.removeEventListener(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { ConfigParseError } from './error.js';
|
||||
import { copyConfig, isConfigUpgradeable } from '../../config/management.js';
|
||||
import { setProfiles } from '../../config/profiles/set-profiles.js';
|
||||
import {
|
||||
@@ -56,7 +57,7 @@ export class ConfigManager {
|
||||
|
||||
public setConfig(inputConfig?: RawAdvancedCameraCardConfig): void {
|
||||
if (!inputConfig) {
|
||||
throw new Error(localize('error.invalid_configuration'));
|
||||
throw new ConfigParseError(localize('error.invalid_configuration'));
|
||||
}
|
||||
|
||||
const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig);
|
||||
@@ -67,7 +68,7 @@ export class ConfigManager {
|
||||
if (isConfigUpgradeable(inputConfig)) {
|
||||
upgradeMessage = `${localize('error.upgrade_available')}. `;
|
||||
}
|
||||
throw new Error(
|
||||
throw new ConfigParseError(
|
||||
upgradeMessage +
|
||||
`${localize('error.invalid_configuration')}: ` +
|
||||
(hint ?? localize('error.invalid_configuration_no_hint')),
|
||||
@@ -109,7 +110,6 @@ export class ConfigManager {
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
|
||||
this._api.getViewManager().reset();
|
||||
|
||||
this._api.getMessageManager().reset();
|
||||
this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
|
||||
|
||||
this._processOverrideConfig();
|
||||
@@ -118,11 +118,28 @@ export class ConfigManager {
|
||||
}
|
||||
|
||||
private _processOverrideConfig(): void {
|
||||
const overriddenConfig = this._getOverriddenConfig();
|
||||
/* istanbul ignore if: No (current) way to reach this code -- @preserve */
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
let overriddenConfig: AdvancedCameraCardConfig;
|
||||
try {
|
||||
overriddenConfig = this._overridesManager.getConfig(this._config);
|
||||
} catch (ev) {
|
||||
this._api.getIssueManager().trigger('config_error', { error: ev });
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any prior config error on success. This method runs both from
|
||||
// setConfig() and from condition-change-driven override recomputations;
|
||||
// resetting here ensures a transient override error is cleared when
|
||||
// conditions change to produce a valid config again.
|
||||
this._api.getIssueManager().reset('config_error');
|
||||
|
||||
// Save on Lit re-rendering costs by only updating the configuration if it
|
||||
// actually changes.
|
||||
if (!overriddenConfig || isEqual(overriddenConfig, this._overriddenConfig)) {
|
||||
if (isEqual(overriddenConfig, this._overriddenConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -189,20 +206,6 @@ export class ConfigManager {
|
||||
/* async */ this._initializeBackgroundAndUpdate(previousConfig);
|
||||
}
|
||||
|
||||
private _getOverriddenConfig(): AdvancedCameraCardConfig | null {
|
||||
/* istanbul ignore if: No (current) way to reach this code -- @preserve */
|
||||
if (!this._config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return this._overridesManager.getConfig(this._config);
|
||||
} catch (ev) {
|
||||
this._api.getMessageManager().setErrorIfHigherPriority(ev);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize config dependent items in the background. For items that the
|
||||
* card hard requires, use InitializationManager instead.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdvancedCameraCardError } from '../../types.js';
|
||||
|
||||
class ConfigError extends AdvancedCameraCardError {}
|
||||
|
||||
export class ConfigParseError extends ConfigError {}
|
||||
@@ -7,6 +7,6 @@ export const setFoldersFromConfig = (api: CardConfigLoaderAPI): void => {
|
||||
.getFoldersManager()
|
||||
.addFolders(api.getConfigManager().getConfig()?.folders ?? []);
|
||||
} catch (ev) {
|
||||
api.getMessageManager().setErrorIfHigherPriority(ev);
|
||||
api.getIssueManager().trigger('config_error', { error: ev });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { merge } from 'lodash-es';
|
||||
import { get, merge } from 'lodash-es';
|
||||
import { ConditionsManager } from '../../conditions/conditions-manager';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
|
||||
import {
|
||||
@@ -92,9 +92,24 @@ export class OverridesManager {
|
||||
|
||||
const parseResult = advancedCameraCardConfigSchema.safeParse(output);
|
||||
if (!parseResult.success) {
|
||||
// Surface one co-located failure object per Zod issue — path, the value
|
||||
// the user actually wrote, and the most informative "expected" field for
|
||||
// this issue code. Avoids dumping the full merged config (which is
|
||||
// mostly schema defaults the user never wrote) and keeps the reader from
|
||||
// cross-referencing two parallel arrays.
|
||||
const failures = parseResult.error.issues.map((issue) => ({
|
||||
path: issue.path.map(String).join('.'),
|
||||
received: get(output, issue.path),
|
||||
expected:
|
||||
issue.code === 'invalid_value'
|
||||
? issue.values
|
||||
: issue.code === 'invalid_type'
|
||||
? issue.expected
|
||||
: issue.message,
|
||||
}));
|
||||
throw new OverrideConfigurationError(
|
||||
localize('error.invalid_configuration_override'),
|
||||
[parseResult.error.issues, output],
|
||||
{ failures },
|
||||
);
|
||||
}
|
||||
return parseResult.data;
|
||||
|
||||
@@ -29,11 +29,11 @@ import { InteractionManager } from './interaction-manager';
|
||||
import { KeyboardStateManager } from './keyboard-state-manager';
|
||||
import { MediaLoadedInfoManager } from './media-info-manager';
|
||||
import { MediaPlayerManager } from './media-player-manager';
|
||||
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 { createIssueManager } from './issues/factory';
|
||||
import { IssueManager } from './issues/issue-manager';
|
||||
import { QueryStringManager } from './query-string-manager';
|
||||
import { StatusBarItemManager } from './status-bar-item-manager';
|
||||
import { StyleManager } from './style-manager';
|
||||
@@ -57,11 +57,10 @@ import {
|
||||
CardKeyboardStateAPI,
|
||||
CardMediaLoadedAPI,
|
||||
CardMediaPlayerAPI,
|
||||
CardMessageAPI,
|
||||
CardMicrophoneAPI,
|
||||
CardNotificationAPI,
|
||||
CardPIPAPI,
|
||||
CardProblemAPI,
|
||||
CardIssueManagerAPI,
|
||||
CardQueryStringAPI,
|
||||
CardStyleAPI,
|
||||
CardTriggersAPI,
|
||||
@@ -85,13 +84,12 @@ export class CardController
|
||||
CardFullscreenAPI,
|
||||
CardHASSAPI,
|
||||
CardPIPAPI,
|
||||
CardProblemAPI,
|
||||
CardIssueManagerAPI,
|
||||
CardInitializerAPI,
|
||||
CardInteractionAPI,
|
||||
CardKeyboardStateAPI,
|
||||
CardMediaLoadedAPI,
|
||||
CardMediaPlayerAPI,
|
||||
CardMessageAPI,
|
||||
CardMicrophoneAPI,
|
||||
CardNotificationAPI,
|
||||
CardQueryStringAPI,
|
||||
@@ -126,11 +124,10 @@ export class CardController
|
||||
private _mediaLoadedInfoManager = new MediaLoadedInfoManager(this);
|
||||
|
||||
private _mediaPlayerManager = new MediaPlayerManager(this);
|
||||
private _messageManager = new MessageManager(this);
|
||||
private _microphoneManager = new MicrophoneManager(this);
|
||||
private _notificationManager = new NotificationManager(this);
|
||||
private _pipManager = new PIPManager(this);
|
||||
private _problemManager = new ProblemManager(this);
|
||||
private _issueManager = createIssueManager(this);
|
||||
private _queryStringManager = new QueryStringManager(this);
|
||||
private _statusBarItemManager = new StatusBarItemManager(this);
|
||||
private _styleManager = new StyleManager(this);
|
||||
@@ -245,10 +242,6 @@ export class CardController
|
||||
return this._mediaPlayerManager;
|
||||
}
|
||||
|
||||
public getMessageManager(): MessageManager {
|
||||
return this._messageManager;
|
||||
}
|
||||
|
||||
public getMicrophoneManager(): MicrophoneManager {
|
||||
return this._microphoneManager;
|
||||
}
|
||||
@@ -264,8 +257,8 @@ export class CardController
|
||||
return this._pipManager;
|
||||
}
|
||||
|
||||
public getProblemManager(): ProblemManager {
|
||||
return this._problemManager;
|
||||
public getIssueManager(): IssueManager {
|
||||
return this._issueManager;
|
||||
}
|
||||
|
||||
public getQueryStringManager(): QueryStringManager {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { hasHAConnectionStateChanged } from '../../ha/has-hass-connection-changed';
|
||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { log } from '../../utils/debug';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { CardHASSAPI } from '../types';
|
||||
@@ -28,40 +27,31 @@ export class HASSManager {
|
||||
}
|
||||
|
||||
public setHASS(hass?: HomeAssistant | null): void {
|
||||
if (hasHAConnectionStateChanged(this._hass, hass)) {
|
||||
if (!hass?.connected) {
|
||||
this._api.getMessageManager().setMessageIfHigherPriority({
|
||||
message: localize('error.reconnecting'),
|
||||
icon: 'mdi:lan-disconnect',
|
||||
type: 'connection',
|
||||
dotdotdot: true,
|
||||
});
|
||||
} else {
|
||||
this._api.getMessageManager().resetType('connection');
|
||||
|
||||
// When the HA WebSocket connection is restored after a drop,
|
||||
// reinitialize cameras and the view. This is necessary because
|
||||
// event subscriptions (e.g. Frigate WebSocket subscriptions via
|
||||
// hass.connection.subscribeMessage) are tied to the old connection
|
||||
// and are lost when it drops. Without reinitialization, triggers
|
||||
// and thumbnail updates stop working.
|
||||
if (this._hass) {
|
||||
// When HA transitions from "not ready" to "ready" (WebSocket reconnected
|
||||
// AND all integrations finished loading), reinitialize cameras and the
|
||||
// view. This is necessary because event subscriptions (e.g. Frigate
|
||||
// WebSocket subscriptions via hass.connection.subscribeMessage) are tied to
|
||||
// the old connection and are lost when it drops. Without reinitialization,
|
||||
// triggers and thumbnail updates stop working.
|
||||
//
|
||||
// We deliberately wait for hass.config.state === STATE_RUNNING rather than
|
||||
// just hass.connected, because HA exposes the WebSocket before integrations
|
||||
// have finished loading. Triggering re-init too early would race against
|
||||
// integration startup and fail with "Unknown command" on
|
||||
// integration-specific WS calls.
|
||||
if (this._hass && !this._isReady(this._hass) && this._isReady(hass)) {
|
||||
log(
|
||||
this._api.getConfigManager().getCardWideConfig(),
|
||||
'Advanced Camera Card: HA connection restored, reinitializing...',
|
||||
'Advanced Camera Card: HA fully ready, reinitializing...',
|
||||
);
|
||||
|
||||
this._api
|
||||
.getInitializationManager()
|
||||
.uninitialize(InitializationAspect.CAMERAS);
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
|
||||
this._api.getCameraManager().destroy();
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW);
|
||||
this._api
|
||||
.getInitializationManager()
|
||||
.uninitialize(InitializationAspect.INITIAL_TRIGGER);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hass) {
|
||||
return;
|
||||
@@ -79,4 +69,8 @@ export class HASSManager {
|
||||
|
||||
this._stateWatcher.setHASS(oldHass, hass);
|
||||
}
|
||||
|
||||
private _isReady(hass?: HomeAssistant | null): boolean {
|
||||
return !!hass?.connected && hass.config?.state === STATE_RUNNING;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import PQueue from 'p-queue';
|
||||
import { sideLoadHomeAssistantElements } from '../ha/side-load-ha-elements';
|
||||
import { loadLanguages } from '../localize/localize';
|
||||
import { errorToConsole } from '../utils/basic';
|
||||
import { Initializer } from '../utils/initializer/initializer';
|
||||
import { CardInitializerAPI } from './types';
|
||||
|
||||
@@ -9,7 +11,6 @@ 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
|
||||
@@ -51,10 +52,6 @@ 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) {
|
||||
@@ -87,14 +84,28 @@ export class InitializationManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait until HA has finished loading integrations before attempting init.
|
||||
// Otherwise integration-specific WS calls (e.g. Frigate event
|
||||
// subscriptions) fail with "Unknown command" against a half-loaded HA. The
|
||||
// HASSManager will trigger another init attempt as soon as
|
||||
// hass.config.state transitions to RUNNING.
|
||||
if (hass.config?.state !== STATE_RUNNING) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this._initializer.initializeMultipleIfNecessary({
|
||||
!(await this._tryInitialize(() =>
|
||||
this._initializer.initializeMultipleIfNecessary({
|
||||
// Caution: Ensure nothing in this set of initializers requires
|
||||
// config or languages since they will not yet have been initialized.
|
||||
[InitializationAspect.LANGUAGES]: async () => await loadLanguages(hass),
|
||||
[InitializationAspect.SIDE_LOAD_ELEMENTS]: async () =>
|
||||
await sideLoadHomeAssistantElements(),
|
||||
}))
|
||||
[InitializationAspect.LANGUAGES]: async () => {
|
||||
await loadLanguages(hass);
|
||||
},
|
||||
[InitializationAspect.SIDE_LOAD_ELEMENTS]: async () => {
|
||||
await sideLoadHomeAssistantElements();
|
||||
},
|
||||
}),
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -105,13 +116,14 @@ export class InitializationManager {
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this._initializer.initializeMultipleIfNecessary({
|
||||
!(await this._tryInitialize(() =>
|
||||
this._initializer.initializeMultipleIfNecessary({
|
||||
[InitializationAspect.CAMERAS]: async () => {
|
||||
// Recreate the camera manager to guarantee an immediate re-render.
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1811
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1769
|
||||
this._api.createCameraManager();
|
||||
return await this._api.getCameraManager().initializeCamerasFromConfig();
|
||||
await this._api.getCameraManager().initializeCamerasFromConfig();
|
||||
},
|
||||
|
||||
// Connecting the microphone (if configured) is considered mandatory to
|
||||
@@ -123,34 +135,37 @@ export class InitializationManager {
|
||||
// Recreate the microphone manager to guarantee an immediate
|
||||
// re-render.
|
||||
this._api.createMicrophoneManager();
|
||||
return await this._api.getMicrophoneManager().connect();
|
||||
await this._api.getMicrophoneManager().connect();
|
||||
},
|
||||
}),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this._api.getMessageManager().hasMessage() ||
|
||||
!(await this._initializer.initializeIfNecessary(
|
||||
InitializationAspect.VIEW,
|
||||
this._api.getViewManager().initialize,
|
||||
}),
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this._initializer.initializeIfNecessary(
|
||||
!(await this._tryInitialize(() =>
|
||||
this._initializer.initializeIfNecessary(
|
||||
InitializationAspect.VIEW,
|
||||
this._api.getViewManager().initialize,
|
||||
),
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await this._tryInitialize(() =>
|
||||
this._initializer.initializeIfNecessary(
|
||||
InitializationAspect.INITIAL_TRIGGER,
|
||||
async (): Promise<boolean> => {
|
||||
async () => {
|
||||
await this._api.getTriggersManager().handleInitialCameraTriggers();
|
||||
|
||||
// Force a card update to continue the initialization.
|
||||
this._api.getCardElementManager().update();
|
||||
return true;
|
||||
},
|
||||
),
|
||||
))
|
||||
) {
|
||||
return;
|
||||
@@ -172,26 +187,40 @@ export class InitializationManager {
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
|
||||
public async initializeBackground(): Promise<void> {
|
||||
await this._initializationQueue.add(() => this._initializeBackground());
|
||||
private async _tryInitialize(fn: () => Promise<void>): Promise<boolean> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
errorToConsole(e);
|
||||
}
|
||||
this._setInitializationIssue(e);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async _initializeBackground(): Promise<void> {
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
if (!hass) {
|
||||
return;
|
||||
if (this._api.getIssueManager().getStateManager().hasFullCardIssue()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this._initializer.initializeIfNecessary(
|
||||
InitializationAspect.PROBLEMS,
|
||||
async () => {
|
||||
await this._api.getProblemManager().detectStatic(hass);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private _setInitializationIssue(error: unknown): void {
|
||||
this._api.getIssueManager().trigger('initialization', { error });
|
||||
}
|
||||
|
||||
public uninitialize(aspect: InitializationAspect): void {
|
||||
this._initializer.uninitialize(aspect);
|
||||
}
|
||||
|
||||
public uninitializeMandatory(): void {
|
||||
for (const aspect of [
|
||||
InitializationAspect.CAMERAS,
|
||||
InitializationAspect.MICROPHONE_CONNECT,
|
||||
InitializationAspect.VIEW,
|
||||
InitializationAspect.INITIAL_TRIGGER,
|
||||
]) {
|
||||
this._initializer.uninitialize(aspect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ export class InteractionManager {
|
||||
this._setInteraction(false);
|
||||
}
|
||||
|
||||
public uninitialize(): void {
|
||||
this._timer.stop();
|
||||
this.reportInteraction.cancel();
|
||||
}
|
||||
|
||||
public hasInteraction(): boolean {
|
||||
return this._interacted;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CardIssueManagerAPI } from '../types';
|
||||
import { IssueManager } from './issue-manager';
|
||||
import { ConfigErrorIssue } from './issues/config-error';
|
||||
import { ConfigUpgradeIssue } from './issues/config-upgrade';
|
||||
import { ConnectionIssue } from './issues/connection';
|
||||
import { InitializationIssue } from './issues/initialization';
|
||||
import { LegacyResourceIssue } from './issues/legacy-resource';
|
||||
import { MediaLoadIssue } from './issues/media-load';
|
||||
import { MediaQueryIssue } from './issues/media-query';
|
||||
import { ViewIncompatibleIssue } from './issues/view-incompatible';
|
||||
|
||||
export const createIssueManager = (api: CardIssueManagerAPI): IssueManager => {
|
||||
const manager = new IssueManager(api);
|
||||
const changeCallback = () => manager.evaluate();
|
||||
|
||||
// Registration order determines both retry priority and full-card display
|
||||
// priority. For retries, issues are retried in order and an exclusive retry
|
||||
// stops the loop. For display, getFullCardIssue() returns the first active
|
||||
// full-card issue. Register broader/more critical issues first.
|
||||
manager.addIssue(new ConfigErrorIssue());
|
||||
manager.addIssue(new ConfigUpgradeIssue(api));
|
||||
manager.addIssue(new ViewIncompatibleIssue(api));
|
||||
manager.addIssue(new ConnectionIssue());
|
||||
manager.addIssue(new InitializationIssue(api));
|
||||
manager.addIssue(new LegacyResourceIssue(changeCallback));
|
||||
manager.addIssue(new MediaQueryIssue(api));
|
||||
manager.addIssue(new MediaLoadIssue(api, changeCallback));
|
||||
|
||||
return manager;
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import { ConditionStateChange } from '../../conditions/types';
|
||||
import { isActionAllowedBasedOnInteractionState } from '../../utils/interaction-mode';
|
||||
import { Timer } from '../../utils/timer';
|
||||
import { CardIssueManagerAPI } from '../types';
|
||||
import { IssueStateManager } from './state-manager';
|
||||
import { 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;
|
||||
const RETRY_EXPONENTIAL_JITTER_MIN = 0.5;
|
||||
const RETRY_EXPONENTIAL_JITTER_MAX = 1.0;
|
||||
|
||||
// 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 Timer();
|
||||
private _retryAttempt = 0;
|
||||
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(),
|
||||
})
|
||||
) {
|
||||
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.stop();
|
||||
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.stop();
|
||||
this._stateManager.suspend();
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
this._suspended = false;
|
||||
this.evaluate();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._retryTimer.stop();
|
||||
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) {
|
||||
/* async */ this._stateManager
|
||||
.detectStatic(change.new.hass)
|
||||
.then(() => this.evaluate());
|
||||
}
|
||||
this.evaluate();
|
||||
}
|
||||
|
||||
private _scheduleRetryIfNeeded(): void {
|
||||
if (!this._stateManager.needsRetry()) {
|
||||
this._retryTimer.stop();
|
||||
this._retryAttempt = 0;
|
||||
return;
|
||||
}
|
||||
if (this._retryTimer.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
if (!config) {
|
||||
this._retryAttempt = 0;
|
||||
return;
|
||||
}
|
||||
const delaySeconds = this._nextRetryDelaySeconds(config.view.issues.retry_seconds);
|
||||
if (delaySeconds === null) {
|
||||
this._retryAttempt = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this._retryTimer.start(delaySeconds, () => {
|
||||
if (!this._stateManager.needsRetry()) {
|
||||
this._retryAttempt = 0;
|
||||
return;
|
||||
}
|
||||
if (this._isScheduledRetryAllowed()) {
|
||||
this._stateManager.retry();
|
||||
this._retryAttempt++;
|
||||
// evaluate() re-arms the timer via _scheduleRetryIfNeeded.
|
||||
this.evaluate();
|
||||
} else {
|
||||
// Retry was gated (e.g. user interaction). This isn't a failed attempt
|
||||
// so don't increment — re-arm at the same delay.
|
||||
this._scheduleRetryIfNeeded();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _nextRetryDelaySeconds(retryConfig: 'auto' | number): number | null {
|
||||
if (typeof retryConfig === 'number') {
|
||||
return retryConfig === 0 ? null : retryConfig;
|
||||
}
|
||||
|
||||
// 'auto': exponential backoff, capped, with jitter to avoid thundering-herd
|
||||
// when multiple cards retry the same backend in lockstep.
|
||||
const exp = Math.min(
|
||||
RETRY_EXPONENTIAL_MAX_SECONDS,
|
||||
RETRY_EXPONENTIAL_BASE_SECONDS * 2 ** this._retryAttempt,
|
||||
);
|
||||
const jitter =
|
||||
RETRY_EXPONENTIAL_JITTER_MIN +
|
||||
Math.random() * (RETRY_EXPONENTIAL_JITTER_MAX - RETRY_EXPONENTIAL_JITTER_MIN);
|
||||
return exp * jitter;
|
||||
}
|
||||
|
||||
private _isScheduledRetryAllowed(): boolean {
|
||||
const interactionMode = this._api.getConfigManager().getConfig()?.view
|
||||
.issues.interaction_mode;
|
||||
return (
|
||||
!!interactionMode &&
|
||||
isActionAllowedBasedOnInteractionState(
|
||||
interactionMode,
|
||||
this._api.getInteractionManager().hasInteraction(),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Issue, IssueDescription, IssueKey } from '../types.js';
|
||||
|
||||
// Shared base for issues whose only state is a single triggered error. Subclasses
|
||||
// define the key and how an error renders; the base handles trigger/reset and
|
||||
// gates getIssue() on error presence.
|
||||
export abstract class AbstractErrorIssue implements Issue {
|
||||
public abstract readonly key: IssueKey;
|
||||
protected _error: unknown = null;
|
||||
|
||||
public trigger(context: { error: unknown }): void {
|
||||
this._error = context.error ?? null;
|
||||
}
|
||||
|
||||
public hasIssue(): boolean {
|
||||
return this._error !== null;
|
||||
}
|
||||
|
||||
public getIssue(): IssueDescription | null {
|
||||
if (this._error == null) {
|
||||
return null;
|
||||
}
|
||||
return this._buildDescription(this._error);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._error = null;
|
||||
}
|
||||
|
||||
protected abstract _buildDescription(error: NonNullable<unknown>): IssueDescription;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createNotificationFromError } from '../../../components-lib/notification/factory.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import { IssueDescription } from '../types.js';
|
||||
import { AbstractErrorIssue } from './abstract-error-issue.js';
|
||||
|
||||
declare module 'issue' {
|
||||
interface IssueTriggerContext {
|
||||
config_error: { error: unknown };
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigErrorIssue extends AbstractErrorIssue {
|
||||
public readonly key = 'config_error' as const;
|
||||
|
||||
public isFullCardIssue(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected _buildDescription(error: NonNullable<unknown>): IssueDescription {
|
||||
return {
|
||||
icon: 'mdi:alert',
|
||||
severity: 'high',
|
||||
notification: createNotificationFromError(error, {
|
||||
heading: { text: localize('issues.config_error.heading') },
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -1,29 +1,29 @@
|
||||
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';
|
||||
import { CardIssueManagerAPI } from '../../types';
|
||||
import { Issue, IssueDescription } from '../types';
|
||||
|
||||
export class ConfigUpgradeProblem implements Problem {
|
||||
export class ConfigUpgradeIssue implements Issue {
|
||||
public readonly key = 'config_upgrade' as const;
|
||||
|
||||
private _api: CardIssueManagerAPI;
|
||||
private _upgradeable = false;
|
||||
private _getRawConfig: () => RawAdvancedCameraCardConfig | null;
|
||||
|
||||
constructor(getRawConfig: () => RawAdvancedCameraCardConfig | null) {
|
||||
this._getRawConfig = getRawConfig;
|
||||
constructor(api: CardIssueManagerAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public async detectStatic(): Promise<void> {
|
||||
const rawConfig = this._getRawConfig();
|
||||
const rawConfig = this._api.getConfigManager().getRawConfig();
|
||||
this._upgradeable = !!rawConfig && isConfigUpgradeable(rawConfig);
|
||||
}
|
||||
|
||||
public hasResult(): boolean {
|
||||
public hasIssue(): boolean {
|
||||
return this._upgradeable;
|
||||
}
|
||||
|
||||
public getResult(): ProblemResult | null {
|
||||
public getIssue(): IssueDescription | null {
|
||||
if (!this._upgradeable) {
|
||||
return null;
|
||||
}
|
||||
@@ -32,14 +32,14 @@ export class ConfigUpgradeProblem implements Problem {
|
||||
severity: 'medium',
|
||||
notification: {
|
||||
heading: {
|
||||
text: localize('problems.config_upgrade.heading'),
|
||||
text: localize('issues.config_upgrade.heading'),
|
||||
icon: 'mdi:update',
|
||||
severity: 'medium',
|
||||
},
|
||||
text: localize('problems.config_upgrade.text'),
|
||||
body: { text: localize('issues.config_upgrade.text') },
|
||||
link: {
|
||||
url: TROUBLESHOOTING_CONFIG_UPGRADE_URL,
|
||||
title: localize('problems.troubleshooting_guide'),
|
||||
title: localize('issues.troubleshooting_guide'),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { STATE_RUNNING } from 'home-assistant-js-websocket';
|
||||
import { ConditionState } from '../../../conditions/types.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import { Issue, IssueDescription } from '../types.js';
|
||||
|
||||
// Tracks "is HA fully ready to talk to". Active in two sub-states:
|
||||
// - 'lost' : the WebSocket is disconnected
|
||||
// - 'starting' : the WebSocket is connected but HA hasn't finished loading
|
||||
// integrations yet (hass.config.state !== STATE_RUNNING)
|
||||
// Both sub-states render as a full-card notification with a spinner. The card
|
||||
// only attempts re-initialization when the issue clears (i.e. HA is fully
|
||||
// ready), so integration-specific WS calls (e.g. Frigate event subscriptions)
|
||||
// don't fail with "Unknown command" against a half-loaded HA.
|
||||
type ConnectionState = 'ready' | 'lost' | 'starting';
|
||||
|
||||
export class ConnectionIssue implements Issue {
|
||||
public readonly key = 'connection' as const;
|
||||
|
||||
private _state: ConnectionState = 'ready';
|
||||
|
||||
public detectDynamic(state: ConditionState): void {
|
||||
// Before HASS is ever provided, leave state untouched — undefined hass is
|
||||
// not a disconnection, just "not yet initialized".
|
||||
if (state.hass === undefined) {
|
||||
return;
|
||||
}
|
||||
if (!state.hass.connected) {
|
||||
this._state = 'lost';
|
||||
} else if (state.hass.config?.state !== STATE_RUNNING) {
|
||||
this._state = 'starting';
|
||||
} else {
|
||||
this._state = 'ready';
|
||||
}
|
||||
}
|
||||
|
||||
public hasIssue(): boolean {
|
||||
return this._state !== 'ready';
|
||||
}
|
||||
|
||||
public isFullCardIssue(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
public getIssue(): IssueDescription | null {
|
||||
return this._state === 'lost'
|
||||
? {
|
||||
icon: 'mdi:lan-disconnect',
|
||||
severity: 'high',
|
||||
notification: {
|
||||
heading: {
|
||||
text: localize('issues.connection.lost.heading'),
|
||||
icon: 'mdi:lan-disconnect',
|
||||
severity: 'high',
|
||||
},
|
||||
body: { text: localize('issues.connection.lost.text') },
|
||||
in_progress: true,
|
||||
},
|
||||
}
|
||||
: this._state === 'starting'
|
||||
? {
|
||||
icon: 'mdi:home-assistant',
|
||||
severity: 'medium',
|
||||
notification: {
|
||||
heading: {
|
||||
text: localize('issues.connection.starting.heading'),
|
||||
icon: 'mdi:home-assistant',
|
||||
severity: 'medium',
|
||||
},
|
||||
body: { text: localize('issues.connection.starting.text') },
|
||||
in_progress: true,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._state = 'ready';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createNotificationFromError } from '../../../components-lib/notification/factory.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import { CardIssueManagerAPI } from '../../types';
|
||||
import { createRetryControl } from '../retry-control.js';
|
||||
import { IssueDescription } from '../types';
|
||||
import { AbstractErrorIssue } from './abstract-error-issue.js';
|
||||
|
||||
declare module 'issue' {
|
||||
interface IssueTriggerContext {
|
||||
initialization: { error: unknown };
|
||||
}
|
||||
}
|
||||
|
||||
export class InitializationIssue extends AbstractErrorIssue {
|
||||
public readonly key = 'initialization' as const;
|
||||
|
||||
private _api: CardIssueManagerAPI;
|
||||
|
||||
constructor(api: CardIssueManagerAPI) {
|
||||
super();
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public detectDynamic(): void {
|
||||
if (
|
||||
this._error !== null &&
|
||||
this._api.getInitializationManager().isInitializedMandatory()
|
||||
) {
|
||||
this._error = null;
|
||||
}
|
||||
}
|
||||
|
||||
public needsRetry(): boolean {
|
||||
return this._error !== null;
|
||||
}
|
||||
|
||||
public retry(): boolean {
|
||||
// Clear the error so the full-card issue is removed and shouldUpdate()
|
||||
// no longer short-circuits before initializeMandatory().
|
||||
this._error = null;
|
||||
|
||||
// Reset init state so initializeMandatory() re-attempts on the next
|
||||
// render cycle. destroy() releases the existing CameraManager's held
|
||||
// resources (WebSocket subscriptions, listeners) before the CAMERAS
|
||||
// init aspect replaces the instance via createCameraManager().
|
||||
this._api.getInitializationManager().uninitializeMandatory();
|
||||
this._api.getCameraManager().destroy();
|
||||
return false;
|
||||
}
|
||||
|
||||
public isFullCardIssue(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected _buildDescription(error: NonNullable<unknown>): IssueDescription {
|
||||
const notification = createNotificationFromError(error, {
|
||||
heading: { text: localize('issues.initialization.heading') },
|
||||
});
|
||||
return {
|
||||
icon: 'mdi:alert',
|
||||
severity: 'high',
|
||||
notification: {
|
||||
...notification,
|
||||
controls: [createRetryControl(this.key)],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+21
-16
@@ -4,7 +4,7 @@ import { HomeAssistant } from '../../../ha/types';
|
||||
import { localize } from '../../../localize/localize';
|
||||
import { createInternalCallbackAction } from '../../../utils/action';
|
||||
import { CardActionsAPI } from '../../types';
|
||||
import { Problem, ProblemResult } from '../types';
|
||||
import { Issue, IssueDescription } from '../types';
|
||||
|
||||
const LEGACY_RESOURCE_FILENAME = 'frigate-hass-card.js';
|
||||
|
||||
@@ -28,16 +28,16 @@ const resourcesSchema = z.array(
|
||||
}),
|
||||
);
|
||||
|
||||
export class LegacyResourceProblem implements Problem {
|
||||
export class LegacyResourceIssue implements Issue {
|
||||
public readonly key = 'legacy_resource' as const;
|
||||
|
||||
private _legacyResourceIDs: string[] = [];
|
||||
private _hasCorrectResource = false;
|
||||
private _checked = false;
|
||||
private _triggerUpdate: () => void;
|
||||
private _changeCallback: (() => void) | null;
|
||||
|
||||
constructor(triggerUpdate: () => void) {
|
||||
this._triggerUpdate = triggerUpdate;
|
||||
constructor(changeCallback?: () => void) {
|
||||
this._changeCallback = changeCallback ?? null;
|
||||
}
|
||||
|
||||
public async detectStatic(hass: HomeAssistant): Promise<void> {
|
||||
@@ -76,38 +76,38 @@ export class LegacyResourceProblem implements Problem {
|
||||
}
|
||||
}
|
||||
|
||||
public hasResult(): boolean {
|
||||
public hasIssue(): boolean {
|
||||
return this._checked && this._legacyResourceIDs.length > 0;
|
||||
}
|
||||
|
||||
public getResult(): ProblemResult | null {
|
||||
if (!this.hasResult()) {
|
||||
public getIssue(): IssueDescription | null {
|
||||
if (!this.hasIssue()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = this._hasCorrectResource
|
||||
? localize('problems.legacy_resource.text_both')
|
||||
: localize('problems.legacy_resource.text_only_legacy');
|
||||
? localize('issues.legacy_resource.text_both')
|
||||
: localize('issues.legacy_resource.text_only_legacy');
|
||||
|
||||
return {
|
||||
icon: 'mdi:alert',
|
||||
severity: 'high',
|
||||
notification: {
|
||||
heading: {
|
||||
text: localize('problems.legacy_resource.heading'),
|
||||
text: localize('issues.legacy_resource.heading'),
|
||||
icon: 'mdi:alert',
|
||||
severity: 'high',
|
||||
},
|
||||
text,
|
||||
body: { text },
|
||||
link: {
|
||||
url: TROUBLESHOOTING_LEGACY_RESOURCE_URL,
|
||||
title: localize('problems.troubleshooting_guide'),
|
||||
title: localize('issues.troubleshooting_guide'),
|
||||
},
|
||||
...(this._hasCorrectResource
|
||||
? {
|
||||
controls: [
|
||||
{
|
||||
tooltip: localize('problems.legacy_resource.remove'),
|
||||
tooltip: localize('issues.legacy_resource.remove'),
|
||||
icon: 'mdi:delete',
|
||||
severity: 'high',
|
||||
actions: {
|
||||
@@ -152,9 +152,14 @@ export class LegacyResourceProblem implements Problem {
|
||||
this._checked = false;
|
||||
await this.detectStatic(hass);
|
||||
|
||||
const fixed = !this.hasResult();
|
||||
// Success requires: detection completed cleanly AND found zero legacy
|
||||
// resources. detectStatic swallows WS / schema failures and leaves
|
||||
// _checked false; treating that as "issue gone" would let a failed
|
||||
// verification fetch masquerade as a successful fix. Demand the
|
||||
// positive signal instead.
|
||||
const fixed = this._checked && this._legacyResourceIDs.length === 0;
|
||||
if (fixed) {
|
||||
this._triggerUpdate();
|
||||
this._changeCallback?.();
|
||||
}
|
||||
return fixed;
|
||||
} catch {
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import { ConditionState } from '../../../conditions/types.js';
|
||||
import { Notification } from '../../../config/schema/actions/types.js';
|
||||
import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import { Timer } from '../../../utils/timer.js';
|
||||
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../view/target-id.js';
|
||||
import { isAnyMediaViewName } from '../../../view/view.js';
|
||||
import { CardIssueManagerAPI } from '../../types.js';
|
||||
import { createRetryControl } from '../retry-control.js';
|
||||
import { Issue, IssueDescription } from '../types.js';
|
||||
|
||||
declare module 'issue' {
|
||||
interface IssueTriggerContext {
|
||||
media_load: { targetID: string };
|
||||
}
|
||||
}
|
||||
|
||||
const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
|
||||
|
||||
export class MediaLoadIssue implements Issue {
|
||||
public readonly key = 'media_load' as const;
|
||||
|
||||
private _issueActive = false;
|
||||
private _erroredTargetIDs = new Set<string>();
|
||||
|
||||
// Timer fires when a target has been loading too long without success.
|
||||
private _timer = new Timer();
|
||||
private _timerTargetID: string | null = null;
|
||||
|
||||
private _api: CardIssueManagerAPI;
|
||||
private _onChange: (() => void) | null;
|
||||
|
||||
constructor(api: CardIssueManagerAPI, onChange?: () => void) {
|
||||
this._api = api;
|
||||
this._onChange = onChange ?? null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Explicit trigger — called when a component fires an issue:trigger event.
|
||||
// =========================================================================
|
||||
|
||||
public trigger(context: IssueTriggerContext['media_load']): void {
|
||||
this._erroredTargetIDs.add(context.targetID);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Detection — called by the manager on every state change.
|
||||
// =========================================================================
|
||||
|
||||
public detectDynamic(state: ConditionState): void {
|
||||
if (!isAnyMediaViewName(state.view)) {
|
||||
this._deactivate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.mediaLoadedInfo) {
|
||||
this._handleMediaLoaded(state);
|
||||
} else {
|
||||
this._handleMediaNotLoaded(state);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// State queries — called by the manager to read current state.
|
||||
// =========================================================================
|
||||
|
||||
public hasIssue(): boolean {
|
||||
return this._issueActive;
|
||||
}
|
||||
|
||||
public getIssue(): IssueDescription | null {
|
||||
if (!this._issueActive) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
icon: 'mdi:cctv-off',
|
||||
severity: 'high',
|
||||
notification: this.getNotification(),
|
||||
};
|
||||
}
|
||||
|
||||
public getNotification(): Notification {
|
||||
const targets = new Set(this._erroredTargetIDs);
|
||||
if (this._timerTargetID) {
|
||||
targets.add(this._timerTargetID);
|
||||
}
|
||||
|
||||
return {
|
||||
heading: {
|
||||
text: localize('issues.media_load.heading'),
|
||||
icon: 'mdi:cctv-off',
|
||||
severity: 'high' as const,
|
||||
},
|
||||
body: {
|
||||
text: localize('issues.media_load.text'),
|
||||
},
|
||||
...(targets.size && {
|
||||
metadata: Array.from(targets).map((id) => ({
|
||||
text:
|
||||
id === IMAGE_VIEW_TARGET_ID_SENTINEL
|
||||
? localize('editor.image')
|
||||
: this._api.getCameraManager().getCameraMetadata(id)?.title ?? id,
|
||||
icon: id === IMAGE_VIEW_TARGET_ID_SENTINEL ? 'mdi:image' : 'mdi:cctv',
|
||||
})),
|
||||
}),
|
||||
link: {
|
||||
url: TROUBLESHOOTING_MEDIA_URL,
|
||||
title: localize('issues.troubleshooting_guide'),
|
||||
},
|
||||
controls: [createRetryControl(this.key)],
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Retry — called by the manager to schedule a media reload.
|
||||
// =========================================================================
|
||||
|
||||
public needsRetry(): boolean {
|
||||
return this._issueActive;
|
||||
}
|
||||
|
||||
public retry(): boolean {
|
||||
// Build the set of targets to retry: all errored targets plus the
|
||||
// target the pending timer was tracking (so a user-initiated retry
|
||||
// works even before the timeout fires).
|
||||
const retryTargets = new Set(this._erroredTargetIDs);
|
||||
if (this._timerTargetID) {
|
||||
retryTargets.add(this._timerTargetID);
|
||||
}
|
||||
|
||||
if (!retryTargets.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const view = this._api.getViewManager().getView();
|
||||
const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) };
|
||||
for (const id of retryTargets) {
|
||||
mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Intentionally keep _issueActive, _erroredTargetIDs, and the pending
|
||||
// timer in place. The issue stays visible while the provider
|
||||
// re-attempts loading underneath. If the retry succeeds,
|
||||
// _handleMediaLoaded will clear everything when media:loaded fires. If
|
||||
// it fails silently (e.g. bogus stream name), the error stays visible
|
||||
// immediately — no new 10s grace period.
|
||||
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
|
||||
return false;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle.
|
||||
// =========================================================================
|
||||
|
||||
public reset(): void {
|
||||
this._deactivate();
|
||||
this._erroredTargetIDs.clear();
|
||||
}
|
||||
|
||||
// Stop the pending-load timer so offscreen time doesn't count toward the
|
||||
// 10s threshold. Preserve _issueActive, _erroredTargetIDs, and
|
||||
// _timerTargetID: already-visible errors remain visible on reattach, and
|
||||
// retaining _timerTargetID lets the existing active/target-mismatch guard
|
||||
// in _handleMediaNotLoaded avoid spuriously deactivating the preserved
|
||||
// issue when the same target is still loading on resume. The timer is
|
||||
// re-armed with a fresh window by the next detectDynamic pass.
|
||||
public suspend(): void {
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private helpers.
|
||||
// =========================================================================
|
||||
|
||||
// Media loaded successfully: deactivate and clear the error for this target
|
||||
// so it won't immediately re-trigger on the next evaluation.
|
||||
private _handleMediaLoaded(state: ConditionState): void {
|
||||
this._deactivate();
|
||||
if (state.targetID) {
|
||||
this._erroredTargetIDs.delete(state.targetID);
|
||||
}
|
||||
}
|
||||
|
||||
// Media not yet loaded: activate immediately if there is a known provider
|
||||
// error for this target, otherwise start a timeout to detect slow loads.
|
||||
private _handleMediaNotLoaded(state: ConditionState): void {
|
||||
// No targetID means no provider is actively rendering media (e.g. the
|
||||
// viewer shows "No media to display" instead of showing a player). Don't
|
||||
// start the timeout as there's nothing to wait for.
|
||||
if (!state.targetID) {
|
||||
this._deactivate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._hasError(state)) {
|
||||
this._activate();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetID = state.targetID;
|
||||
|
||||
// When the target changes to one without a known error, clear the active
|
||||
// state so the new target gets its own timeout window instead of
|
||||
// inheriting the previous target's error.
|
||||
if (this._issueActive && this._timerTargetID !== targetID) {
|
||||
this._deactivate();
|
||||
}
|
||||
|
||||
// Start (or restart) the timer for this target.
|
||||
if (!this._timer.isRunning() || this._timerTargetID !== targetID) {
|
||||
this._timerTargetID = targetID;
|
||||
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
|
||||
// Record the error on timeout so retry() knows which epoch to bump.
|
||||
// targetID is guaranteed non-null here — the null case bails at the
|
||||
// top of _handleMediaNotLoaded.
|
||||
this._erroredTargetIDs.add(targetID);
|
||||
this._activate();
|
||||
this._onChange?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _hasError(state: ConditionState): boolean {
|
||||
return !!state.targetID && this._erroredTargetIDs.has(state.targetID);
|
||||
}
|
||||
|
||||
private _activate(): void {
|
||||
this._timer.stop();
|
||||
this._issueActive = true;
|
||||
}
|
||||
|
||||
private _deactivate(): void {
|
||||
this._timer.stop();
|
||||
this._timerTargetID = null;
|
||||
this._issueActive = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createNotificationFromError } from '../../../components-lib/notification/factory.js';
|
||||
import { Notification } from '../../../config/schema/actions/types.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import { CardIssueManagerAPI } from '../../types.js';
|
||||
import { createRetryControl } from '../retry-control.js';
|
||||
import { IssueDescription } from '../types.js';
|
||||
import { AbstractErrorIssue } from './abstract-error-issue.js';
|
||||
|
||||
declare module 'issue' {
|
||||
interface IssueTriggerContext {
|
||||
media_query: { error: unknown };
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaQueryIssue extends AbstractErrorIssue {
|
||||
public readonly key = 'media_query' as const;
|
||||
|
||||
private _api: CardIssueManagerAPI;
|
||||
|
||||
constructor(api: CardIssueManagerAPI) {
|
||||
super();
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public needsRetry(): boolean {
|
||||
return this._error !== null;
|
||||
}
|
||||
|
||||
public retry(): boolean {
|
||||
if (this._error === null) {
|
||||
return false;
|
||||
}
|
||||
this._error = null;
|
||||
this._api.getViewManager().setViewByParametersWithNewQuery();
|
||||
|
||||
// Exclusive retry. No other issue should attempt to retry until the next
|
||||
// evaluation cycle, when we'll know if this was successful.
|
||||
return true;
|
||||
}
|
||||
|
||||
public getNotification(): Notification | null {
|
||||
return this.getIssue()?.notification ?? null;
|
||||
}
|
||||
|
||||
protected _buildDescription(error: NonNullable<unknown>): IssueDescription {
|
||||
return {
|
||||
icon: 'mdi:alert',
|
||||
severity: 'high',
|
||||
notification: {
|
||||
...createNotificationFromError(error, {
|
||||
heading: { text: localize('issues.media_query.heading') },
|
||||
}),
|
||||
controls: [createRetryControl(this.key)],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import { getContextFromError } from '../../../utils/error-context.js';
|
||||
import { CardIssueManagerAPI } from '../../types.js';
|
||||
import { IssueDescription } from '../types.js';
|
||||
import { AbstractErrorIssue } from './abstract-error-issue.js';
|
||||
|
||||
declare module 'issue' {
|
||||
interface IssueTriggerContext {
|
||||
view_incompatible: { error: unknown };
|
||||
}
|
||||
}
|
||||
|
||||
export class ViewIncompatibleIssue extends AbstractErrorIssue {
|
||||
public readonly key = 'view_incompatible' as const;
|
||||
|
||||
private _api: CardIssueManagerAPI;
|
||||
|
||||
constructor(api: CardIssueManagerAPI) {
|
||||
super();
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
// Full-card when no view is available to anchor a popup to (initial load
|
||||
// with an unrealizable default view); popup when an existing view remains
|
||||
// visible underneath (mid-session user action that can't resolve).
|
||||
public isFullCardIssue(): boolean {
|
||||
return !this._api.getViewManager().getView();
|
||||
}
|
||||
|
||||
protected _buildDescription(error: NonNullable<unknown>): IssueDescription {
|
||||
return {
|
||||
icon: 'mdi:video-off',
|
||||
severity: 'high',
|
||||
notification: createNotificationFromText(
|
||||
localize('issues.view_incompatible.text'),
|
||||
{
|
||||
heading: {
|
||||
text: localize('issues.view_incompatible.heading'),
|
||||
icon: 'mdi:video-off',
|
||||
severity: 'high',
|
||||
},
|
||||
context: getContextFromError(error) ?? undefined,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NotificationControl } from '../../config/schema/actions/types.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import { createInternalCallbackAction } from '../../utils/action.js';
|
||||
import { IssueKey } from './types.js';
|
||||
|
||||
export function createRetryControl(key: IssueKey): NotificationControl {
|
||||
return {
|
||||
icon: 'mdi:refresh',
|
||||
tooltip: localize('common.retry'),
|
||||
dismiss: true,
|
||||
actions: {
|
||||
tap_action: createInternalCallbackAction(async (api) => {
|
||||
api.getIssueManager().retry(key, true);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import { summarizeNotification } from '../../components-lib/notification/summarize';
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { Notification } from '../../config/schema/actions/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { isTruthy } from '../../utils/basic';
|
||||
import {
|
||||
Issue,
|
||||
IssueDescription,
|
||||
IssueKey,
|
||||
IssuePresence,
|
||||
IssueReadOnlyState,
|
||||
IssueTriggerContextKey,
|
||||
KeyedIssueDescription,
|
||||
} from './types';
|
||||
|
||||
export class IssueStateManager implements IssueReadOnlyState {
|
||||
private _issues = new Map<IssueKey, Issue>();
|
||||
private _loggedKeys = new Set<IssueKey>();
|
||||
|
||||
// =========================================================================
|
||||
// Setup.
|
||||
// =========================================================================
|
||||
|
||||
public addIssue(issue: Issue): void {
|
||||
this._issues.set(issue.key, issue);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Detection — static (one-shot on init) and dynamic (on every state change).
|
||||
// =========================================================================
|
||||
|
||||
public async detectStatic(hass: HomeAssistant): Promise<void> {
|
||||
for (const issue of this._issues.values()) {
|
||||
await issue.detectStatic?.(hass);
|
||||
this._logIfNew(issue);
|
||||
}
|
||||
}
|
||||
|
||||
public trigger<K extends IssueTriggerContextKey>(
|
||||
key: K,
|
||||
context: IssueTriggerContext[K],
|
||||
): void {
|
||||
const issue = this._issues.get(key);
|
||||
if (!issue) {
|
||||
return;
|
||||
}
|
||||
issue.trigger?.(context);
|
||||
this._logIfNew(issue);
|
||||
}
|
||||
|
||||
public detectDynamic(context: ConditionState): void {
|
||||
for (const issue of this._issues.values()) {
|
||||
issue.detectDynamic?.(context);
|
||||
this._logIfNew(issue);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Queries — read active issue state.
|
||||
// =========================================================================
|
||||
|
||||
public getFullCardIssue(): IssueDescription | null {
|
||||
for (const issue of this._issues.values()) {
|
||||
if (issue.hasIssue() && issue.isFullCardIssue?.()) {
|
||||
return issue.getIssue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public hasFullCardIssue(): boolean {
|
||||
return !!this.getFullCardIssue();
|
||||
}
|
||||
|
||||
public getIssueDescriptions(): KeyedIssueDescription[] {
|
||||
const descriptions: KeyedIssueDescription[] = [];
|
||||
for (const issue of this._issues.values()) {
|
||||
const description = issue.getIssue();
|
||||
if (description) {
|
||||
descriptions.push({ key: issue.key, issue: description });
|
||||
}
|
||||
}
|
||||
return descriptions;
|
||||
}
|
||||
|
||||
public getIssuePresence(): IssuePresence {
|
||||
const presence: IssuePresence = new Map();
|
||||
for (const issue of this._issues.values()) {
|
||||
const description = issue.getIssue();
|
||||
if (description) {
|
||||
presence.set(issue.key, description);
|
||||
}
|
||||
}
|
||||
return presence;
|
||||
}
|
||||
|
||||
public getNotification(key: IssueKey): Notification | null {
|
||||
return this._issues.get(key)?.getNotification?.() ?? null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Retry.
|
||||
// =========================================================================
|
||||
|
||||
public needsRetry(): boolean {
|
||||
return [...this._issues.values()].some((issue) => issue.needsRetry?.());
|
||||
}
|
||||
|
||||
public retry(key?: IssueKey, force?: boolean): void {
|
||||
const issues = key
|
||||
? [this._issues.get(key)].filter(isTruthy)
|
||||
: [...this._issues.values()];
|
||||
|
||||
for (const issue of issues) {
|
||||
if (!force && !issue.needsRetry?.()) {
|
||||
continue;
|
||||
}
|
||||
if (issue.retry?.()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle.
|
||||
// =========================================================================
|
||||
|
||||
public reset(key?: IssueKey): void {
|
||||
const issues = key
|
||||
? [this._issues.get(key)].filter(isTruthy)
|
||||
: [...this._issues.values()];
|
||||
|
||||
for (const issue of issues) {
|
||||
issue.reset?.();
|
||||
}
|
||||
}
|
||||
|
||||
public suspend(): void {
|
||||
for (const issue of this._issues.values()) {
|
||||
issue.suspend?.();
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.reset();
|
||||
this._issues.clear();
|
||||
this._loggedKeys.clear();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private helpers.
|
||||
// =========================================================================
|
||||
|
||||
private _logIfNew(issue: Issue): void {
|
||||
const description = issue.getIssue();
|
||||
if (!description) {
|
||||
// Issue cleared (explicit reset, or self-clear via detectDynamic).
|
||||
// Release the dedupe so the next activation is logged as a new episode.
|
||||
this._loggedKeys.delete(issue.key);
|
||||
return;
|
||||
}
|
||||
if (this._loggedKeys.has(issue.key)) {
|
||||
return;
|
||||
}
|
||||
this._loggedKeys.add(issue.key);
|
||||
const summary = summarizeNotification(description.notification);
|
||||
if (summary) {
|
||||
console.warn(`Advanced Camera Card [issue=${issue.key}]: ${summary}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { IssueTriggerContext } from 'issue';
|
||||
import { ConditionState } from '../../conditions/types';
|
||||
import { Notification } from '../../config/schema/actions/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { Severity } from '../../severity';
|
||||
|
||||
export type IssueKey =
|
||||
| 'config_error'
|
||||
| 'config_upgrade'
|
||||
| 'connection'
|
||||
| 'initialization'
|
||||
| 'legacy_resource'
|
||||
| 'media_load'
|
||||
| 'media_query'
|
||||
| 'view_incompatible';
|
||||
|
||||
export interface IssueDescription {
|
||||
icon: string;
|
||||
severity: Severity;
|
||||
notification: Notification;
|
||||
}
|
||||
|
||||
export interface KeyedIssueDescription {
|
||||
key: IssueKey;
|
||||
issue: IssueDescription;
|
||||
}
|
||||
|
||||
// Map of currently active issues keyed by IssueKey, with each entry's value
|
||||
// being the issue's current rendered description. Stored as a Map (not just
|
||||
// a Set of keys) so that sub-state changes within an issue — e.g.
|
||||
// ConnectionIssue swapping between 'lost' and 'starting' — are reflected as
|
||||
// real value-level diffs to the condition state, triggering re-renders and
|
||||
// any user-defined conditions that depend on issue state.
|
||||
export type IssuePresence = Map<IssueKey, IssueDescription>;
|
||||
export interface IssueReadOnlyState {
|
||||
hasFullCardIssue(): boolean;
|
||||
getFullCardIssue(): IssueDescription | null;
|
||||
getIssueDescriptions(): KeyedIssueDescription[];
|
||||
getIssuePresence(): IssuePresence;
|
||||
getNotification(key: IssueKey): Notification | null;
|
||||
}
|
||||
|
||||
export type IssueTriggerContextKey = keyof IssueTriggerContext;
|
||||
export type IssueTriggerEventData = {
|
||||
[K in IssueTriggerContextKey]: { key: K } & IssueTriggerContext[K];
|
||||
}[IssueTriggerContextKey];
|
||||
|
||||
export interface Issue {
|
||||
readonly key: IssueKey;
|
||||
|
||||
// One-time async detection (WS calls, config checks).
|
||||
detectStatic?(hass?: HomeAssistant): Promise<void>;
|
||||
|
||||
// Ongoing sync evaluation, called on state changes.
|
||||
detectDynamic?(context: ConditionState): void;
|
||||
|
||||
// Explicitly trigger this issue with key-specific context.
|
||||
trigger?(context: IssueTriggerContext[IssueTriggerContextKey]): void;
|
||||
|
||||
hasIssue(): boolean;
|
||||
getIssue(): IssueDescription | null;
|
||||
|
||||
// Whether this issue renders as a full-card display when active. Defaults
|
||||
// to false when absent (popup notification). Issues that take over the
|
||||
// entire card must explicitly return true.
|
||||
isFullCardIssue?(): boolean;
|
||||
|
||||
// Return notification content regardless of active state, for user-initiated
|
||||
// queries (e.g. clicking a loading icon). May return null when content
|
||||
// depends on transient state (e.g. no current error to show).
|
||||
getNotification?(): Notification | null;
|
||||
|
||||
// Whether this issue wants the manager to schedule a retry. Gates
|
||||
// scheduled retries; user-initiated (forced) retries bypass this check.
|
||||
needsRetry?(): boolean;
|
||||
|
||||
// Called by the manager when a retry is due. Returns true to stop the retry
|
||||
// loop (exclusive), false to allow subsequent issues to also retry.
|
||||
retry?(): boolean;
|
||||
|
||||
// Optional user-initiated fix. Not called by the issue infrastructure —
|
||||
// callers (e.g. notification control actions) invoke this directly.
|
||||
fix?(hass: HomeAssistant): Promise<boolean>;
|
||||
|
||||
// Reset internal state (clear errors, stop timers, etc.).
|
||||
reset?(): void;
|
||||
|
||||
// Called when the card is detached. Issues with age-based timers (e.g.
|
||||
// loading-timeout timers) must stop them here so that time spent offscreen
|
||||
// doesn't count against the user. Must preserve already-active issue state
|
||||
// — a full-card issue visible at detach should still be visible on
|
||||
// reattach. No `resume` hook: IssueManager.resume() triggers a normal
|
||||
// evaluate(), so any timer that should restart is re-armed via
|
||||
// detectDynamic against the current condition state.
|
||||
suspend?(): void;
|
||||
}
|
||||
@@ -21,6 +21,14 @@ export class KeyboardStateManager {
|
||||
element.removeEventListener('keydown', this._handleKeydown);
|
||||
element.removeEventListener('keyup', this._handleKeyup);
|
||||
element.removeEventListener('blur', this._handleBlur);
|
||||
|
||||
// Clear state on disconnect. Without listeners the card cannot know
|
||||
// whether a key was released while detached, and stale "down" state
|
||||
// would suppress the next real keydown (e.g. PTZ stop shortcuts).
|
||||
if (Object.keys(this._state).length) {
|
||||
this._state = {};
|
||||
this._processStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleKeydown = (ev: KeyboardEvent): void => {
|
||||
@@ -40,7 +48,7 @@ export class KeyboardStateManager {
|
||||
|
||||
private _handleKeyup = (ev: KeyboardEvent): void => {
|
||||
if (ev.key in this._state && this._state[ev.key].state === 'down') {
|
||||
this._state[ev.key].state = 'up';
|
||||
this._state[ev.key] = { ...this._state[ev.key], state: 'up' as const };
|
||||
this._processStateChange();
|
||||
}
|
||||
};
|
||||
@@ -53,7 +61,10 @@ export class KeyboardStateManager {
|
||||
}
|
||||
};
|
||||
|
||||
// Clone before passing to ConditionStateManager so that subsequent
|
||||
// in-place mutations to this._state don't affect the stored reference,
|
||||
// which would make isEqual comparisons always see the same object.
|
||||
private _processStateChange(): void {
|
||||
this._api.getConditionStateManager().setState({ keys: this._state });
|
||||
this._api.getConditionStateManager().setState({ keys: { ...this._state } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '../const';
|
||||
import { Entity } from '../ha/registry/entity/types';
|
||||
import { supportsFeature } from '../ha/supports-feature';
|
||||
import { localize } from '../localize/localize';
|
||||
import { errorToConsole } from '../utils/basic';
|
||||
import { ViewMedia } from '../view/item';
|
||||
import { ViewItemClassifier } from '../view/item-classifier';
|
||||
@@ -165,12 +164,10 @@ export class MediaPlayerManager {
|
||||
}
|
||||
|
||||
const dashboardConfig = cameraConfig.cast?.dashboard;
|
||||
|
||||
// Guaranteed by schema refinement when cast.method is 'dashboard', but
|
||||
// needed for TypeScript narrowing since refine() doesn't narrow types.
|
||||
if (!dashboardConfig?.dashboard_path || !dashboardConfig?.view_path) {
|
||||
this._api.getMessageManager().setMessageIfHigherPriority({
|
||||
type: 'error',
|
||||
icon: 'mdi:cast',
|
||||
message: localize('error.no_dashboard_or_view'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { AdvancedCameraCardError, Message, MessageType } from '../types';
|
||||
import { errorToConsole } from '../utils/basic';
|
||||
import { CardMessageAPI } from './types';
|
||||
|
||||
type MessagePriority = {
|
||||
[type in MessageType]: number;
|
||||
};
|
||||
|
||||
const MESSAGE_TYPE_PRIORITIES: MessagePriority = {
|
||||
info: 10,
|
||||
error: 20,
|
||||
connection: 30,
|
||||
diagnostics: 40,
|
||||
};
|
||||
|
||||
export class MessageManager {
|
||||
private _message: Message | null = null;
|
||||
private _api: CardMessageAPI;
|
||||
|
||||
constructor(api: CardMessageAPI) {
|
||||
this._api = api;
|
||||
}
|
||||
|
||||
public getMessage(): Message | null {
|
||||
return this._message;
|
||||
}
|
||||
|
||||
public hasMessage(): boolean {
|
||||
return !!this._message;
|
||||
}
|
||||
|
||||
public hasErrorMessage(): boolean {
|
||||
return this._message?.type === 'error';
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
const hadMessage = this.hasMessage();
|
||||
this._message = null;
|
||||
|
||||
if (hadMessage) {
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
}
|
||||
|
||||
public resetType(type: MessageType): void {
|
||||
if (this._message?.type === type) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
public setErrorIfHigherPriority(error: unknown, prefix?: string): void {
|
||||
// This object should accept unknown objects to be able to seamlessly
|
||||
// process arguments to catch() which can only be unknown/any. HA may throw
|
||||
// non Error() based errors.
|
||||
if (!error || typeof error !== 'object' || !('message' in error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
errorToConsole(error);
|
||||
this.setMessageIfHigherPriority({
|
||||
message: prefix ? `${prefix}: ${error.message}` : String(error.message),
|
||||
type: 'error',
|
||||
...(error instanceof AdvancedCameraCardError && { context: error.context }),
|
||||
});
|
||||
}
|
||||
|
||||
public setMessageIfHigherPriority(message: Message): boolean {
|
||||
const resolveMessageType = (message: Message): MessageType => {
|
||||
return message.type ?? 'info';
|
||||
};
|
||||
const currentPriority = this._message
|
||||
? MESSAGE_TYPE_PRIORITIES[resolveMessageType(this._message)]
|
||||
: 0;
|
||||
const newPriority = MESSAGE_TYPE_PRIORITIES[resolveMessageType(message)];
|
||||
|
||||
if (this._message && newPriority < currentPriority) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._message = message;
|
||||
|
||||
// When a message is displayed it effectively unloads the media.
|
||||
this._api.getMediaLoadedInfoManager().clear();
|
||||
this._api.getCardElementManager().scrollReset();
|
||||
this._api.getCardElementManager().update();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { errorToConsole } from '../utils/basic';
|
||||
import { localize } from '../localize/localize';
|
||||
import { AdvancedCameraCardError } from '../types';
|
||||
import { Timer } from '../utils/timer';
|
||||
import { CardMicrophoneAPI, MicrophoneState } from './types';
|
||||
|
||||
export class MicrophoneNotSupportedError extends AdvancedCameraCardError {
|
||||
constructor() {
|
||||
super(localize('error.microphone_not_supported'));
|
||||
}
|
||||
}
|
||||
|
||||
export class MicrophoneManager {
|
||||
private _api: CardMicrophoneAPI;
|
||||
private _stream?: MediaStream | null;
|
||||
@@ -46,9 +53,9 @@ export class MicrophoneManager {
|
||||
return !!navigator.mediaDevices?.getUserMedia;
|
||||
}
|
||||
|
||||
public async connect(): Promise<boolean> {
|
||||
public async connect(): Promise<void> {
|
||||
if (!this.isSupported()) {
|
||||
return false;
|
||||
throw new MicrophoneNotSupportedError();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -57,15 +64,12 @@ export class MicrophoneManager {
|
||||
video: false,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
errorToConsole(e as Error);
|
||||
|
||||
this._stream = null;
|
||||
this._setState();
|
||||
return false;
|
||||
throw e;
|
||||
}
|
||||
this._setDesiredMuteOnStream();
|
||||
this._setState();
|
||||
return true;
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
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>();
|
||||
private _loggedKeys = new Set<ProblemKey>();
|
||||
|
||||
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._logIfNew(problem);
|
||||
}
|
||||
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;
|
||||
this._logIfNew(problem);
|
||||
}
|
||||
if (stateChanged) {
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
}
|
||||
|
||||
private _logIfNew(problem: Problem): void {
|
||||
if (problem.hasResult() && !this._loggedKeys.has(problem.key)) {
|
||||
this._loggedKeys.add(problem.key);
|
||||
const text = problem.getResult()?.notification.text;
|
||||
if (text) {
|
||||
console.warn(`Advanced Camera Card: ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -5,15 +5,11 @@ import { StatusBarConfig } from '../config/schema/status-bar';
|
||||
import { MediaLoadedInfo } from '../types';
|
||||
import { createNotificationAction } from '../utils/action';
|
||||
import { View } from '../view/view';
|
||||
import { KeyedProblemResult, ProblemKey } from './problems/types';
|
||||
import { KeyedIssueDescription } from './issues/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;
|
||||
|
||||
@@ -48,7 +44,7 @@ export class StatusBarItemManager {
|
||||
cameraManager?: CameraManager | null;
|
||||
view?: View | null;
|
||||
mediaLoadedInfo?: MediaLoadedInfo | null;
|
||||
problems?: KeyedProblemResult[] | null;
|
||||
issues?: KeyedIssueDescription[] | null;
|
||||
}): StatusBarItem[] {
|
||||
const cameraMetadata = options?.view?.camera
|
||||
? options?.cameraManager?.getCameraMetadata(options.view.camera)
|
||||
@@ -132,22 +128,18 @@ export class StatusBarItemManager {
|
||||
]
|
||||
: []),
|
||||
|
||||
...(options?.problems ?? [])
|
||||
.filter(
|
||||
({ key }) =>
|
||||
options?.statusConfig?.items[problemKeyToStatusBarKey(key)]?.enabled !==
|
||||
false,
|
||||
)
|
||||
.map(({ key, problem }) => ({
|
||||
...(options?.statusConfig?.items.issues?.enabled === false
|
||||
? []
|
||||
: (options?.issues ?? []).map(({ issue }) => ({
|
||||
type: 'custom:advanced-camera-card-status-bar-icon' as const,
|
||||
icon: problem.icon,
|
||||
severity: problem.severity,
|
||||
title: problem.notification.heading?.text,
|
||||
icon: issue.icon,
|
||||
severity: issue.severity,
|
||||
title: issue.notification.heading?.text,
|
||||
actions: {
|
||||
tap_action: createNotificationAction(problem.notification),
|
||||
tap_action: createNotificationAction(issue.notification),
|
||||
},
|
||||
...options?.statusConfig?.items[problemKeyToStatusBarKey(key)],
|
||||
})),
|
||||
...options?.statusConfig?.items.issues,
|
||||
}))),
|
||||
...this._dynamicItems,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export class TriggersManager {
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
let triggered = false;
|
||||
let startupActionEvent: CameraEvent | null = null;
|
||||
this._states.clear();
|
||||
this.reset();
|
||||
|
||||
for (const [cameraID, camera] of this._api
|
||||
.getCameraManager()
|
||||
@@ -395,6 +395,20 @@ export class TriggersManager {
|
||||
}
|
||||
}
|
||||
|
||||
private _stopAllTimers(): void {
|
||||
for (const [cameraID] of this._states) {
|
||||
this._deleteUntriggerDelayTimer(cameraID);
|
||||
this._deleteForceUntriggerTimer(cameraID);
|
||||
}
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._throttledTriggerAction.cancel();
|
||||
this._stopAllTimers();
|
||||
this._states.clear();
|
||||
this._setConditionStateIfNecessary();
|
||||
}
|
||||
|
||||
private _isStateTriggered(state: CameraTriggerState): boolean {
|
||||
return !!(state.sources.size || state.untriggerDelayTimer);
|
||||
}
|
||||
|
||||
@@ -17,14 +17,13 @@ import type { FullscreenManager } from './fullscreen/fullscreen-manager';
|
||||
import type { HASSManager } from './hass/hass-manager';
|
||||
import type { InitializationManager } from './initialization-manager';
|
||||
import type { InteractionManager } from './interaction-manager';
|
||||
import type { IssueManager } from './issues/issue-manager';
|
||||
import type { KeyboardStateManager } from './keyboard-state-manager';
|
||||
import type { MediaLoadedInfoManager } from './media-info-manager';
|
||||
import type { MediaPlayerManager } from './media-player-manager';
|
||||
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';
|
||||
@@ -53,10 +52,10 @@ export interface CardActionsAPI {
|
||||
getHASSManager(): HASSManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getNotificationManager(): NotificationManager;
|
||||
getPIPManager(): PIPManager;
|
||||
getIssueManager(): IssueManager;
|
||||
getStatusBarItemManager(): StatusBarItemManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getViewItemManager(): ViewItemManager;
|
||||
@@ -70,7 +69,8 @@ export interface CardAutomationsAPI {
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getInitializationManager(): InitializationManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getNotificationManager(): NotificationManager;
|
||||
getIssueManager(): IssueManager;
|
||||
}
|
||||
|
||||
export interface CardCameraAPI {
|
||||
@@ -79,7 +79,6 @@ export interface CardCameraAPI {
|
||||
getDeviceRegistryManager(): DeviceRegistryManager;
|
||||
getEntityRegistryManager(): EntityRegistryManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getResolvedMediaCache(): ResolvedMediaCache;
|
||||
getTriggersManager(): TriggersManager;
|
||||
}
|
||||
@@ -106,8 +105,8 @@ export interface CardConfigAPI {
|
||||
getInitializationManager(): InitializationManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getIssueManager(): IssueManager;
|
||||
getStatusBarItemManager(): StatusBarItemManager;
|
||||
getStyleManager(): StyleManager;
|
||||
getViewManager(): ViewManager;
|
||||
@@ -118,7 +117,7 @@ export interface CardConfigLoaderAPI {
|
||||
getConfigManager(): ConfigManager;
|
||||
getFoldersManager(): FoldersManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getIssueManager(): IssueManager;
|
||||
}
|
||||
|
||||
export interface CardDefaultManagerAPI {
|
||||
@@ -134,7 +133,7 @@ export interface CardDownloadAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getNotificationManager(): NotificationManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
@@ -154,8 +153,9 @@ export interface CardElementAPI {
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
getPIPManager(): PIPManager;
|
||||
getProblemManager(): ProblemManager;
|
||||
getIssueManager(): IssueManager;
|
||||
getQueryStringManager(): QueryStringManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
@@ -193,7 +193,6 @@ export interface CardHASSAPI {
|
||||
getInitializationManager(): InitializationManager;
|
||||
getInteractionManager(): InteractionManager;
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getStyleManager(): StyleManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
getViewManager(): ViewManager;
|
||||
@@ -209,13 +208,11 @@ export interface CardInitializerAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getDefaultManager(): DefaultManager;
|
||||
getEntityRegistryManager(): EntityRegistryManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getMediaPlayerManager(): MediaPlayerManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getProblemManager(): ProblemManager;
|
||||
getIssueManager(): IssueManager;
|
||||
getQueryStringManager(): QueryStringManager;
|
||||
getResolvedMediaCache(): ResolvedMediaCache;
|
||||
getTriggersManager(): TriggersManager;
|
||||
@@ -250,25 +247,22 @@ export interface CardMediaPlayerAPI {
|
||||
getConfigManager(): ConfigManager;
|
||||
getEntityRegistryManager(): EntityRegistryManager;
|
||||
getHASSManager(): HASSManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getQueryStringManager(): QueryStringManager;
|
||||
}
|
||||
|
||||
export interface CardMessageAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
}
|
||||
|
||||
export interface CardNotificationAPI {
|
||||
getCardElementManager(): CardElementManager;
|
||||
}
|
||||
|
||||
export interface CardProblemAPI {
|
||||
export interface CardIssueManagerAPI {
|
||||
getCameraManager(): CameraManager;
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getInitializationManager(): InitializationManager;
|
||||
getInteractionManager(): InteractionManager;
|
||||
getNotificationManager(): NotificationManager;
|
||||
getViewManager(): ViewManager;
|
||||
}
|
||||
|
||||
export interface CardMicrophoneAPI {
|
||||
@@ -318,7 +312,8 @@ export interface CardViewAPI {
|
||||
getHASSManager(): HASSManager;
|
||||
getInitializationManager(): InitializationManager;
|
||||
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||
getMessageManager(): MessageManager;
|
||||
getNotificationManager(): NotificationManager;
|
||||
getIssueManager(): IssueManager;
|
||||
getQueryStringManager(): QueryStringManager;
|
||||
getStyleManager(): StyleManager;
|
||||
getTriggersManager(): TriggersManager;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AdvancedCameraCardView } from '../../config/schema/common/const';
|
||||
import { ViewDisplayMode } from '../../config/schema/common/display';
|
||||
import { AdvancedCameraCardConfig } from '../../config/schema/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { resolveViewName } from '../../view/utils/resolve-default';
|
||||
import { View, ViewParameters } from '../../view/view';
|
||||
import {
|
||||
@@ -182,16 +181,16 @@ export class ViewFactory {
|
||||
if (options?.failSafe && !doesViewRequireCamera(defaultViewName)) {
|
||||
return { viewName: defaultViewName, cameraID: null };
|
||||
}
|
||||
const cameraID = this._api.getCameraManager().getStore().getDefaultCameraID();
|
||||
if (options?.failSafe) {
|
||||
return {
|
||||
viewName: defaultViewName,
|
||||
cameraID: this._api.getCameraManager().getStore().getDefaultCameraID(),
|
||||
cameraID,
|
||||
};
|
||||
}
|
||||
throw new ViewIncompatible(localize('error.no_supported_cameras'), {
|
||||
throw new ViewIncompatible({
|
||||
view: viewName,
|
||||
camera: null,
|
||||
default_view: defaultViewName,
|
||||
camera: cameraID,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -221,10 +220,9 @@ export class ViewFactory {
|
||||
?.getCapabilities()
|
||||
?.getRawCapabilities();
|
||||
|
||||
throw new ViewIncompatible(localize('error.no_supported_camera'), {
|
||||
throw new ViewIncompatible({
|
||||
view: viewName,
|
||||
camera: cameraID,
|
||||
default_view: defaultViewName,
|
||||
...(capabilities && { camera_capabilities: capabilities }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { format } from 'date-fns';
|
||||
import { createNotificationFromError } from '../../components-lib/notification/factory';
|
||||
import { homeAssistantGetSignedURLIfNecessary } from '../../ha/sign-path';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { AdvancedCameraCardError } from '../../types';
|
||||
@@ -37,7 +38,15 @@ export class ViewItemManager {
|
||||
try {
|
||||
await this._download(item);
|
||||
} catch (error: unknown) {
|
||||
this._api.getMessageManager().setErrorIfHigherPriority(error);
|
||||
/* istanbul ignore if: catch always provides a non-null error -- @preserve */
|
||||
if (error == null) {
|
||||
return false;
|
||||
}
|
||||
this._api.getNotificationManager().setNotification(
|
||||
createNotificationFromError(error, {
|
||||
heading: { text: localize('error.download_failed'), icon: 'mdi:download-off' },
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -75,4 +75,8 @@ export interface ViewManagerInterface {
|
||||
hasMajorMediaChange(oldView?: View | null, newView?: View | null): boolean;
|
||||
}
|
||||
|
||||
export class ViewIncompatible extends AdvancedCameraCardError {}
|
||||
export class ViewIncompatible extends AdvancedCameraCardError {
|
||||
constructor(context?: unknown) {
|
||||
super('', context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ViewContext } from 'view';
|
||||
import { log } from '../../utils/debug';
|
||||
import { getStreamCameraID } from '../../utils/substream';
|
||||
import { View } from '../../view/view';
|
||||
import { getViewTargetID } from '../../view/target-id';
|
||||
import { InitializationAspect } from '../initialization-manager';
|
||||
import { CardViewAPI } from '../types';
|
||||
import { ViewFactory } from './factory';
|
||||
@@ -111,19 +112,39 @@ export class ViewManager implements ViewManagerInterface {
|
||||
baseView: this._view,
|
||||
...options,
|
||||
});
|
||||
// A non-throwing factory call clears any prior view_incompatible /
|
||||
// media_query state — ensures a previously-dismissed mid-session popup
|
||||
// does not linger invisibly and re-pop on the next evaluation cycle,
|
||||
// and that a stale media_query failure from an abandoned gallery /
|
||||
// viewer doesn't follow the user into an unrelated view.
|
||||
this._api.getIssueManager().reset('view_incompatible');
|
||||
this._api.getIssueManager().reset('media_query');
|
||||
} catch (e) {
|
||||
this._api.getMessageManager().setErrorIfHigherPriority(e);
|
||||
if (!this._view) {
|
||||
view = this._getFailSafeView(viewFactoryFunc);
|
||||
}
|
||||
this._api.getIssueManager().trigger('view_incompatible', { error: e });
|
||||
}
|
||||
if (view) {
|
||||
this._setView(view);
|
||||
}
|
||||
}
|
||||
|
||||
private _markViewLoadingQuery(view: View, index: number): View {
|
||||
return view.mergeInContext({ loading: { query: index } });
|
||||
private _getFailSafeView(
|
||||
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
|
||||
): View | null {
|
||||
try {
|
||||
return viewFactoryFunc({ baseView: null, failSafe: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
private _markViewAsNotLoadingQuery(view: View): View {
|
||||
return view.removeContextProperty('loading', 'query');
|
||||
}
|
||||
|
||||
private _markViewLoadingQuery(view: View, index: number): void {
|
||||
view.mergeInContext({ loading: { query: index } });
|
||||
}
|
||||
private _markViewAsNotLoadingQuery(view: View): void {
|
||||
view.removeContextProperty('loading', 'query');
|
||||
}
|
||||
|
||||
private _isAllowedToSetView(): boolean {
|
||||
@@ -162,8 +183,16 @@ export class ViewManager implements ViewManagerInterface {
|
||||
...options?.params,
|
||||
},
|
||||
});
|
||||
this._api.getIssueManager().reset('view_incompatible');
|
||||
// A new query is about to run, so any stale media_query error from a
|
||||
// previous attempt is no longer meaningful. If this new query also
|
||||
// fails, it will re-trigger below.
|
||||
this._api.getIssueManager().reset('media_query');
|
||||
} catch (e) {
|
||||
this._api.getMessageManager().setErrorIfHigherPriority(e);
|
||||
if (!this._view) {
|
||||
initialView = this._getFailSafeView(viewFactoryFunc);
|
||||
}
|
||||
this._api.getIssueManager().trigger('view_incompatible', { error: e });
|
||||
}
|
||||
|
||||
if (!initialView) {
|
||||
@@ -205,13 +234,23 @@ export class ViewManager implements ViewManagerInterface {
|
||||
// the contrary, small changes such as the user zooming in are fine to
|
||||
// merge into the resultant view.
|
||||
if (this._view.context?.loading?.query === loadingIndex) {
|
||||
this._setView(this._markViewAsNotLoadingQuery(this._view.clone()));
|
||||
const view = this._view.clone();
|
||||
this._markViewAsNotLoadingQuery(view);
|
||||
this._setView(view);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
this._api.getMessageManager().setErrorIfHigherPriority(error);
|
||||
// Clear the loading flag before surfacing the error. Otherwise the
|
||||
// view stays marked in-flight and components (gallery, viewer) keep
|
||||
// rendering "Awaiting media" on top of the error notification.
|
||||
if (this._view?.context?.loading?.query === loadingIndex) {
|
||||
const view = this._view.clone();
|
||||
this._markViewAsNotLoadingQuery(view);
|
||||
this._setView(view);
|
||||
}
|
||||
this._api.getIssueManager().trigger('media_query', { error });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -221,6 +260,8 @@ export class ViewManager implements ViewManagerInterface {
|
||||
return;
|
||||
}
|
||||
|
||||
this._api.getIssueManager().reset('media_query');
|
||||
|
||||
const newView = this._view.clone();
|
||||
if (this._view.context?.loading?.query === loadingIndex) {
|
||||
this._markViewAsNotLoadingQuery(newView);
|
||||
@@ -279,7 +320,7 @@ export class ViewManager implements ViewManagerInterface {
|
||||
);
|
||||
}
|
||||
|
||||
public initialize = async (): Promise<boolean> => {
|
||||
public initialize = async (): Promise<void> => {
|
||||
// If the query string contains a view related action, we don't set any view
|
||||
// here and allow that action to be triggered by the next call of to execute
|
||||
// query actions (called at least once per render cycle).
|
||||
@@ -289,7 +330,6 @@ export class ViewManager implements ViewManagerInterface {
|
||||
// query is answered.
|
||||
this.setViewDefaultWithNewQuery({ failSafe: true });
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
private _setView(view: Readonly<View> | null): void {
|
||||
@@ -312,13 +352,13 @@ export class ViewManager implements ViewManagerInterface {
|
||||
this._api.getCardElementManager().scrollReset();
|
||||
}
|
||||
|
||||
this._api.getMessageManager().reset();
|
||||
this._api.getStyleManager().setExpandedMode();
|
||||
|
||||
this._api.getConditionStateManager()?.setState({
|
||||
view: view?.view,
|
||||
camera: view?.camera ?? undefined,
|
||||
displayMode: view?.displayMode ?? undefined,
|
||||
targetID: view ? getViewTargetID(view) ?? undefined : undefined,
|
||||
});
|
||||
|
||||
this._api.getCardElementManager().update();
|
||||
|
||||
+36
-28
@@ -7,10 +7,7 @@ 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 type { IssueKey, IssueTriggerEventData } from './card-controller/issues/types.js';
|
||||
import { MenuButtonController } from './components-lib/menu-button-controller';
|
||||
import './components/effects/effects';
|
||||
import './components/elements.js';
|
||||
@@ -18,9 +15,9 @@ import { AdvancedCameraCardElements } from './components/elements.js';
|
||||
import './components/loading.js';
|
||||
import './components/menu.js';
|
||||
import { AdvancedCameraCardMenu } from './components/menu.js';
|
||||
import './components/message.js';
|
||||
import { renderMessage } from './components/message.js';
|
||||
import './components/notification.js';
|
||||
import './components/notification/block.js';
|
||||
import { renderNotificationBlock } from './components/notification/block.js';
|
||||
import './components/notification/popup.js';
|
||||
import './components/overlay.js';
|
||||
import { AdvancedCameraCardOverlay } from './components/overlay.js';
|
||||
import './components/status-bar';
|
||||
@@ -36,7 +33,7 @@ import { REPO_URL } from './const.js';
|
||||
import { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
|
||||
import { localize } from './localize/localize.js';
|
||||
import cardStyle from './scss/card.scss';
|
||||
import { MediaLoadedInfo, Message } from './types.js';
|
||||
import { MediaLoadedInfo } from './types.js';
|
||||
import { hasAction } from './utils/action.js';
|
||||
import { getReleaseVersion } from './utils/diagnostics';
|
||||
|
||||
@@ -167,16 +164,14 @@ class AdvancedCameraCard extends LitElement {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always allow messages to render, as a message may be generated during
|
||||
// Always allow blocking issues to render, as they may be generated during
|
||||
// initialization.
|
||||
if (this._controller.getMessageManager().hasMessage()) {
|
||||
if (this._controller.getIssueManager().getStateManager().hasFullCardIssue()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this._controller.getInitializationManager().isInitializedMandatory()) {
|
||||
/* async */ this._controller.getInitializationManager().initializeMandatory();
|
||||
} else if (!this._controller.getInitializationManager().isInitializedBackground()) {
|
||||
/* async */ this._controller.getInitializationManager().initializeBackground();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -286,6 +281,15 @@ class AdvancedCameraCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Suppress the status bar entirely while a full-card issue (config
|
||||
// error, lost connection, failed initialization) is rendering. Those
|
||||
// take over the card and the status bar would be either redundant
|
||||
// chrome (showing the engine icon etc. over an error) or physically
|
||||
// obscuring the notification (in popup/overlay styles).
|
||||
if (this._controller.getIssueManager().getStateManager().hasFullCardIssue()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return html`
|
||||
<advanced-camera-card-status-bar
|
||||
slot=${ifDefined(slot)}
|
||||
@@ -294,7 +298,10 @@ class AdvancedCameraCard extends LitElement {
|
||||
cameraManager: this._controller.getCameraManager(),
|
||||
view: this._controller.getViewManager().getView(),
|
||||
mediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(),
|
||||
problems: this._controller.getProblemManager().getProblemResults(),
|
||||
issues: this._controller
|
||||
.getIssueManager()
|
||||
.getStateManager()
|
||||
.getIssueDescriptions(),
|
||||
})}
|
||||
.config=${this._config.status_bar}
|
||||
></advanced-camera-card-status-bar>
|
||||
@@ -346,10 +353,14 @@ class AdvancedCameraCard extends LitElement {
|
||||
|
||||
const actions = this._controller.getActionsManager().getMergedActions();
|
||||
const cameraManager = this._controller.getCameraManager();
|
||||
const fullCardIssue = this._controller
|
||||
.getIssueManager()
|
||||
.getStateManager()
|
||||
.getFullCardIssue();
|
||||
|
||||
const showLoading =
|
||||
this._config?.performance?.features.card_loading_indicator !== false &&
|
||||
!this._controller.getMessageManager().hasMessage();
|
||||
!fullCardIssue;
|
||||
|
||||
// Caution: Keep the main div and the menu next to one another in order to
|
||||
// ensure the hover menu styling continues to work.
|
||||
@@ -364,19 +375,17 @@ class AdvancedCameraCard extends LitElement {
|
||||
hasDoubleClick: hasAction(actions.double_tap_action),
|
||||
})}
|
||||
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
|
||||
@advanced-camera-card:message=${(ev: CustomEvent<Message>) =>
|
||||
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
||||
@advanced-camera-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
|
||||
this._controller.getMediaLoadedInfoManager().set(ev.detail);
|
||||
}}
|
||||
@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=${({
|
||||
@advanced-camera-card:issue:notify=${(ev: CustomEvent<IssueKey>) =>
|
||||
this._controller.getIssueManager().showNotification(ev.detail)}
|
||||
@advanced-camera-card:issue:trigger=${({
|
||||
detail: { key, ...context },
|
||||
}: CustomEvent<ProblemTriggerEventData>) =>
|
||||
this._controller.getProblemManager().trigger(key, context)}
|
||||
}: CustomEvent<IssueTriggerEventData>) =>
|
||||
this._controller.getIssueManager().trigger(key, context)}
|
||||
@advanced-camera-card:media:volumechange=${
|
||||
() => this.requestUpdate() /* Refresh mute menu button */
|
||||
}
|
||||
@@ -416,20 +425,19 @@ class AdvancedCameraCard extends LitElement {
|
||||
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
|
||||
.rawConfig=${this._controller.getConfigManager().getRawConfig()}
|
||||
.configManager=${this._controller.getConfigManager()}
|
||||
.hide=${!!this._controller.getMessageManager().hasMessage()}
|
||||
.hide=${!!fullCardIssue}
|
||||
.microphoneState=${this._controller.getMicrophoneManager().getState()}
|
||||
.conditionStateManager=${this._controller.getConditionStateManager()}
|
||||
.triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status
|
||||
? this._controller.getTriggersManager().getTriggeredCameraIDs()
|
||||
: undefined}
|
||||
.deviceRegistryManager=${this._controller.getDeviceRegistryManager()}
|
||||
.problems=${this._controller.getProblemManager().getProblemPresence()}
|
||||
.issues=${this._controller
|
||||
.getIssueManager()
|
||||
.getStateManager()
|
||||
.getIssuePresence()}
|
||||
></advanced-camera-card-views>
|
||||
${this._controller.getMessageManager().hasMessage()
|
||||
? // Keep message rendering to last to show messages that may have been
|
||||
// generated during the render.
|
||||
renderMessage(this._controller.getMessageManager().getMessage())
|
||||
: ''}
|
||||
${fullCardIssue ? renderNotificationBlock(fullCardIssue.notification) : ''}
|
||||
</div>
|
||||
${this._renderMenuStatusContainer('bottom')}
|
||||
${this._config?.elements
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AdvancedCameraCardMediaLoadedEventTarget,
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
} from '../../utils/media-info.js';
|
||||
import { AdvancedCameraCardMessageEventTarget } from '../message/dispatch.js';
|
||||
|
||||
interface LiveViewContext {
|
||||
// A cameraID override (used for dependencies/substreams to force a different
|
||||
@@ -23,9 +22,7 @@ interface LastMediaLoadedInfo {
|
||||
source: EventTarget;
|
||||
}
|
||||
|
||||
type LiveControllerHost = LitElement &
|
||||
AdvancedCameraCardMediaLoadedEventTarget &
|
||||
AdvancedCameraCardMessageEventTarget;
|
||||
type LiveControllerHost = LitElement & AdvancedCameraCardMediaLoadedEventTarget;
|
||||
|
||||
export class LiveController implements ReactiveController {
|
||||
private _host: LiveControllerHost;
|
||||
|
||||
+14
-12
@@ -33,8 +33,8 @@ export interface NotificationControlsContext {
|
||||
filterReviewed?: boolean;
|
||||
}
|
||||
|
||||
export class MediaDetailsController {
|
||||
private _details: NotificationDetail[] = [];
|
||||
export class MediaNotificationController {
|
||||
private _metadata: NotificationDetail[] = [];
|
||||
private _heading: NotificationDetail | null = null;
|
||||
private _item: ViewItem | null = null;
|
||||
|
||||
@@ -50,7 +50,7 @@ export class MediaDetailsController {
|
||||
: null;
|
||||
|
||||
this._calculateHeading(cameraMetadata, item);
|
||||
this._calculateDetails(cameraMetadata, item, seek);
|
||||
this._calculateMetadata(cameraMetadata, item, seek);
|
||||
}
|
||||
|
||||
private _calculateHeading(
|
||||
@@ -99,7 +99,7 @@ export class MediaDetailsController {
|
||||
this._heading = null;
|
||||
}
|
||||
|
||||
private _calculateDetails(
|
||||
private _calculateMetadata(
|
||||
cameraMetadata: CameraManagerCameraMetadata | null,
|
||||
item?: ViewItem,
|
||||
seek?: Date,
|
||||
@@ -111,7 +111,7 @@ export class MediaDetailsController {
|
||||
const duration = startTime && endTime ? getDurationString(startTime, endTime) : null;
|
||||
const inProgress = ViewItemClassifier.isMedia(item)
|
||||
? item.inProgress()
|
||||
? localize('thumbnail.in_progress')
|
||||
? localize('common.in_progress')
|
||||
: null
|
||||
: null;
|
||||
const where = ViewItemClassifier.isMedia(item)
|
||||
@@ -184,7 +184,7 @@ export class MediaDetailsController {
|
||||
const includeTitle =
|
||||
(!ViewItemClassifier.isEvent(item) && !ViewItemClassifier.isReview(item)) ||
|
||||
!startTime;
|
||||
this._details = [
|
||||
this._metadata = [
|
||||
...(includeTitle && itemTitle
|
||||
? [
|
||||
{
|
||||
@@ -204,18 +204,20 @@ export class MediaDetailsController {
|
||||
return this._heading;
|
||||
}
|
||||
|
||||
public getDetails(): NotificationDetail[] {
|
||||
return this._details;
|
||||
public getMetadata(): NotificationDetail[] {
|
||||
return this._metadata;
|
||||
}
|
||||
|
||||
public getNotification(context?: NotificationControlsContext): Notification {
|
||||
const description = ViewItemClassifier.isMedia(this._item)
|
||||
? this._item.getDescription()
|
||||
: null;
|
||||
|
||||
return {
|
||||
heading: this._heading ?? undefined,
|
||||
controls: context ? this._getControls(context) : undefined,
|
||||
details: this._details,
|
||||
text: ViewItemClassifier.isMedia(this._item)
|
||||
? this._item.getDescription() ?? undefined
|
||||
: undefined,
|
||||
metadata: this._metadata,
|
||||
body: description ? { text: description } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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 } from '../../types';
|
||||
|
||||
export class MessageController {
|
||||
public getMessageString(message: Message): string {
|
||||
return (
|
||||
message.message +
|
||||
(message.context && typeof message.context === 'string'
|
||||
? ': ' + message.context
|
||||
: '')
|
||||
);
|
||||
}
|
||||
|
||||
public getIcon(message: Message): string {
|
||||
return message.icon
|
||||
? message.icon
|
||||
: message.type === 'error'
|
||||
? 'mdi:alert-circle'
|
||||
: 'mdi:information-outline';
|
||||
}
|
||||
|
||||
public getLink(message: Message): Link | null {
|
||||
return message.link
|
||||
? message.link
|
||||
: message.type === 'error'
|
||||
? { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') }
|
||||
: null;
|
||||
}
|
||||
|
||||
public getContextStrings(message: Message): string[] {
|
||||
if (Array.isArray(message.context)) {
|
||||
return message.context.map((contextItem) => yaml.dump(contextItem));
|
||||
}
|
||||
if (typeof message.context === 'object') {
|
||||
return [yaml.dump(message.context)];
|
||||
}
|
||||
if (typeof message.context === 'string') {
|
||||
return [message.context];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { AdvancedCameraCardError, Message } from '../../types';
|
||||
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
|
||||
|
||||
// Facilitates correct typing of event handlers.
|
||||
export interface AdvancedCameraCardMessageEventTarget extends EventTarget {
|
||||
addEventListener(
|
||||
event: 'advanced-camera-card:message',
|
||||
listener: (
|
||||
this: AdvancedCameraCardMessageEventTarget,
|
||||
ev: CustomEvent<Message>,
|
||||
) => void,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void;
|
||||
addEventListener(
|
||||
type: string,
|
||||
callback: EventListenerOrEventListenerObject,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void;
|
||||
removeEventListener(
|
||||
event: 'advanced-camera-card:message',
|
||||
listener: (
|
||||
this: AdvancedCameraCardMessageEventTarget,
|
||||
ev: CustomEvent<Message>,
|
||||
) => void,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
callback: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event with an error message to show to the user. Calling this
|
||||
* method will grind the card to a halt, so should only be used for "global" /
|
||||
* critical errors (i.e. not for individual errors with a given camera, since
|
||||
* there may be multiple correctly functioning cameras in a grid).
|
||||
* @param element The element to send the event.
|
||||
* @param message The message to show.
|
||||
*/
|
||||
export const dispatchAdvancedCameraCardErrorEvent = (
|
||||
element: EventTarget,
|
||||
error: unknown,
|
||||
): void => {
|
||||
if (error instanceof Error) {
|
||||
fireAdvancedCameraCardEvent<Message>(element, 'message', {
|
||||
message: error.message,
|
||||
type: 'error',
|
||||
...(error instanceof AdvancedCameraCardError && { context: error.context }),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { dispatchActionExecutionRequest } from '../../card-controller/actions/utils/execution-request.js';
|
||||
import { NotificationControl } from '../../config/schema/actions/types.js';
|
||||
import {
|
||||
getActionConfigGivenAction,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../../utils/action.js';
|
||||
import { arrayify } from '../../utils/basic.js';
|
||||
|
||||
export function handleControlAction(
|
||||
ev: CustomEvent<{ action: string }>,
|
||||
control: NotificationControl,
|
||||
host: HTMLElement,
|
||||
onDismiss?: () => void,
|
||||
): void {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
const action = getActionConfigGivenAction(ev.detail.action, control.actions);
|
||||
if (action) {
|
||||
dispatchActionExecutionRequest(host, { actions: arrayify(action) });
|
||||
}
|
||||
if (onDismiss && control.dismiss !== false) {
|
||||
onDismiss();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
// Converts a structured data object into preformatted text strings for a
|
||||
// notification's context section. Arrays produce one string per item (strings
|
||||
// pass through; objects are YAML-dumped); all other objects produce a single
|
||||
// YAML-dumped string.
|
||||
export const dataToContext = (data: object): string[] => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => (typeof item === 'string' ? item : yaml.dump(item)));
|
||||
}
|
||||
return [yaml.dump(data)];
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Notification, NotificationDetail } from '../../config/schema/actions/types.js';
|
||||
import { Link } from '../../config/schema/common/link.js';
|
||||
import { getContextFromError } from '../../utils/error-context.js';
|
||||
import { dataToContext } from './data-to-context.js';
|
||||
|
||||
const DEFAULT_ERROR_ICON = 'mdi:alert';
|
||||
|
||||
export interface NotificationOptions {
|
||||
heading?: NotificationDetail;
|
||||
icon?: string;
|
||||
link?: Link;
|
||||
metadata?: NotificationDetail[];
|
||||
context?: object;
|
||||
in_progress?: boolean;
|
||||
}
|
||||
|
||||
export const createNotificationFromText = (
|
||||
text: string,
|
||||
options?: NotificationOptions,
|
||||
): Notification => ({
|
||||
...(options?.heading && { heading: options.heading }),
|
||||
body: {
|
||||
text,
|
||||
...((options?.icon || !options?.heading) && {
|
||||
icon: options?.icon ?? DEFAULT_ERROR_ICON,
|
||||
}),
|
||||
},
|
||||
...(options?.metadata && { metadata: options.metadata }),
|
||||
...(options?.link && { link: options.link }),
|
||||
...(options?.context && {
|
||||
context: dataToContext(options.context),
|
||||
}),
|
||||
...(options?.in_progress !== undefined && { in_progress: options.in_progress }),
|
||||
});
|
||||
|
||||
export const createNotificationFromError = (
|
||||
error: NonNullable<unknown>,
|
||||
options?: NotificationOptions,
|
||||
): Notification => {
|
||||
const text =
|
||||
typeof error === 'object' && 'message' in error
|
||||
? String(error.message)
|
||||
: typeof error === 'object'
|
||||
? JSON.stringify(error)
|
||||
: String(error);
|
||||
|
||||
const context = options?.context ?? getContextFromError(error) ?? undefined;
|
||||
|
||||
return createNotificationFromText(text, {
|
||||
...options,
|
||||
context,
|
||||
heading: options?.heading
|
||||
? { icon: DEFAULT_ERROR_ICON, severity: 'high', ...options.heading }
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Notification } from '../../config/schema/actions/types.js';
|
||||
|
||||
export const summarizeNotification = (notification: Notification): string | null => {
|
||||
const text = notification.body?.text ?? notification.heading?.text ?? null;
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const metadata = notification.metadata?.map((m) => m.text).join(', ');
|
||||
return metadata ? `${text} [${metadata}]` : text;
|
||||
};
|
||||
@@ -35,11 +35,25 @@ export class StatusBarController {
|
||||
const sufficientBefore = this._getSufficientValues(this._items);
|
||||
const sufficientAfter = this._getSufficientValues(newItems);
|
||||
|
||||
const hadPermanent = this._hasPermanentItems(this._items);
|
||||
this._items = newItems;
|
||||
|
||||
if (this._config?.style === 'popup' && !isEqual(sufficientBefore, sufficientAfter)) {
|
||||
if (this._config?.style === 'popup') {
|
||||
const hasPermanent = this._hasPermanentItems(newItems);
|
||||
const sufficientChanged = !isEqual(sufficientBefore, sufficientAfter);
|
||||
|
||||
if (hasPermanent) {
|
||||
// Permanent items present: show and cancel any running timer.
|
||||
this._show();
|
||||
this._popupTimer.stop();
|
||||
} else if (sufficientChanged) {
|
||||
// Sufficient items changed without permanent items: normal popup.
|
||||
this._show();
|
||||
this._popupTimer.start(this._config.popup_seconds, () => this._hide());
|
||||
} else if (hadPermanent && !hasPermanent) {
|
||||
// Permanent items just removed: start the popup timer to fade out.
|
||||
this._popupTimer.start(this._config.popup_seconds, () => this._hide());
|
||||
}
|
||||
}
|
||||
|
||||
this._host.requestUpdate();
|
||||
@@ -55,7 +69,7 @@ export class StatusBarController {
|
||||
this._host.setAttribute('data-style', config.style);
|
||||
this._host.setAttribute('data-position', config.position);
|
||||
|
||||
if (this._config?.style !== 'popup') {
|
||||
if (this._config?.style !== 'popup' || this._hasPermanentItems(this._items)) {
|
||||
this._show();
|
||||
}
|
||||
|
||||
@@ -67,7 +81,9 @@ export class StatusBarController {
|
||||
}
|
||||
|
||||
public shouldRender(): boolean {
|
||||
return this._items.some((item) => item.enabled !== false && item.sufficient);
|
||||
return this._items.some(
|
||||
(item) => item.enabled !== false && (item.sufficient || item.permanent),
|
||||
);
|
||||
}
|
||||
|
||||
public actionHandler(
|
||||
@@ -109,6 +125,10 @@ export class StatusBarController {
|
||||
.map((item) => this._getSufficientValue(item));
|
||||
}
|
||||
|
||||
private _hasPermanentItems(items: StatusBarItem[]): boolean {
|
||||
return items.some((item) => item.enabled !== false && item.permanent);
|
||||
}
|
||||
|
||||
private _show(): void {
|
||||
setOrRemoveAttribute(this._host, false, 'hide');
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
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 { IssuePresence } from '../card-controller/issues/types';
|
||||
import { RawAdvancedCameraCardConfig } from '../config/types';
|
||||
import { DeviceRegistryManager } from '../ha/registry/device';
|
||||
import { HomeAssistant } from '../ha/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import basicBlockStyle from '../scss/basic-block.scss';
|
||||
import { getDiagnostics } from '../utils/diagnostics';
|
||||
import { renderMessage } from './message';
|
||||
|
||||
import { renderNotificationBlockFromText } from './notification/block';
|
||||
@customElement('advanced-camera-card-diagnostics')
|
||||
export class AdvancedCameraCardDiagnostics extends LitElement {
|
||||
// Not a reactive property to avoid multiple diagnostics fetches.
|
||||
@@ -22,18 +21,17 @@ export class AdvancedCameraCardDiagnostics extends LitElement {
|
||||
public rawConfig?: RawAdvancedCameraCardConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public problems?: ProblemPresence;
|
||||
public issues?: IssuePresence;
|
||||
|
||||
private async _renderDiagnostics(): Promise<TemplateResult> {
|
||||
const diagnostics = await getDiagnostics(
|
||||
this.hass,
|
||||
this.deviceRegistryManager,
|
||||
this.rawConfig,
|
||||
this.problems,
|
||||
this.issues,
|
||||
);
|
||||
|
||||
return renderMessage({
|
||||
message: localize('error.diagnostics'),
|
||||
return renderNotificationBlockFromText(localize('error.diagnostics'), {
|
||||
icon: 'mdi:cogs',
|
||||
context: diagnostics,
|
||||
});
|
||||
@@ -42,10 +40,9 @@ export class AdvancedCameraCardDiagnostics extends LitElement {
|
||||
protected render(): TemplateResult | void {
|
||||
return html`${until(
|
||||
this._renderDiagnostics(),
|
||||
renderMessage({
|
||||
message: localize('error.fetching_diagnostics'),
|
||||
dotdotdot: true,
|
||||
renderNotificationBlockFromText(localize('error.fetching_diagnostics'), {
|
||||
icon: 'mdi:cogs',
|
||||
in_progress: true,
|
||||
}),
|
||||
)}`;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { IssueTriggerEventData } from '../card-controller/issues/types.js';
|
||||
import { TemplateRenderer } from '../card-controller/templates/index.js';
|
||||
import { dispatchAdvancedCameraCardErrorEvent } from '../components-lib/message/dispatch.js';
|
||||
import { ConditionsManager } from '../conditions/conditions-manager.js';
|
||||
import { getConditionStateManagerViaEvent } from '../conditions/state-manager-via-event.js';
|
||||
import { ConditionStateManager } from '../conditions/state-manager.js';
|
||||
@@ -35,6 +35,12 @@ import { AdvancedCameraCardError } from '../types.js';
|
||||
import { errorToConsole } from '../utils/basic.js';
|
||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
|
||||
|
||||
class ElementsCreationError extends AdvancedCameraCardError {
|
||||
constructor(context?: unknown) {
|
||||
super(localize('error.could_not_create_elements'), context);
|
||||
}
|
||||
}
|
||||
|
||||
/* A note on picture element rendering:
|
||||
*
|
||||
* To avoid needing to deal with the rendering of all the picture elements
|
||||
@@ -106,7 +112,7 @@ export class AdvancedCameraCardElementsCore extends LitElement {
|
||||
private _createRoot(): HuiConditionalElement {
|
||||
const elementConstructor = customElements.get('hui-conditional-element');
|
||||
if (!elementConstructor || !this.hass) {
|
||||
throw new Error(localize('error.could_not_render_elements'));
|
||||
throw new ElementsCreationError(this._renderedElements);
|
||||
}
|
||||
|
||||
const element = new elementConstructor() as HuiConditionalElement;
|
||||
@@ -145,7 +151,11 @@ export class AdvancedCameraCardElementsCore extends LitElement {
|
||||
this._renderedElements = elements;
|
||||
this._root = this._createRoot();
|
||||
} catch (e) {
|
||||
return dispatchAdvancedCameraCardErrorEvent(this, e as AdvancedCameraCardError);
|
||||
errorToConsole(e as Error);
|
||||
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this, 'issue:trigger', {
|
||||
key: 'config_error',
|
||||
error: new ElementsCreationError(elements),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thu
|
||||
import { CardWideConfig } from '../../config/schema/types.js';
|
||||
import { HomeAssistant } from '../../ha/types.js';
|
||||
import galleryCoreStyle from '../../scss/gallery-core.scss';
|
||||
import '../message.js';
|
||||
import '../progress-indicator.js';
|
||||
import { renderProgressIndicator } from '../progress-indicator.js';
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import { MediaGalleryConfig } from '../../config/schema/media-gallery.js';
|
||||
import { CardWideConfig } from '../../config/schema/types.js';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const.js';
|
||||
import { HomeAssistant } from '../../ha/types.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import galleryStyle from '../../scss/gallery.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||
@@ -36,8 +35,7 @@ import { UnifiedQueryBuilder } from '../../view/unified-query-builder.js';
|
||||
import { UnifiedQueryRunner } from '../../view/unified-query-runner.js';
|
||||
import { getReviewedQueryFilterFromQuery } from '../../view/utils/query-filter.js';
|
||||
import '../media-filter.js';
|
||||
import '../message.js';
|
||||
import { renderMessage } from '../message.js';
|
||||
import { renderNoMedia } from '../notification/no-media.js';
|
||||
import '../surround-basic.js';
|
||||
import '../thumbnail/thumbnail.js';
|
||||
import './gallery-core.js';
|
||||
@@ -216,13 +214,10 @@ export class AdvancedCameraCardGallery extends LitElement {
|
||||
</advanced-camera-card-media-filter>`
|
||||
: ''}
|
||||
${!hasItems
|
||||
? renderMessage({
|
||||
type: 'info',
|
||||
message: isLoading
|
||||
? localize('error.awaiting_media')
|
||||
: localize('common.no_media'),
|
||||
icon: 'mdi:multimedia',
|
||||
dotdotdot: isLoading,
|
||||
? renderNoMedia({
|
||||
cameraID: this.viewManagerEpoch?.manager.getView()?.camera ?? null,
|
||||
cameraManager: this.cameraManager ?? null,
|
||||
loading: isLoading,
|
||||
})
|
||||
: html`<advanced-camera-card-gallery-core
|
||||
.hass=${this.hass}
|
||||
|
||||
@@ -12,32 +12,33 @@ import { live } from 'lit/directives/live.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
|
||||
import { IssueTriggerEventData } from '../card-controller/issues/types.js';
|
||||
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
||||
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
|
||||
import { dataToContext } from '../components-lib/notification/data-to-context.js';
|
||||
import { SignedURLController } from '../components-lib/signed-url-controller.js';
|
||||
import { Notification } from '../config/schema/actions/types.js';
|
||||
import { CameraConfig } from '../config/schema/cameras.js';
|
||||
import { type ImageBaseConfig, ImageMode } from '../config/schema/common/image.js';
|
||||
import { EnabledProxyConfig } from '../config/schema/common/proxy.js';
|
||||
import { TROUBLESHOOTING_URL } from '../const.js';
|
||||
import { isHassDifferent } from '../ha/is-hass-different.js';
|
||||
import { HomeAssistant } from '../ha/types.js';
|
||||
import defaultImage from '../images/iris-screensaver.jpg';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import imageUpdatingPlayerStyle from '../scss/image-updating-player.scss';
|
||||
import {
|
||||
MediaLoadedInfo,
|
||||
MediaPlayer,
|
||||
MediaPlayerController,
|
||||
Message,
|
||||
} from '../types.js';
|
||||
import { MediaLoadedInfo, MediaPlayer, MediaPlayerController } from '../types.js';
|
||||
import { contentsChanged } from '../utils/basic.js';
|
||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
|
||||
import {
|
||||
createMediaLoadedInfo,
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
dispatchMediaPauseEvent,
|
||||
dispatchMediaPlayEvent,
|
||||
} from '../utils/media-info.js';
|
||||
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../view/target-id.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { renderMessage } from './message.js';
|
||||
import { renderNotificationBlock } from './notification/block.js';
|
||||
|
||||
// See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py .
|
||||
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
|
||||
@@ -109,10 +110,21 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
() => this._getImageSource(),
|
||||
() => dispatchMediaPlayEvent(this),
|
||||
() => dispatchMediaPauseEvent(this),
|
||||
// Clear image load errors on each timer tick so the next render retries the
|
||||
// <img>. Retries are bounded by refresh_seconds, not a tight loop.
|
||||
// Clear image load errors on each timer tick so the next render retries
|
||||
// the <img> — but only for modes where the underlying URL genuinely
|
||||
// changes between ticks (camera/entity snapshots). For mode: url, the
|
||||
// same static URL will fail the same way every time, so clearing the
|
||||
// error just causes a visible flicker (notification → blank <img> →
|
||||
// notification) every refresh_seconds. URL-mode retries are driven by
|
||||
// mediaEpoch (user-initiated or auto-retry) instead.
|
||||
() => {
|
||||
const mode = resolveImageMode({
|
||||
imageConfig: this.imageConfig,
|
||||
cameraConfig: this.cameraConfig,
|
||||
});
|
||||
if (mode !== 'url') {
|
||||
this._imageLoadError = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -393,31 +405,35 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
}
|
||||
}
|
||||
|
||||
private _getDisplayMessage(): Message | null {
|
||||
private _getDisplayNotification(): Notification | null {
|
||||
const error = this._signedURLController.getError();
|
||||
if (error) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: localize(
|
||||
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
|
||||
),
|
||||
context: this.proxyConfig,
|
||||
heading: {
|
||||
text: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
|
||||
icon: 'mdi:alert-circle',
|
||||
},
|
||||
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
|
||||
context: this.proxyConfig ? dataToContext(this.proxyConfig) : undefined,
|
||||
};
|
||||
}
|
||||
if (this._imageLoadError) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: localize('error.image_load_error'),
|
||||
context: this.imageConfig,
|
||||
heading: {
|
||||
text: localize('error.image_load_error'),
|
||||
icon: 'mdi:alert-circle',
|
||||
},
|
||||
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
|
||||
context: this.imageConfig ? dataToContext(this.imageConfig) : undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const message = this._getDisplayMessage();
|
||||
if (message) {
|
||||
return renderMessage(message);
|
||||
const notification = this._getDisplayNotification();
|
||||
if (notification) {
|
||||
return renderNotificationBlock(notification);
|
||||
}
|
||||
|
||||
const src = this._cachedValueController?.getValue();
|
||||
@@ -449,13 +465,14 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
cameraConfig: this.cameraConfig,
|
||||
});
|
||||
if (mode === 'camera' || mode === 'entity' || mode === 'screensaver') {
|
||||
// In camera, entity, or screensaver mode the user has likely
|
||||
// not made an error, but the source may be unavailable, so show
|
||||
// the stock image.
|
||||
this._forceSafeImage(true);
|
||||
} else if (mode === 'url') {
|
||||
this._imageLoadError = true;
|
||||
}
|
||||
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this, 'issue:trigger', {
|
||||
key: 'media_load',
|
||||
targetID: IMAGE_VIEW_TARGET_ID_SENTINEL,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
`
|
||||
|
||||
+16
-8
@@ -1,6 +1,7 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { ViewManagerEpoch } from '../card-controller/view/types';
|
||||
@@ -12,15 +13,15 @@ import {
|
||||
resolveProxyConfig,
|
||||
} from '../config/schema/common/proxy';
|
||||
import { ImageViewConfig, type ImageViewProxyConfig } from '../config/schema/image';
|
||||
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../const';
|
||||
import { HomeAssistant } from '../ha/types';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import imageStyle from '../scss/image.scss';
|
||||
import { MediaPlayer, MediaPlayerController, MediaPlayerElement } from '../types.js';
|
||||
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../view/target-id.js';
|
||||
import './image-updating-player';
|
||||
import { resolveImageMode } from './image-updating-player';
|
||||
import './media-dimensions-container';
|
||||
import { renderMessage } from './message.js';
|
||||
import { renderNotificationBlockFromText } from './notification/block.js';
|
||||
import './zoomer.js';
|
||||
|
||||
@customElement('advanced-camera-card-image')
|
||||
@@ -48,7 +49,7 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
|
||||
}
|
||||
|
||||
private _renderContainer(template: TemplateResult): TemplateResult {
|
||||
const zoomTarget = IMAGE_VIEW_ZOOM_TARGET_SENTINEL;
|
||||
const zoomTarget = IMAGE_VIEW_TARGET_ID_SENTINEL;
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
const mode = resolveImageMode({
|
||||
imageConfig: this.imageConfig,
|
||||
@@ -108,23 +109,30 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
|
||||
});
|
||||
|
||||
if (mode === 'camera' && !this.cameraConfig) {
|
||||
return renderMessage({
|
||||
type: 'info',
|
||||
message: localize('error.no_camera_for_image'),
|
||||
return renderNotificationBlockFromText(localize('error.no_camera_for_image'), {
|
||||
icon: 'mdi:camera-off',
|
||||
});
|
||||
}
|
||||
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
const mediaEpoch = view?.context?.mediaEpoch?.[IMAGE_VIEW_TARGET_ID_SENTINEL] ?? 0;
|
||||
|
||||
return this._renderContainer(html`
|
||||
${keyed(
|
||||
mediaEpoch,
|
||||
html`
|
||||
<advanced-camera-card-image-updating-player
|
||||
${ref(this._refImage)}
|
||||
.hass=${this.hass}
|
||||
.view=${this.viewManagerEpoch?.manager.getView()}
|
||||
.view=${view}
|
||||
.imageConfig=${this.imageConfig}
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
.proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ?? undefined}
|
||||
.proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ??
|
||||
undefined}
|
||||
>
|
||||
</advanced-camera-card-image-updating-player>
|
||||
`,
|
||||
)}
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { CameraManagerCameraMetadata } from '../../camera-manager/types.js';
|
||||
@@ -220,10 +221,13 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
|
||||
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
const mediaEpoch = view?.context?.mediaEpoch?.[cameraID] ?? 0;
|
||||
|
||||
return html`
|
||||
<div class="embla__slide">
|
||||
<advanced-camera-card-live-provider
|
||||
${keyed(
|
||||
mediaEpoch,
|
||||
html`<advanced-camera-card-live-provider
|
||||
.microphoneState=${view?.camera === cameraID
|
||||
? this.microphoneState
|
||||
: undefined}
|
||||
@@ -238,14 +242,17 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
|
||||
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
|
||||
@advanced-camera-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
|
||||
@advanced-camera-card:zoom:change=${(
|
||||
ev: CustomEvent<ZoomSettingsObserved>,
|
||||
) =>
|
||||
handleZoomSettingsObservedEvent(
|
||||
ev,
|
||||
this.viewManagerEpoch?.manager,
|
||||
cameraID,
|
||||
)}
|
||||
>
|
||||
</advanced-camera-card-live-provider>
|
||||
</advanced-camera-card-live-provider>`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-ca
|
||||
import { getResolvedLiveProvider } from '../../utils/live-provider.js';
|
||||
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
|
||||
import '../icon.js';
|
||||
import { renderMessage } from '../message.js';
|
||||
import { renderNotificationBlockFromText } from '../notification/block.js';
|
||||
import './../media-dimensions-container';
|
||||
|
||||
@customElement('advanced-camera-card-live-provider')
|
||||
@@ -73,6 +73,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
@state()
|
||||
private _hasProviderError = false;
|
||||
|
||||
// Whether the camera entity has ever been in a non-unavailable state. Used
|
||||
// to suppress transient unavailability errors for entities that were
|
||||
// previously working (e.g. during PTZ operations).
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2124
|
||||
private _entityHasBeenAvailable = false;
|
||||
|
||||
private _refProvider: Ref<MediaPlayerElement> = createRef();
|
||||
|
||||
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
|
||||
@@ -121,6 +127,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._isVideoMediaLoaded = false;
|
||||
this._entityHasBeenAvailable = false;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -132,11 +139,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
ev.stopPropagation();
|
||||
this._hasProviderError = true;
|
||||
|
||||
const cameraID = this.camera?.getID();
|
||||
if (cameraID) {
|
||||
fireAdvancedCameraCardEvent(this, 'problem:trigger', {
|
||||
key: 'stream_not_loading' as const,
|
||||
cameraID,
|
||||
// this.camera is already substream-aware (resolved by the carousel layer).
|
||||
const targetID = this.camera?.getID();
|
||||
if (targetID) {
|
||||
fireAdvancedCameraCardEvent(this, 'issue:trigger', {
|
||||
key: 'media_load' as const,
|
||||
targetID,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -162,6 +170,10 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
}
|
||||
|
||||
if (changedProps.has('camera')) {
|
||||
this._isVideoMediaLoaded = false;
|
||||
this._hasProviderError = false;
|
||||
this._entityHasBeenAvailable = false;
|
||||
|
||||
const provider = getResolvedLiveProvider(this.camera?.getConfig());
|
||||
if (provider === 'jsmpeg') {
|
||||
this._importPromises.push(import('./providers/jsmpeg.js'));
|
||||
@@ -256,9 +268,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
) {
|
||||
if (!cameraConfig?.camera_entity) {
|
||||
dispatchLiveErrorEvent(this);
|
||||
return renderMessage({
|
||||
message: localize('error.no_live_camera'),
|
||||
type: 'error',
|
||||
return renderNotificationBlockFromText(localize('error.no_live_camera'), {
|
||||
icon: 'mdi:camera',
|
||||
context: cameraConfig,
|
||||
});
|
||||
@@ -267,25 +277,28 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
const stateObj = this.hass.states[cameraConfig.camera_entity];
|
||||
if (!stateObj) {
|
||||
dispatchLiveErrorEvent(this);
|
||||
return renderMessage({
|
||||
message: localize('error.live_camera_not_found'),
|
||||
type: 'error',
|
||||
return renderNotificationBlockFromText(localize('error.live_camera_not_found'), {
|
||||
icon: 'mdi:camera',
|
||||
context: cameraConfig,
|
||||
});
|
||||
}
|
||||
|
||||
if (stateObj.state === 'unavailable') {
|
||||
if (
|
||||
!this._entityHasBeenAvailable ||
|
||||
cameraConfig.always_error_if_entity_unavailable
|
||||
) {
|
||||
dispatchLiveErrorEvent(this);
|
||||
dispatchMediaUnloadedEvent(this);
|
||||
return renderMessage({
|
||||
message: `${localize('error.live_camera_unavailable')}${
|
||||
return renderNotificationBlockFromText(
|
||||
`${localize('error.live_camera_unavailable')}${
|
||||
this.label ? `: ${this.label}` : ''
|
||||
}`,
|
||||
type: 'info',
|
||||
icon: 'mdi:cctv-off',
|
||||
dotdotdot: true,
|
||||
});
|
||||
{ icon: 'mdi:cctv-off', in_progress: true },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this._entityHasBeenAvailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,8 +386,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
? html`<advanced-camera-card-icon
|
||||
title=${localize('error.awaiting_live')}
|
||||
.icon=${{ icon: 'mdi:progress-helper' }}
|
||||
@click=${() =>
|
||||
fireAdvancedCameraCardEvent(this, 'problem:notify', 'stream_not_loading')}
|
||||
@click=${() => fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_load')}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}`;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { HomeAssistant } from '../../../../ha/types.js';
|
||||
import { localize } from '../../../../localize/localize.js';
|
||||
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
|
||||
import { MediaPlayer, MediaPlayerController } from '../../../../types.js';
|
||||
import { renderMessage } from '../../../message.js';
|
||||
import { renderNotificationBlockFromText } from '../../../notification/block.js';
|
||||
import { VideoRTC } from './video-rtc.js';
|
||||
|
||||
customElements.define('advanced-camera-card-live-go2rtc-player', VideoRTC);
|
||||
@@ -144,18 +144,13 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
||||
protected render(): TemplateResult | void {
|
||||
const error = this._signedURLController.getError();
|
||||
if (error) {
|
||||
return renderMessage({
|
||||
type: 'error',
|
||||
message: localize(
|
||||
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
|
||||
),
|
||||
context: this.camera?.getConfig(),
|
||||
});
|
||||
return renderNotificationBlockFromText(
|
||||
localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
|
||||
{ context: this.camera?.getConfig() },
|
||||
);
|
||||
}
|
||||
if (!this.cameraEndpoints?.go2rtc) {
|
||||
return renderMessage({
|
||||
type: 'error',
|
||||
message: localize('error.live_camera_no_endpoint'),
|
||||
return renderNotificationBlockFromText(localize('error.live_camera_no_endpoint'), {
|
||||
context: this.camera?.getConfig(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,13 +12,15 @@ import { until } from 'lit/directives/until.js';
|
||||
import { CameraEndpoints } from '../../../camera-manager/types.js';
|
||||
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
|
||||
import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js';
|
||||
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
|
||||
import { Notification } from '../../../config/schema/actions/types.js';
|
||||
import { CameraConfig } from '../../../config/schema/cameras.js';
|
||||
import { CardWideConfig } from '../../../config/schema/types.js';
|
||||
import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js';
|
||||
import { HomeAssistant } from '../../../ha/types.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
|
||||
import { MediaPlayer, MediaPlayerController, Message } from '../../../types.js';
|
||||
import { MediaPlayer, MediaPlayerController } from '../../../types.js';
|
||||
import { convertHTTPAdressToWebsocket, errorToConsole } from '../../../utils/basic.js';
|
||||
import {
|
||||
dispatchMediaLoadedEvent,
|
||||
@@ -26,8 +28,8 @@ import {
|
||||
dispatchMediaPlayEvent,
|
||||
} from '../../../utils/media-info.js';
|
||||
import { Timer } from '../../../utils/timer.js';
|
||||
import '../../message.js';
|
||||
import { renderMessage } from '../../message.js';
|
||||
import '../../notification/block.js';
|
||||
import { renderNotificationBlock } from '../../notification/block.js';
|
||||
import '../../progress-indicator.js';
|
||||
import { renderProgressIndicator } from '../../progress-indicator.js';
|
||||
|
||||
@@ -51,7 +53,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
@state()
|
||||
private _message: Message | null = null;
|
||||
private _notification: Notification | null = null;
|
||||
|
||||
private _jsmpegCanvasElement?: HTMLCanvasElement;
|
||||
private _jsmpegVideoPlayer?: JSMpeg.VideoElement;
|
||||
@@ -71,7 +73,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
||||
if (
|
||||
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
|
||||
) {
|
||||
this._message = null;
|
||||
this._notification = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +135,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
||||
}
|
||||
|
||||
private _resetPlayer(): void {
|
||||
this._message = null;
|
||||
this._notification = null;
|
||||
this._refreshPlayerTimer.stop();
|
||||
if (this._jsmpegVideoPlayer) {
|
||||
try {
|
||||
@@ -175,11 +177,10 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
||||
|
||||
const endpoint = this.cameraEndpoints?.jsmpeg;
|
||||
if (!endpoint) {
|
||||
this._message = {
|
||||
message: localize('error.live_camera_no_endpoint'),
|
||||
type: 'error',
|
||||
context: this.cameraConfig,
|
||||
};
|
||||
this._notification = createNotificationFromText(
|
||||
localize('error.live_camera_no_endpoint'),
|
||||
{ context: this.cameraConfig },
|
||||
);
|
||||
dispatchLiveErrorEvent(this);
|
||||
return;
|
||||
}
|
||||
@@ -197,11 +198,9 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
||||
const address = response ? convertHTTPAdressToWebsocket(response) : null;
|
||||
|
||||
if (!address) {
|
||||
this._message = {
|
||||
type: 'error',
|
||||
message: localize('error.failed_sign'),
|
||||
this._notification = createNotificationFromText(localize('error.failed_sign'), {
|
||||
context: this.cameraConfig,
|
||||
};
|
||||
});
|
||||
dispatchLiveErrorEvent(this);
|
||||
return;
|
||||
}
|
||||
@@ -214,20 +213,19 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (this._message) {
|
||||
return renderMessage(this._message);
|
||||
if (this._notification) {
|
||||
return renderNotificationBlock(this._notification);
|
||||
}
|
||||
|
||||
const _render = async (): Promise<TemplateResult | void> => {
|
||||
await this._refreshPlayer();
|
||||
|
||||
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
|
||||
if (!this._message) {
|
||||
this._message = {
|
||||
message: localize('error.jsmpeg_no_player'),
|
||||
type: 'error',
|
||||
context: this.cameraConfig,
|
||||
};
|
||||
if (!this._notification) {
|
||||
this._notification = createNotificationFromText(
|
||||
localize('error.jsmpeg_no_player'),
|
||||
{ context: this.cameraConfig },
|
||||
);
|
||||
dispatchLiveErrorEvent(this);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -12,6 +12,8 @@ import { CameraEndpoints } from '../../../camera-manager/types.js';
|
||||
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
|
||||
import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js';
|
||||
import { VideoMediaPlayerController } from '../../../components-lib/media-player/video.js';
|
||||
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
|
||||
import { Notification } from '../../../config/schema/actions/types.js';
|
||||
import { CameraConfig } from '../../../config/schema/cameras.js';
|
||||
import { CardWideConfig } from '../../../config/schema/types.js';
|
||||
import { HomeAssistant } from '../../../ha/types.js';
|
||||
@@ -21,7 +23,6 @@ import {
|
||||
AdvancedCameraCardError,
|
||||
MediaPlayer,
|
||||
MediaPlayerController,
|
||||
Message,
|
||||
} from '../../../types.js';
|
||||
import { mayHaveAudio } from '../../../utils/audio.js';
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||
setControlsOnVideo,
|
||||
} from '../../../utils/controls.js';
|
||||
import { getContextFromError } from '../../../utils/error-context.js';
|
||||
import {
|
||||
dispatchMediaLoadedEvent,
|
||||
dispatchMediaPauseEvent,
|
||||
@@ -36,8 +38,8 @@ import {
|
||||
dispatchMediaVolumeChangeEvent,
|
||||
} from '../../../utils/media-info.js';
|
||||
import { renderTask } from '../../../utils/task.js';
|
||||
import '../../message.js';
|
||||
import { renderMessage } from '../../message.js';
|
||||
import '../../notification/block.js';
|
||||
import { renderNotificationBlock } from '../../notification/block.js';
|
||||
import '../../progress-indicator.js';
|
||||
import { renderProgressIndicator } from '../../progress-indicator.js';
|
||||
import { VideoRTC } from './go2rtc/video-rtc.js';
|
||||
@@ -59,7 +61,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
||||
public controls = false;
|
||||
|
||||
@state()
|
||||
private _message: Message | null = null;
|
||||
private _notification: Notification | null = null;
|
||||
|
||||
private hass?: HomeAssistant;
|
||||
|
||||
@@ -88,7 +90,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this._videoRTC = null;
|
||||
this._message = null;
|
||||
this._notification = null;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -96,7 +98,7 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
||||
if (
|
||||
['cameraConfig', 'cameraEndpoints'].some((prop) => changedProperties.has(prop))
|
||||
) {
|
||||
this._message = null;
|
||||
this._notification = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +150,8 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (this._message) {
|
||||
return renderMessage(this._message);
|
||||
if (this._notification) {
|
||||
return renderNotificationBlock(this._notification);
|
||||
}
|
||||
|
||||
const render = (): TemplateResult | void => {
|
||||
@@ -157,16 +159,15 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
||||
try {
|
||||
webrtcElement = this._createWebRTC();
|
||||
} catch (e) {
|
||||
this._message = {
|
||||
type: 'error',
|
||||
message:
|
||||
const context = getContextFromError(e);
|
||||
this._notification = createNotificationFromText(
|
||||
e instanceof AdvancedCameraCardError
|
||||
? e.message
|
||||
: localize('error.webrtc_card_reported_error') +
|
||||
': ' +
|
||||
(e as Error).message,
|
||||
context: (e as AdvancedCameraCardError).context,
|
||||
};
|
||||
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
|
||||
{
|
||||
...(context && { context }),
|
||||
},
|
||||
);
|
||||
dispatchLiveErrorEvent(this);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { MessageController } from '../components-lib/message/controller.js';
|
||||
import messageStyle from '../scss/message.scss';
|
||||
import { Message } from '../types.js';
|
||||
import './icon.js';
|
||||
|
||||
export function renderMessage(
|
||||
message: Message | null,
|
||||
renderOptions?: {
|
||||
overlay?: boolean;
|
||||
},
|
||||
): TemplateResult {
|
||||
return html` <advanced-camera-card-message
|
||||
.message=${message}
|
||||
?overlay=${!!renderOptions?.overlay}
|
||||
></advanced-camera-card-message>`;
|
||||
}
|
||||
@customElement('advanced-camera-card-message')
|
||||
export class AdvancedCameraCardMessage extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public message?: Message;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public overlay = false;
|
||||
|
||||
private _controller = new MessageController();
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = this._controller.getLink(this.message);
|
||||
const messageTemplate = html`
|
||||
${this._controller.getMessageString(this.message)}
|
||||
${link ? html`. <a href="${link.url}">${link.title}</a>` : ''}
|
||||
`;
|
||||
|
||||
const icon = this._controller.getIcon(this.message);
|
||||
const classes = {
|
||||
dotdotdot: !!this.message?.dotdotdot,
|
||||
};
|
||||
|
||||
return html` <div class="wrapper">
|
||||
<div class="message padded">
|
||||
<div class="icon">
|
||||
<advanced-camera-card-icon
|
||||
part="icon"
|
||||
.icon="${{ icon: icon }}"
|
||||
></advanced-camera-card-icon>
|
||||
</div>
|
||||
<div class="contents">
|
||||
<span class="${classMap(classes)}">${messageTemplate}</span>
|
||||
${this._controller
|
||||
.getContextStrings(this.message)
|
||||
.map((contextItem) => html`<pre>${contextItem}</pre>`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(messageStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-message': AdvancedCameraCardMessage;
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { actionHandler } from '../action-handler-directive.js';
|
||||
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request';
|
||||
import {
|
||||
Notification,
|
||||
NotificationControl,
|
||||
NotificationDetail,
|
||||
} from '../config/schema/actions/types.js';
|
||||
import notificationStyle from '../scss/notification.scss';
|
||||
import {
|
||||
getActionConfigGivenAction,
|
||||
hasAction,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../utils/action.js';
|
||||
import { arrayify } from '../utils/basic.js';
|
||||
import { dispatchDismissNotificationEvent } from '../utils/notification.js';
|
||||
import './icon.js';
|
||||
|
||||
@customElement('advanced-camera-card-notification')
|
||||
export class AdvancedCameraCardNotification extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public notification: Notification | null = null;
|
||||
|
||||
private _refNotification: Ref<HTMLElement> = createRef();
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('click', this._handleOutsideInteraction);
|
||||
window.addEventListener('focusin', this._handleOutsideInteraction);
|
||||
window.addEventListener('keydown', this._handleKeyDown);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
window.removeEventListener('click', this._handleOutsideInteraction);
|
||||
window.removeEventListener('focusin', this._handleOutsideInteraction);
|
||||
window.removeEventListener('keydown', this._handleKeyDown);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
const heading = this.notification.heading;
|
||||
const details = this.notification.details ?? [];
|
||||
const text = this.notification.text;
|
||||
const controls = this.notification.controls ?? [];
|
||||
|
||||
return html`
|
||||
<div class="backdrop" @click=${this._dismiss}></div>
|
||||
<div
|
||||
class="notification"
|
||||
${ref(this._refNotification)}
|
||||
@animationend=${this._handleAnimationEnd}
|
||||
>
|
||||
<div class="details">
|
||||
${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">
|
||||
${controls.map((control) => this._renderControl(control))}
|
||||
</div>`
|
||||
: ''}
|
||||
<div class="close" @click=${this._dismiss}>
|
||||
<advanced-camera-card-icon
|
||||
.icon=${{ icon: 'mdi:close' }}
|
||||
></advanced-camera-card-icon>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderControl(control: NotificationControl): TemplateResult {
|
||||
const severityClass = control.severity ? `severity-${control.severity}` : '';
|
||||
return html`
|
||||
<div
|
||||
class="control ${severityClass}"
|
||||
title=${control.tooltip ?? ''}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasAction(control.actions?.hold_action),
|
||||
hasDoubleClick: hasAction(control.actions?.double_tap_action),
|
||||
})}
|
||||
@action=${(ev: CustomEvent) => this._handleControlAction(ev, control)}
|
||||
>
|
||||
${control.icon
|
||||
? html`<advanced-camera-card-icon
|
||||
.icon=${{ icon: control.icon }}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleControlAction(
|
||||
ev: CustomEvent<{ action: string }>,
|
||||
control: NotificationControl,
|
||||
): void {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const action = getActionConfigGivenAction(ev.detail.action, control.actions);
|
||||
if (action) {
|
||||
dispatchActionExecutionRequest(this, {
|
||||
actions: arrayify(action),
|
||||
});
|
||||
}
|
||||
if (control.dismiss !== false) {
|
||||
this._dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
private _renderDetail(detail: NotificationDetail, isHeading = false): TemplateResult {
|
||||
const classes = {
|
||||
detail: true,
|
||||
heading: isHeading,
|
||||
[`severity-${detail.severity}`]: !!detail.severity,
|
||||
};
|
||||
return html`
|
||||
<div class="${classMap(classes)}">
|
||||
${detail.icon
|
||||
? html`<advanced-camera-card-icon
|
||||
title=${detail.tooltip ?? ''}
|
||||
.icon=${{ icon: detail.icon }}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}
|
||||
<span title=${detail.text}>${detail.text}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _dismiss = (): void => {
|
||||
this._refNotification.value?.classList.add('exiting');
|
||||
};
|
||||
|
||||
private _handleAnimationEnd = (ev: AnimationEvent): void => {
|
||||
if (ev.animationName === 'slideDown') {
|
||||
dispatchDismissNotificationEvent(this);
|
||||
}
|
||||
};
|
||||
|
||||
private _handleOutsideInteraction = (ev: Event): void => {
|
||||
if (!ev.composedPath().includes(this)) {
|
||||
this._dismiss();
|
||||
}
|
||||
};
|
||||
|
||||
private _handleKeyDown = (ev: KeyboardEvent): void => {
|
||||
if (ev.key === 'Escape') {
|
||||
this._dismiss();
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(notificationStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-notification': AdvancedCameraCardNotification;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { handleControlAction } from '../../components-lib/notification/action.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import {
|
||||
createNotificationFromText,
|
||||
NotificationOptions,
|
||||
} from '../../components-lib/notification/factory.js';
|
||||
import { Notification } from '../../config/schema/actions/types.js';
|
||||
import notificationBlockStyle from '../../scss/notification-block.scss';
|
||||
import {
|
||||
renderControl,
|
||||
renderDetail,
|
||||
renderNotificationBody,
|
||||
} from './common-rendering.js';
|
||||
|
||||
export function renderNotificationBlock(
|
||||
notification: Notification | null,
|
||||
): TemplateResult {
|
||||
return html`<advanced-camera-card-notification-block
|
||||
.notification=${notification}
|
||||
></advanced-camera-card-notification-block>`;
|
||||
}
|
||||
|
||||
export function renderNotificationBlockFromText(
|
||||
text: string,
|
||||
options?: NotificationOptions,
|
||||
): TemplateResult {
|
||||
return renderNotificationBlock(createNotificationFromText(text, options));
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-notification-block')
|
||||
export class AdvancedCameraCardNotificationBlock extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public notification: Notification | null = null;
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { heading, in_progress } = this.notification;
|
||||
const controls = this.notification.controls ?? [];
|
||||
|
||||
// Anchor the spinner to whichever status element is most prominent: the
|
||||
// heading if present, the body icon otherwise. Never render it orphaned
|
||||
// on its own row (which would float it to the left with no visual tie to
|
||||
// the text).
|
||||
const spinner = in_progress
|
||||
? html`<div class="spinner" title=${localize('common.in_progress')}>
|
||||
<ha-spinner indeterminate size="tiny"></ha-spinner>
|
||||
</div>`
|
||||
: null;
|
||||
const spinnerInHeadingRow = spinner && (heading || controls.length);
|
||||
const spinnerInBody = spinner && !spinnerInHeadingRow;
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
${heading || spinnerInHeadingRow || controls.length
|
||||
? html`<div class="heading-row">
|
||||
${heading ? renderDetail(heading, 'heading') : ''}
|
||||
${spinnerInHeadingRow || controls.length
|
||||
? html`<div class="controls">
|
||||
${spinnerInHeadingRow ? spinner : ''}
|
||||
${controls.map((control) =>
|
||||
renderControl(control, (ev, c) =>
|
||||
handleControlAction(ev, c, this),
|
||||
),
|
||||
)}
|
||||
</div>`
|
||||
: ''}
|
||||
</div>`
|
||||
: ''}
|
||||
${renderNotificationBody(this.notification, spinnerInBody ? spinner : undefined)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(notificationBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-notification-block': AdvancedCameraCardNotificationBlock;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { actionHandler } from '../../action-handler-directive.js';
|
||||
import {
|
||||
Notification,
|
||||
NotificationControl,
|
||||
NotificationDetail,
|
||||
} from '../../config/schema/actions/types.js';
|
||||
import {
|
||||
hasAction,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../../utils/action.js';
|
||||
import '../icon.js';
|
||||
|
||||
export function renderDetail(
|
||||
detail: NotificationDetail,
|
||||
role: 'heading' | 'body' | 'metadata' = 'metadata',
|
||||
iconOverride?: TemplateResult,
|
||||
): TemplateResult {
|
||||
const classes = {
|
||||
detail: true,
|
||||
heading: role === 'heading',
|
||||
body: role === 'body',
|
||||
[`severity-${detail.severity}`]: !!detail.severity,
|
||||
};
|
||||
return html`
|
||||
<div class="${classMap(classes)}">
|
||||
${iconOverride ??
|
||||
(detail.icon
|
||||
? html`<advanced-camera-card-icon
|
||||
title=${detail.tooltip ?? ''}
|
||||
.icon=${{ icon: detail.icon }}
|
||||
></advanced-camera-card-icon>`
|
||||
: '')}
|
||||
<span title=${detail.text}>${detail.text}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderControl(
|
||||
control: NotificationControl,
|
||||
onAction: (ev: CustomEvent<{ action: string }>, control: NotificationControl) => void,
|
||||
): TemplateResult {
|
||||
const classes = {
|
||||
control: true,
|
||||
[`severity-${control.severity}`]: !!control.severity,
|
||||
};
|
||||
return html`
|
||||
<div
|
||||
class="${classMap(classes)}"
|
||||
title=${control.tooltip ?? ''}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: hasAction(control.actions?.hold_action),
|
||||
hasDoubleClick: hasAction(control.actions?.double_tap_action),
|
||||
})}
|
||||
@action=${(ev: CustomEvent) => onAction(ev, control)}
|
||||
>
|
||||
${control.icon
|
||||
? html`<advanced-camera-card-icon
|
||||
.icon=${{ icon: control.icon }}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderNotificationBody(
|
||||
notification: Notification,
|
||||
bodyIconOverride?: TemplateResult,
|
||||
): TemplateResult {
|
||||
const { body, link } = notification;
|
||||
const context = notification.context ?? [];
|
||||
const metadata = notification.metadata ?? [];
|
||||
return html`
|
||||
${metadata.map((detail) => renderDetail(detail, 'metadata'))}
|
||||
${body ? renderDetail(body, 'body', bodyIconOverride) : ''}
|
||||
${link
|
||||
? html`<div class="url">
|
||||
<a
|
||||
href=${link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click=${stopEventFromActivatingCardWideActions}
|
||||
>${link.title}</a
|
||||
>
|
||||
</div>`
|
||||
: ''}
|
||||
${context.length
|
||||
? html`<div class="context">
|
||||
${context.map((item) => html`<pre>${item}</pre>`)}
|
||||
</div>`
|
||||
: ''}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { TemplateResult } from 'lit';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import { renderNotificationBlock } from './block.js';
|
||||
|
||||
interface NoMediaOptions {
|
||||
cameraID: string | null;
|
||||
cameraManager: CameraManager | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function renderNoMedia(options: NoMediaOptions): TemplateResult {
|
||||
const cameraID =
|
||||
options.cameraID ?? options.cameraManager?.getStore().getDefaultCameraID() ?? null;
|
||||
const cameraTitle = cameraID
|
||||
? options.cameraManager?.getCameraMetadata(cameraID)?.title ?? cameraID
|
||||
: null;
|
||||
|
||||
return renderNotificationBlock({
|
||||
heading: {
|
||||
text: options.loading
|
||||
? localize('error.awaiting_media')
|
||||
: localize('common.no_media'),
|
||||
icon: 'mdi:multimedia',
|
||||
},
|
||||
in_progress: options.loading,
|
||||
...(cameraTitle && {
|
||||
metadata: [{ text: cameraTitle, icon: 'mdi:cctv' }],
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { handleControlAction } from '../../components-lib/notification/action.js';
|
||||
import { Notification } from '../../config/schema/actions/types.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import notificationPopupStyle from '../../scss/notification-popup.scss';
|
||||
import { dispatchDismissNotificationEvent } from '../../utils/notification.js';
|
||||
import {
|
||||
renderControl,
|
||||
renderDetail,
|
||||
renderNotificationBody,
|
||||
} from './common-rendering.js';
|
||||
|
||||
@customElement('advanced-camera-card-notification')
|
||||
export class AdvancedCameraCardNotification extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public notification: Notification | null = null;
|
||||
|
||||
private _refNotification: Ref<HTMLElement> = createRef();
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('click', this._handleOutsideInteraction);
|
||||
window.addEventListener('focusin', this._handleOutsideInteraction);
|
||||
window.addEventListener('keydown', this._handleKeyDown);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
window.removeEventListener('click', this._handleOutsideInteraction);
|
||||
window.removeEventListener('focusin', this._handleOutsideInteraction);
|
||||
window.removeEventListener('keydown', this._handleKeyDown);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { heading, in_progress } = this.notification;
|
||||
const controls = this.notification.controls ?? [];
|
||||
|
||||
return html`
|
||||
<div class="backdrop" @click=${this._dismiss}></div>
|
||||
<div
|
||||
class="notification"
|
||||
${ref(this._refNotification)}
|
||||
@animationend=${this._handleAnimationEnd}
|
||||
>
|
||||
${controls.length || in_progress
|
||||
? html`<div class="controls">
|
||||
${in_progress
|
||||
? html`<div class="spinner" title=${localize('common.in_progress')}>
|
||||
<ha-spinner indeterminate size="tiny"></ha-spinner>
|
||||
</div>`
|
||||
: ''}
|
||||
${controls.map((control) =>
|
||||
renderControl(control, (ev, c) =>
|
||||
handleControlAction(ev, c, this, this._dismiss),
|
||||
),
|
||||
)}
|
||||
</div>`
|
||||
: ''}
|
||||
<div class="close" @click=${this._dismiss}>
|
||||
<advanced-camera-card-icon
|
||||
.icon=${{ icon: 'mdi:close' }}
|
||||
></advanced-camera-card-icon>
|
||||
</div>
|
||||
<div class="details">
|
||||
${heading ? renderDetail(heading, 'heading') : ''}
|
||||
${renderNotificationBody(this.notification)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _dismiss = (): void => {
|
||||
this._refNotification.value?.classList.add('exiting');
|
||||
};
|
||||
|
||||
private _handleAnimationEnd = (ev: AnimationEvent): void => {
|
||||
if (ev.animationName === 'slideDown') {
|
||||
dispatchDismissNotificationEvent(this);
|
||||
}
|
||||
};
|
||||
|
||||
private _handleOutsideInteraction = (ev: Event): void => {
|
||||
if (!ev.composedPath().includes(this)) {
|
||||
this._dismiss();
|
||||
}
|
||||
};
|
||||
|
||||
private _handleKeyDown = (ev: KeyboardEvent): void => {
|
||||
if (ev.key === 'Escape') {
|
||||
this._dismiss();
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(notificationPopupStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-notification': AdvancedCameraCardNotification;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { customElement, property } from 'lit/decorators.js';
|
||||
import { ClassInfo, classMap } from 'lit/directives/class-map.js';
|
||||
import { ref, Ref } from 'lit/directives/ref.js';
|
||||
import { CardWideConfig } from '../config/schema/types';
|
||||
import messageStyle from '../scss/message.scss';
|
||||
import progressIndicatorStyle from '../scss/progress-indicator.scss';
|
||||
import './icon.js';
|
||||
|
||||
type AdvancedCameraCardProgressIndicatorSize = 'tiny' | 'small' | 'medium' | 'large';
|
||||
@@ -40,7 +40,7 @@ export class AdvancedCameraCardProgressIndicator extends LitElement {
|
||||
public size: AdvancedCameraCardProgressIndicatorSize = 'large';
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html` <div class="message vertical">
|
||||
return html` <div class="indicator">
|
||||
${this.animated
|
||||
? html`<ha-spinner indeterminate size="${this.size}"> </ha-spinner>`
|
||||
: html`<advanced-camera-card-icon
|
||||
@@ -51,7 +51,7 @@ export class AdvancedCameraCardProgressIndicator extends LitElement {
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(messageStyle);
|
||||
return unsafeCSS(progressIndicatorStyle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { CameraManager } from '../../camera-manager/manager';
|
||||
import { MediaDetailsController } from '../../components-lib/media/details-controller';
|
||||
import { MediaNotificationController } from '../../components-lib/media/notification-controller';
|
||||
import { NotificationDetail } from '../../config/schema/actions/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss';
|
||||
@@ -31,17 +31,17 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public seek?: Date;
|
||||
|
||||
private _controller = new MediaDetailsController();
|
||||
private _notificationController = new MediaNotificationController();
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
if (['item', 'seek', 'cameraManager'].some((prop) => changedProperties.has(prop))) {
|
||||
this._controller.calculate(this.cameraManager, this.item, this.seek);
|
||||
this._notificationController.calculate(this.cameraManager, this.item, this.seek);
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const heading = this._controller.getHeading();
|
||||
const details = this._controller.getDetails();
|
||||
const heading = this._notificationController.getHeading();
|
||||
const details = this._notificationController.getMetadata();
|
||||
|
||||
const renderDetail = (
|
||||
detail: NotificationDetail,
|
||||
|
||||
@@ -13,9 +13,9 @@ import { dispatchActionExecutionRequest } from '../../../card-controller/actions
|
||||
import { ViewItemManager } from '../../../card-controller/view/item-manager';
|
||||
import { ViewManagerEpoch } from '../../../card-controller/view/types';
|
||||
import {
|
||||
MediaDetailsController,
|
||||
MediaNotificationController,
|
||||
NotificationControlsContext,
|
||||
} from '../../../components-lib/media/details-controller';
|
||||
} from '../../../components-lib/media/notification-controller';
|
||||
import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller';
|
||||
import { HomeAssistant } from '../../../ha/types';
|
||||
import { localize } from '../../../localize/localize';
|
||||
@@ -208,12 +208,12 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement {
|
||||
title=${this.item?.getDescription() ?? ''}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
const detailsController = new MediaDetailsController();
|
||||
detailsController.calculate(this.cameraManager, this.item);
|
||||
const notificationController = new MediaNotificationController();
|
||||
notificationController.calculate(this.cameraManager, this.item);
|
||||
dispatchActionExecutionRequest(this, {
|
||||
actions: [
|
||||
createNotificationAction(
|
||||
detailsController.getNotification(this._getControlContext()),
|
||||
notificationController.getNotification(this._getControlContext()),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ import { contentsChanged } from '../utils/basic';
|
||||
import './date-picker.js';
|
||||
import { AdvancedCameraCardDatePicker, DatePickerEvent } from './date-picker.js';
|
||||
import './icon';
|
||||
import { renderMessage } from './message';
|
||||
import { renderNotificationBlockFromText } from './notification/block';
|
||||
import './thumbnail/thumbnail.js';
|
||||
|
||||
/**
|
||||
@@ -151,11 +151,9 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
|
||||
|
||||
if (isLoading) {
|
||||
if (!this.mini) {
|
||||
return renderMessage({
|
||||
message: localize('error.awaiting_media'),
|
||||
return renderNotificationBlockFromText(localize('error.awaiting_media'), {
|
||||
icon: 'mdi:chart-gantt',
|
||||
type: 'info',
|
||||
dotdotdot: true,
|
||||
in_progress: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -163,11 +161,10 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
|
||||
|
||||
if (!view?.query || !view.query.hasNodes()) {
|
||||
if (!this.mini) {
|
||||
return renderMessage({
|
||||
message: localize('error.no_camera_or_media_for_timeline'),
|
||||
icon: 'mdi:chart-gantt',
|
||||
type: 'info',
|
||||
});
|
||||
return renderNotificationBlockFromText(
|
||||
localize('error.no_camera_or_media_for_timeline'),
|
||||
{ icon: 'mdi:chart-gantt' },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { RemoveContextPropertyViewModifier } from '../../card-controller/view/modifiers/remove-context-property.js';
|
||||
@@ -32,8 +33,8 @@ import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||
import { ViewMedia } from '../../view/item.js';
|
||||
import '../carousel';
|
||||
import type { EmblaCarouselPlugins } from '../carousel.js';
|
||||
import { renderMessage } from '../message.js';
|
||||
import '../next-prev-control.js';
|
||||
import { renderNoMedia } from '../notification/no-media.js';
|
||||
import '../ptz.js';
|
||||
import './provider.js';
|
||||
|
||||
@@ -314,15 +315,12 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
protected render(): TemplateResult | void {
|
||||
const mediaCount = this._media?.length ?? 0;
|
||||
if (!this._media || !mediaCount) {
|
||||
return renderMessage({
|
||||
message: localize('common.no_media'),
|
||||
type: 'info',
|
||||
icon: 'mdi:multimedia',
|
||||
...(this.viewFilterCameraID && {
|
||||
context: {
|
||||
camera_id: this.viewFilterCameraID,
|
||||
},
|
||||
}),
|
||||
return renderNoMedia({
|
||||
cameraID:
|
||||
this.viewFilterCameraID ??
|
||||
this.viewManagerEpoch?.manager.getView()?.camera ??
|
||||
null,
|
||||
cameraManager: this.cameraManager ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -464,8 +462,13 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaID = media.getID();
|
||||
const mediaEpoch = mediaID ? view.context?.mediaEpoch?.[mediaID] ?? 0 : 0;
|
||||
|
||||
return html` <div class="embla__slide">
|
||||
<advanced-camera-card-viewer-provider
|
||||
${keyed(
|
||||
mediaEpoch,
|
||||
html`<advanced-camera-card-viewer-provider
|
||||
.hass=${this.hass}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
.media=${media}
|
||||
@@ -473,7 +476,8 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
></advanced-camera-card-viewer-provider>
|
||||
></advanced-camera-card-viewer-provider>`,
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,10 @@ import { CardWideConfig } from '../../config/schema/types.js';
|
||||
import { ViewerConfig } from '../../config/schema/viewer.js';
|
||||
import { ResolvedMediaCache } from '../../ha/resolved-media.js';
|
||||
import { HomeAssistant } from '../../ha/types.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import '../../patches/ha-hls-player.js';
|
||||
import viewerStyle from '../../scss/viewer.scss';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||
import { renderMessage } from '../message.js';
|
||||
import { renderNoMedia } from '../notification/no-media.js';
|
||||
import './grid';
|
||||
|
||||
export interface MediaViewerViewContext {
|
||||
@@ -81,15 +80,10 @@ export class AdvancedCameraCardViewer extends LitElement {
|
||||
// Directly render an error message (instead of dispatching it upwards)
|
||||
// to preserve the mini-timeline if the user pans into an area with no
|
||||
// media.
|
||||
const loadingMedia =
|
||||
!!this.viewManagerEpoch.manager.getView()?.context?.loading?.query;
|
||||
return renderMessage({
|
||||
type: 'info',
|
||||
message: loadingMedia
|
||||
? localize('error.awaiting_media')
|
||||
: localize('common.no_media'),
|
||||
icon: 'mdi:multimedia',
|
||||
dotdotdot: loadingMedia,
|
||||
return renderNoMedia({
|
||||
cameraID: this.viewManagerEpoch.manager.getView()?.camera ?? null,
|
||||
cameraManager: this.cameraManager ?? null,
|
||||
loading: !!this.viewManagerEpoch.manager.getView()?.context?.loading?.query,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||
import { VideoContentType, ViewMedia } from '../../view/item.js';
|
||||
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer.js';
|
||||
import '../image-player.js';
|
||||
import { renderMessage } from '../message.js';
|
||||
import { renderNotificationBlockFromText } from '../notification/block.js';
|
||||
import { renderProgressIndicator } from '../progress-indicator.js';
|
||||
import '../video-player.js';
|
||||
import './../media-dimensions-container';
|
||||
@@ -233,13 +233,13 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
||||
|
||||
const error = this._signedURLController.getError();
|
||||
if (error) {
|
||||
return renderMessage({
|
||||
type: 'error',
|
||||
message: localize(
|
||||
error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign',
|
||||
),
|
||||
context: this.media?.getContentID(),
|
||||
});
|
||||
const contentID = this.media?.getContentID();
|
||||
return renderNotificationBlockFromText(
|
||||
localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
|
||||
{
|
||||
...(contentID && { metadata: [{ text: contentID, icon: 'mdi:identifier' }] }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const url = this._signedURLController.getValue();
|
||||
|
||||
@@ -10,7 +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 { IssuePresence } from '../card-controller/issues/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';
|
||||
@@ -70,7 +70,7 @@ export class AdvancedCameraCardViews extends LitElement {
|
||||
public deviceRegistryManager?: DeviceRegistryManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public problems?: ProblemPresence;
|
||||
public issues?: IssuePresence;
|
||||
|
||||
@property({ attribute: false })
|
||||
public conditionStateManager?: ConditionStateManagerReadonlyInterface;
|
||||
@@ -215,7 +215,7 @@ export class AdvancedCameraCardViews extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.rawConfig=${this.rawConfig}
|
||||
.deviceRegistryManager=${this.deviceRegistryManager}
|
||||
.problems=${this.problems}
|
||||
.issues=${this.issues}
|
||||
>
|
||||
</advanced-camera-card-diagnostics>`
|
||||
: ``}
|
||||
|
||||
@@ -25,13 +25,10 @@ export class ConditionStateManager implements ConditionStateManagerReadonlyInter
|
||||
return this._state;
|
||||
}
|
||||
|
||||
public setState(state: ConditionState): void {
|
||||
this._processStateChange(this._calculateTrueChange(state));
|
||||
}
|
||||
|
||||
private _processStateChange(changeState: ConditionState): void {
|
||||
public setState(state: ConditionState): boolean {
|
||||
const changeState = this._calculateTrueChange(state);
|
||||
if (!Object.keys(changeState).length) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldState = this._state;
|
||||
@@ -40,6 +37,7 @@ export class ConditionStateManager implements ConditionStateManagerReadonlyInter
|
||||
...changeState,
|
||||
};
|
||||
this._callListeners({ old: oldState, change: changeState, new: this._state });
|
||||
return true;
|
||||
}
|
||||
|
||||
private _calculateTrueChange(change: ConditionState): ConditionState {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IssuePresence } from '../card-controller/issues/types';
|
||||
import { KeysState, MicrophoneState } from '../card-controller/types';
|
||||
import { AdvancedCameraCardView } from '../config/schema/common/const';
|
||||
import { ViewDisplayMode } from '../config/schema/common/display';
|
||||
@@ -17,7 +18,11 @@ export interface ConditionState {
|
||||
mediaLoadedInfo?: MediaLoadedInfo | null;
|
||||
microphone?: MicrophoneState;
|
||||
panel?: boolean;
|
||||
issues?: IssuePresence;
|
||||
hass?: HomeAssistant;
|
||||
|
||||
// Generic media target identifier. See @view/target-id for details.
|
||||
targetID?: string;
|
||||
triggered?: Set<string>;
|
||||
userAgent?: string;
|
||||
view?: AdvancedCameraCardView;
|
||||
|
||||
@@ -136,10 +136,12 @@ export type NotificationControl = z.infer<typeof notificationControlSchema>;
|
||||
|
||||
const notificationSchema = z.object({
|
||||
heading: notificationDetailSchema.optional(),
|
||||
controls: notificationControlSchema.array().optional(),
|
||||
details: notificationDetailSchema.array().optional(),
|
||||
text: z.string().optional(),
|
||||
body: notificationDetailSchema.optional(),
|
||||
metadata: notificationDetailSchema.array().optional(),
|
||||
context: z.string().array().optional(),
|
||||
link: linkSchema.optional(),
|
||||
in_progress: z.boolean().optional(),
|
||||
controls: notificationControlSchema.array().optional(),
|
||||
});
|
||||
export type Notification = z.infer<typeof notificationSchema>;
|
||||
|
||||
|
||||
@@ -86,15 +86,29 @@ const castConfigDefault = {
|
||||
method: 'standard' as const,
|
||||
};
|
||||
|
||||
const castSchema = z.object({
|
||||
method: z.enum(['standard', 'dashboard']).default(castConfigDefault.method).optional(),
|
||||
const castSchema = z
|
||||
.object({
|
||||
method: z
|
||||
.enum(['standard', 'dashboard'])
|
||||
.default(castConfigDefault.method)
|
||||
.optional(),
|
||||
dashboard: z
|
||||
.object({
|
||||
dashboard_path: z.string().optional(),
|
||||
view_path: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
})
|
||||
.refine(
|
||||
(cast) =>
|
||||
cast.method !== 'dashboard' ||
|
||||
(cast.dashboard?.dashboard_path && cast.dashboard?.view_path),
|
||||
{
|
||||
message:
|
||||
'dashboard_path and view_path are required when cast method is "dashboard"',
|
||||
path: ['dashboard'],
|
||||
},
|
||||
);
|
||||
|
||||
// *************************************************************************
|
||||
// Camera Configuration
|
||||
|
||||
@@ -3,6 +3,7 @@ import { STATUS_BAR_PRIORITY_DEFAULT, STATUS_BAR_PRIORITY_MAX } from './const';
|
||||
|
||||
export const statusBarItemBaseSchema = z.object({
|
||||
enabled: z.boolean().default(true).optional(),
|
||||
permanent: z.boolean().default(false).optional(),
|
||||
priority: z
|
||||
.number()
|
||||
.min(0)
|
||||
|
||||
@@ -16,21 +16,34 @@ const STATUS_BAR_POSITIONS = ['top', 'bottom'] as const;
|
||||
const statusBarItemDefault = {
|
||||
priority: STATUS_BAR_PRIORITY_DEFAULT,
|
||||
enabled: true,
|
||||
permanent: false,
|
||||
};
|
||||
|
||||
// Issues share a single status-bar item config so per-issue granularity
|
||||
// doesn't leak into the schema/editor surface. Defaults to permanent so the
|
||||
// status bar stays visible while any issue is active, even in popup mode.
|
||||
const statusBarIssuesItemDefault = {
|
||||
...statusBarItemDefault,
|
||||
permanent: true,
|
||||
};
|
||||
|
||||
// Extend the base schema so the `permanent: true` default survives a user
|
||||
// override that touches only one sibling field (e.g. `issues.enabled: true`).
|
||||
// With the item-level default alone, zod would reparse with the base schema's
|
||||
// field-level `permanent: false` and silently flip the behavior.
|
||||
const statusBarIssuesItemSchema = statusBarItemBaseSchema.extend({
|
||||
permanent: z.boolean().default(true).optional(),
|
||||
});
|
||||
|
||||
export const statusBarConfigDefault = {
|
||||
height: 40,
|
||||
items: {
|
||||
engine: statusBarItemDefault,
|
||||
issues: statusBarIssuesItemDefault,
|
||||
resolution: statusBarItemDefault,
|
||||
severity: statusBarItemDefault,
|
||||
technology: statusBarItemDefault,
|
||||
title: statusBarItemDefault,
|
||||
|
||||
// Problems.
|
||||
problem_config_upgrade: statusBarItemDefault,
|
||||
problem_legacy_resource: statusBarItemDefault,
|
||||
problem_stream_not_loading: statusBarItemDefault,
|
||||
},
|
||||
position: 'bottom' as const,
|
||||
style: 'popup' as const,
|
||||
@@ -50,6 +63,7 @@ export const statusBarConfigSchema = z
|
||||
items: z
|
||||
.object({
|
||||
engine: statusBarItemBaseSchema.default(statusBarConfigDefault.items.engine),
|
||||
issues: statusBarIssuesItemSchema.default(statusBarConfigDefault.items.issues),
|
||||
resolution: statusBarItemBaseSchema.default(
|
||||
statusBarConfigDefault.items.resolution,
|
||||
),
|
||||
@@ -58,17 +72,6 @@ export const statusBarConfigSchema = z
|
||||
statusBarConfigDefault.items.technology,
|
||||
),
|
||||
title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title),
|
||||
|
||||
// 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),
|
||||
})
|
||||
|
||||
@@ -74,6 +74,10 @@ export const viewConfigDefault = {
|
||||
untrigger_force_seconds: 0,
|
||||
},
|
||||
keyboard_shortcuts: keyboardShortcutsDefault,
|
||||
issues: {
|
||||
interaction_mode: 'all' as const,
|
||||
retry_seconds: 'auto' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const interactionModeSchema = z
|
||||
@@ -146,6 +150,16 @@ export const viewConfigSchema = z
|
||||
keyboard_shortcuts: keyboardShortcutsSchema.default(
|
||||
viewConfigDefault.keyboard_shortcuts,
|
||||
),
|
||||
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2099
|
||||
issues: z
|
||||
.object({
|
||||
interaction_mode: interactionModeSchema,
|
||||
retry_seconds: z
|
||||
.union([z.literal('auto'), z.number().min(0)])
|
||||
.default(viewConfigDefault.issues.retry_seconds),
|
||||
})
|
||||
.default(viewConfigDefault.issues),
|
||||
})
|
||||
.extend(actionsSchema.shape)
|
||||
.default(viewConfigDefault);
|
||||
|
||||
+7
-6
@@ -5,10 +5,8 @@ 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`;
|
||||
export const TROUBLESHOOTING_MEDIA_URL =
|
||||
`${TROUBLESHOOTING_URL}?id=media-does-not-load` as const;
|
||||
|
||||
export const CONF_AUTOMATIONS = 'automations' as const;
|
||||
|
||||
@@ -154,6 +152,11 @@ export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const;
|
||||
export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const;
|
||||
export const CONF_VIEW_DIM = `${CONF_VIEW}.dim` as const;
|
||||
export const CONF_VIEW_INTERACTION_SECONDS = `${CONF_VIEW}.interaction_seconds` as const;
|
||||
export const CONF_VIEW_ISSUES = `${CONF_VIEW}.issues` as const;
|
||||
export const CONF_VIEW_ISSUES_INTERACTION_MODE =
|
||||
`${CONF_VIEW_ISSUES}.interaction_mode` as const;
|
||||
export const CONF_VIEW_ISSUES_RETRY_SECONDS =
|
||||
`${CONF_VIEW_ISSUES}.retry_seconds` as const;
|
||||
export const CONF_VIEW_KEYBOARD_SHORTCUTS = `${CONF_VIEW}.keyboard_shortcuts` as const;
|
||||
export const CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED =
|
||||
`${CONF_VIEW}.keyboard_shortcuts.enabled` as const;
|
||||
@@ -453,5 +456,3 @@ export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
|
||||
// improved rendering performance.
|
||||
export const MEDIA_CHUNK_SIZE_DEFAULT = 50;
|
||||
export const MEDIA_CHUNK_SIZE_MAX = 1000;
|
||||
|
||||
export const IMAGE_VIEW_ZOOM_TARGET_SENTINEL = '__IMAGE_VIEW_ZOOM__';
|
||||
|
||||
Vendored
+4
@@ -5,6 +5,10 @@ declare module 'view' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface ViewContext {}
|
||||
}
|
||||
declare module 'issue' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface IssueTriggerContext {}
|
||||
}
|
||||
declare module 'action' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface ActionContext {}
|
||||
|
||||
+79
-67
@@ -3,7 +3,7 @@ import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import './components/icon.js';
|
||||
import './components/key-assigner.js';
|
||||
import { renderMessage } from './components/message.js';
|
||||
import { renderNotificationBlock } from './components/notification/block.js';
|
||||
import {
|
||||
copyConfig,
|
||||
deleteConfigValue,
|
||||
@@ -246,6 +246,9 @@ import {
|
||||
CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE,
|
||||
CONF_VIEW_DIM,
|
||||
CONF_VIEW_INTERACTION_SECONDS,
|
||||
CONF_VIEW_ISSUES,
|
||||
CONF_VIEW_ISSUES_INTERACTION_MODE,
|
||||
CONF_VIEW_ISSUES_RETRY_SECONDS,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN,
|
||||
@@ -266,7 +269,6 @@ import {
|
||||
CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS,
|
||||
CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS,
|
||||
DOCS_URL,
|
||||
FOLDERS_CONFIGURATION_URL,
|
||||
MEDIA_CHUNK_SIZE_MAX,
|
||||
} from './const.js';
|
||||
import { fireHASSEvent } from './ha/fire-hass-event.js';
|
||||
@@ -329,6 +331,7 @@ const MENU_STATUS_BAR_ITEMS = 'status_bar.items';
|
||||
const MENU_TIMELINE_FORMAT = 'timeline.format';
|
||||
const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
|
||||
const MENU_VIEW_DEFAULT_RESET = 'view.default_reset';
|
||||
const MENU_VIEW_ISSUES = 'view.issues';
|
||||
const MENU_VIEW_KEYBOARD_SHORTCUTS = 'view.keyboard_shortcuts';
|
||||
const MENU_VIEW_TRIGGERS = 'view.triggers';
|
||||
const MENU_VIEW_TRIGGERS_ACTIONS = 'view.triggers.actions';
|
||||
@@ -352,6 +355,7 @@ const SECTION_DOC_LINKS: Record<string, string> = {
|
||||
|
||||
const SUBMENU_DOC_LINKS: Record<string, string> = {
|
||||
[MENU_VIEW_DEFAULT_RESET]: 'configuration/view?id=default_reset',
|
||||
[MENU_VIEW_ISSUES]: 'configuration/view?id=issues',
|
||||
[MENU_VIEW_TRIGGERS]: 'configuration/view?id=triggers',
|
||||
[MENU_VIEW_TRIGGERS_ACTIONS]: 'configuration/view?id=trigger-action-configuration',
|
||||
[MENU_VIEW_KEYBOARD_SHORTCUTS]: 'configuration/view?id=keyboard_shortcuts',
|
||||
@@ -904,19 +908,19 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
},
|
||||
];
|
||||
|
||||
private _triggersActionsInteractionModes: EditorSelectOption[] = [
|
||||
private _interactionModes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'all',
|
||||
label: localize('config.view.triggers.actions.interaction_modes.all'),
|
||||
label: localize('config.common.interaction_modes.all'),
|
||||
},
|
||||
{
|
||||
value: 'inactive',
|
||||
label: localize('config.view.triggers.actions.interaction_modes.inactive'),
|
||||
label: localize('config.common.interaction_modes.inactive'),
|
||||
},
|
||||
{
|
||||
value: 'active',
|
||||
label: localize('config.view.triggers.actions.interaction_modes.active'),
|
||||
label: localize('config.common.interaction_modes.active'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1043,22 +1047,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
},
|
||||
];
|
||||
|
||||
private _defaultResetInteractionModes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'all',
|
||||
label: localize('config.view.default_reset.interaction_modes.all'),
|
||||
},
|
||||
{
|
||||
value: 'inactive',
|
||||
label: localize('config.view.default_reset.interaction_modes.inactive'),
|
||||
},
|
||||
{
|
||||
value: 'active',
|
||||
label: localize('config.view.default_reset.interaction_modes.active'),
|
||||
},
|
||||
];
|
||||
|
||||
private _proxyModes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
@@ -1180,10 +1168,8 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
|
||||
protected willUpdate(): void {
|
||||
if (!this._initialized) {
|
||||
sideLoadHomeAssistantElements().then((success) => {
|
||||
if (success) {
|
||||
sideLoadHomeAssistantElements().then(() => {
|
||||
this._initialized = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1467,7 +1453,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
${this._renderNumberInput(CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE,
|
||||
this._defaultResetInteractionModes,
|
||||
this._interactionModes,
|
||||
{
|
||||
label: localize('config.view.default_reset.interaction_mode'),
|
||||
},
|
||||
@@ -1483,6 +1469,25 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
);
|
||||
}
|
||||
|
||||
private _renderViewIssuesMenu(): TemplateResult {
|
||||
return this._putInSubmenu(
|
||||
MENU_VIEW_ISSUES,
|
||||
true,
|
||||
`config.${CONF_VIEW_ISSUES}.editor_label`,
|
||||
'mdi:alert-circle-outline',
|
||||
html`
|
||||
${this._renderOptionSelector(
|
||||
CONF_VIEW_ISSUES_INTERACTION_MODE,
|
||||
this._interactionModes,
|
||||
{
|
||||
label: localize('config.view.issues.interaction_mode'),
|
||||
},
|
||||
)}
|
||||
${this._renderNumberInput(CONF_VIEW_ISSUES_RETRY_SECONDS, { min: 0 })}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
private _renderViewTriggersMenu(): TemplateResult {
|
||||
return this._putInSubmenu(
|
||||
MENU_VIEW_TRIGGERS,
|
||||
@@ -1531,7 +1536,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
|
||||
this._triggersActionsInteractionModes,
|
||||
this._interactionModes,
|
||||
{
|
||||
label: localize('config.view.triggers.actions.interaction_mode'),
|
||||
},
|
||||
@@ -2346,12 +2351,10 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(CONF_FOLDERS_ARRAY_HA_URL, folderIndex),
|
||||
)}
|
||||
${renderMessage({
|
||||
message: localize('config.folders.ha.path_info'),
|
||||
${renderNotificationBlock({
|
||||
body: {
|
||||
text: localize('config.folders.ha.path_info'),
|
||||
icon: 'mdi:information-outline',
|
||||
link: {
|
||||
url: FOLDERS_CONFIGURATION_URL,
|
||||
title: localize('error.configuration'),
|
||||
},
|
||||
})}
|
||||
`,
|
||||
@@ -3064,8 +3067,12 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
|
||||
this._defaults.view.default_cycle_camera,
|
||||
)}
|
||||
${this._renderViewDefaultResetMenu()} ${this._renderViewTriggersMenu()}
|
||||
${this._renderViewKeyboardShortcutMenu()}
|
||||
${[
|
||||
this._renderViewDefaultResetMenu(),
|
||||
this._renderViewIssuesMenu(),
|
||||
this._renderViewTriggersMenu(),
|
||||
this._renderViewKeyboardShortcutMenu(),
|
||||
]}
|
||||
${this._renderOptionSelector(CONF_VIEW_THEME_THEMES, this._themes, {
|
||||
label: localize('config.view.theme.themes.editor_label'),
|
||||
multiple: true,
|
||||
@@ -3086,40 +3093,42 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
${this._renderNumberInput(CONF_MENU_BUTTON_SIZE, {
|
||||
min: BUTTON_SIZE_MIN,
|
||||
})}
|
||||
${this._renderMenuButton('iris') /* */}
|
||||
${this._renderMenuButton('camera_ui')}
|
||||
${this._renderMenuButton('cameras') /* */}
|
||||
${this._renderMenuButton('clips')}
|
||||
${this._renderMenuButton('display_mode')}
|
||||
${this._renderMenuButton('download') /* */}
|
||||
${this._renderMenuButton('expand') /* */}
|
||||
${this._renderMenuButton('folders')}
|
||||
${this._renderMenuButton('fullscreen')}
|
||||
${this._renderMenuButton('gallery')}
|
||||
${this._renderMenuButton('image') /* */}
|
||||
${this._renderMenuButton('info') /* */}
|
||||
${this._renderMenuButton('live')}
|
||||
${this._renderMenuButton('media_player')}
|
||||
${this._renderMenuButton(
|
||||
${[
|
||||
this._renderMenuButton('iris'),
|
||||
this._renderMenuButton('camera_ui'),
|
||||
this._renderMenuButton('cameras'),
|
||||
this._renderMenuButton('clips'),
|
||||
this._renderMenuButton('display_mode'),
|
||||
this._renderMenuButton('download'),
|
||||
this._renderMenuButton('expand'),
|
||||
this._renderMenuButton('folders'),
|
||||
this._renderMenuButton('fullscreen'),
|
||||
this._renderMenuButton('gallery'),
|
||||
this._renderMenuButton('image'),
|
||||
this._renderMenuButton('info'),
|
||||
this._renderMenuButton('live'),
|
||||
this._renderMenuButton('media_player'),
|
||||
this._renderMenuButton(
|
||||
'microphone',
|
||||
html`${this._renderOptionSelector(
|
||||
`${CONF_MENU_BUTTONS}.microphone.type`,
|
||||
this._microphoneButtonTypes,
|
||||
{ label: localize('config.menu.buttons.type') },
|
||||
)}`,
|
||||
)}
|
||||
${this._renderMenuButton('mute') /* */}
|
||||
${this._renderMenuButton('pip') /* */}
|
||||
${this._renderMenuButton('play')}
|
||||
${this._renderMenuButton('ptz_controls')}
|
||||
${this._renderMenuButton('ptz_home')}
|
||||
${this._renderMenuButton('recordings')}
|
||||
${this._renderMenuButton('reviews')}
|
||||
${this._renderMenuButton('screenshot')}
|
||||
${this._renderMenuButton('set_review')}
|
||||
${this._renderMenuButton('snapshots')}
|
||||
${this._renderMenuButton('substreams')}
|
||||
${this._renderMenuButton('timeline')}
|
||||
),
|
||||
this._renderMenuButton('mute'),
|
||||
this._renderMenuButton('pip'),
|
||||
this._renderMenuButton('play'),
|
||||
this._renderMenuButton('ptz_controls'),
|
||||
this._renderMenuButton('ptz_home'),
|
||||
this._renderMenuButton('recordings'),
|
||||
this._renderMenuButton('reviews'),
|
||||
this._renderMenuButton('screenshot'),
|
||||
this._renderMenuButton('set_review'),
|
||||
this._renderMenuButton('snapshots'),
|
||||
this._renderMenuButton('substreams'),
|
||||
this._renderMenuButton('timeline'),
|
||||
]}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
@@ -3148,11 +3157,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
|
||||
default: this._defaults.status_bar.popup_seconds,
|
||||
label: localize('config.status_bar.popup_seconds'),
|
||||
})}
|
||||
${this._renderStatusBarItem('title') /* */}
|
||||
${this._renderStatusBarItem('resolution') /* */}
|
||||
${this._renderStatusBarItem('technology') /* */}
|
||||
${this._renderStatusBarItem('engine') /* */}
|
||||
${this._renderStatusBarItem('upgrade') /* */}
|
||||
${[
|
||||
this._renderStatusBarItem('title'),
|
||||
this._renderStatusBarItem('resolution'),
|
||||
this._renderStatusBarItem('technology'),
|
||||
this._renderStatusBarItem('engine'),
|
||||
this._renderStatusBarItem('severity'),
|
||||
this._renderStatusBarItem('issues'),
|
||||
]}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { HomeAssistant } from './types';
|
||||
|
||||
/**
|
||||
* Determine if HA connection state has changed.
|
||||
* @param newHass The new HA object.
|
||||
* @param oldHass The old HA object.
|
||||
* @returns `true` if the connection state has changed.
|
||||
*/
|
||||
export const hasHAConnectionStateChanged = (
|
||||
oldHass?: HomeAssistant | null,
|
||||
newHass?: HomeAssistant | null,
|
||||
): boolean => {
|
||||
return oldHass?.connected !== newHass?.connected;
|
||||
};
|
||||
@@ -1,12 +1,19 @@
|
||||
import { localize } from '../localize/localize';
|
||||
import { AdvancedCameraCardError } from '../types';
|
||||
import { CardHelpers, LovelaceCardWithEditor } from '../types';
|
||||
|
||||
class HomeAssistantElementsLoadError extends AdvancedCameraCardError {
|
||||
constructor() {
|
||||
super(localize('error.home_assistant_elements_load_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Side loads the HA elements this card needs. This trickery is unfortunate
|
||||
* necessary, see:
|
||||
* - https://github.com/thomasloven/hass-config/wiki/PreLoading-Lovelace-Elements
|
||||
* @returns `true` if the load is successful, `false` otherwise.
|
||||
*/
|
||||
export const sideLoadHomeAssistantElements = async (): Promise<boolean> => {
|
||||
export const sideLoadHomeAssistantElements = async (): Promise<void> => {
|
||||
const neededElements = [
|
||||
'ha-button',
|
||||
'ha-camera-stream',
|
||||
@@ -27,7 +34,7 @@ export const sideLoadHomeAssistantElements = async (): Promise<boolean> => {
|
||||
];
|
||||
|
||||
if (neededElements.every((element) => customElements.get(element))) {
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -49,12 +56,10 @@ export const sideLoadHomeAssistantElements = async (): Promise<boolean> => {
|
||||
await customElements.whenDefined('hui-picture-glance-card');
|
||||
const pgcConstructor = customElements.get('hui-picture-glance-card');
|
||||
if (!pgcConstructor) {
|
||||
return false;
|
||||
throw new HomeAssistantElementsLoadError();
|
||||
}
|
||||
|
||||
const pgc = new pgcConstructor() as LovelaceCardWithEditor;
|
||||
|
||||
await pgc.constructor.getConfigElement();
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"in_progress": "En procés",
|
||||
"no_media": "No hi ha suport per mostrar"
|
||||
},
|
||||
"config": {
|
||||
@@ -106,15 +107,6 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"media_types": {
|
||||
"events": "Esdeveniments",
|
||||
"recordings": "Enregistraments"
|
||||
},
|
||||
"events_media_types": {
|
||||
"all": "Tots els tipus de mitjans",
|
||||
"clips": "Clips",
|
||||
"snapshots": "Imatges instantànies"
|
||||
},
|
||||
"controls": {
|
||||
"builtin": "Controls de vídeo integrats",
|
||||
"filter": {
|
||||
@@ -177,6 +169,11 @@
|
||||
"grid_selected_width_factor": "Augmenta l'amplada del suport multimèdia seleccionat en aquest factor",
|
||||
"mode": "Mode"
|
||||
},
|
||||
"events_media_types": {
|
||||
"all": "Tots els tipus de mitjans",
|
||||
"clips": "Clips",
|
||||
"snapshots": "Imatges instantànies"
|
||||
},
|
||||
"image": {
|
||||
"mode": "Mode de visualització d'imatges",
|
||||
"modes": {
|
||||
@@ -194,6 +191,10 @@
|
||||
"unselected": "En desselecció",
|
||||
"visible": "En la visualització del navegador/pestanya"
|
||||
},
|
||||
"media_types": {
|
||||
"events": "Esdeveniments",
|
||||
"recordings": "Enregistraments"
|
||||
},
|
||||
"timeline": {
|
||||
"clustering_threshold": "El recompte d'esdeveniments en què s'agrupen (0=sense agrupació)",
|
||||
"show_recordings": "Mostra les gravacions",
|
||||
@@ -365,11 +366,6 @@
|
||||
"actions": {
|
||||
"editor_label": "Activar accions",
|
||||
"interaction_mode": "Com gestionar les accions quan la targeta té interacció humana",
|
||||
"interaction_modes": {
|
||||
"active": "Activa només accions quan la targeta té interacció humana",
|
||||
"all": "Activa accions independentment de la interacció humana",
|
||||
"inactive": "Només activa accions quan la targeta no té interacció humana"
|
||||
},
|
||||
"trigger": "Activar l'acció",
|
||||
"triggers": {
|
||||
"default": "Canvia o actualitza la vista predeterminada",
|
||||
@@ -450,7 +446,7 @@
|
||||
},
|
||||
"error": {
|
||||
"awaiting_live": "S'està esperant que es carregui la transmissió en directe ...",
|
||||
"could_not_render_elements": "No s'han pogut representar els elements de la imatge",
|
||||
"could_not_create_elements": "No s'han pogut representar els elements de la imatge",
|
||||
"diagnostics": "Diagnòstic de targetes. Reviseu la informació confidencial abans de compartir-la",
|
||||
"download_no_media": "No hi ha contingut multimèdia per descarregar",
|
||||
"download_sign_failed": "No s'ha pogut signar l'URL multimèdia per baixar-la",
|
||||
@@ -516,7 +512,6 @@
|
||||
"camera": "Càmera",
|
||||
"download": "Descarregar mitjans multimèdia",
|
||||
"duration": "Durada",
|
||||
"in_progress": "En procés",
|
||||
"no_thumbnail": "No hi ha cap miniatura disponible",
|
||||
"retain_indefinitely": "Els mitjans multimèdia es conservaran indefinidament",
|
||||
"seek": "Buscar",
|
||||
|
||||
@@ -301,12 +301,7 @@
|
||||
"after_interaction": "Zurücksetzen auf Standardansicht nach Ende der Benutzerinteraktion",
|
||||
"editor_label": "Verhalten Zurücksetzen auf Standardansicht",
|
||||
"entities": "Nach Zustandsänderung der Entität Zurücksetzen auf Standardansicht",
|
||||
"interaction_mode": "Wie der Standard-Reset bei Benutzerinteraktion verhalten soll",
|
||||
"interaction_modes": {
|
||||
"active": "Zeige nur wenn Karte Nutzerinteraktion erfährt",
|
||||
"all": "Zurücksetzen unabhängig von Benutzerinteraktion",
|
||||
"inactive": "Nur zurücksetzen wenn keine Nutzerinteraktion"
|
||||
}
|
||||
"interaction_mode": "Wie der Standard-Reset bei Benutzerinteraktion verhalten soll"
|
||||
},
|
||||
"dim": "Abdimmen wenn keine Interaktion",
|
||||
"interaction_seconds": "Sekunden nach der Interaktion aktiv bleiben (0=niemals)",
|
||||
@@ -334,11 +329,6 @@
|
||||
"actions": {
|
||||
"editor_label": "Auslöseaktion",
|
||||
"interaction_mode": "Wie Aktion verarbeiten, wenn Karte Nutzerinteraktion hat",
|
||||
"interaction_modes": {
|
||||
"active": "Nur auslösen, wenn Karte Nutzerinteraktion hat",
|
||||
"all": "Auslösen unabhängig von Nutzerinteraktion",
|
||||
"inactive": "Nur auslösen wenn Karte keine Nutzerinteraktion hat"
|
||||
},
|
||||
"trigger": "Auslöseaktion",
|
||||
"triggers": {
|
||||
"default": "Wechseln in oder Anpassen der Standardansicht",
|
||||
@@ -399,11 +389,16 @@
|
||||
"invalid_configuration_override": "Ungültige Konfiguration für Ersetzung",
|
||||
"no_camera_or_media_for_timeline": "Keine Kamera oder Medium für Zeitleiste",
|
||||
"no_dashboard_or_view": "Beide Prameter 'dashboard_path' und 'view_path' sind für die Sende-Methode 'dashboard' erforderlich",
|
||||
"no_supported_camera": "Die ausgewählte Kamera unterstützt diese Ansicht nicht",
|
||||
"no_supported_cameras": "Keine Kameras unterstützen diese Ansicht",
|
||||
"stream_not_loading": "Der Videostream wurde nicht geladen. Dafür kann es verschiedene Gründe geben. Wenn konfiguriert (Standard), wird ein Bild jede Sekunde angezeigt, bis der Steam richtig lädt",
|
||||
"too_many_automations": "Zu viele automatisierte Anfragen, bitte prüfe deine Konfiguration auf etwaige Schleifen"
|
||||
},
|
||||
"issues": {
|
||||
"media_load": {
|
||||
"text": "Der Videostream wurde nicht geladen. Dafür kann es verschiedene Gründe geben. Wenn konfiguriert (Standard), wird ein Bild jede Sekunde angezeigt, bis der Steam richtig lädt"
|
||||
},
|
||||
"view_incompatible": {
|
||||
"text": "Die ausgewählte Kamera unterstützt diese Ansicht nicht"
|
||||
}
|
||||
},
|
||||
"key_assigner": {
|
||||
"assign": "Zuweisen",
|
||||
"modifiers": {
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"advanced_camera_card": "Advanced Camera Card",
|
||||
"advanced_camera_card_description": "An Advanced Camera Card",
|
||||
"folder": "Folder",
|
||||
"in_progress": "In progress...",
|
||||
"no_media": "No media to display",
|
||||
"retry": "Retry",
|
||||
"set_reviews": {
|
||||
"reviewed": "Mark as reviewed",
|
||||
"unreviewed": "Mark as unreviewed"
|
||||
@@ -302,6 +304,11 @@
|
||||
"refresh_seconds": "Seconds between refreshes (0=never)",
|
||||
"url": "Static image URL"
|
||||
},
|
||||
"interaction_modes": {
|
||||
"active": "Only when actively interacting",
|
||||
"all": "Always regardless of interaction",
|
||||
"inactive": "Only when not interacting"
|
||||
},
|
||||
"media_action_conditions": {
|
||||
"hidden": "On browser/tab hiding",
|
||||
"microphone_mute": "On microphone mute",
|
||||
@@ -549,11 +556,12 @@
|
||||
"items": {
|
||||
"enabled": "Item enabled",
|
||||
"engine": "Camera engine",
|
||||
"issues": "Issues",
|
||||
"priority": "Item priority",
|
||||
"resolution": "Resolution",
|
||||
"severity": "Severity",
|
||||
"technology": "Technology",
|
||||
"title": "Title",
|
||||
"upgrade": "Configuration upgrade notification"
|
||||
"title": "Title"
|
||||
},
|
||||
"popup_seconds": "Status bar popup seconds",
|
||||
"position": "Status bar position",
|
||||
@@ -580,15 +588,15 @@
|
||||
"editor_label": "Default view reset behavior",
|
||||
"entities": "Reset to the default view on entity state change",
|
||||
"every_seconds": "Reset to default view every X seconds (0=never)",
|
||||
"interaction_mode": "How default reset behaves when the card has human interaction",
|
||||
"interaction_modes": {
|
||||
"active": "Only allow reset when card has active human interaction",
|
||||
"all": "Reset regardless of human interaction",
|
||||
"inactive": "Only reset when card has no human interaction"
|
||||
}
|
||||
"interaction_mode": "How default reset behaves when the card has human interaction"
|
||||
},
|
||||
"dim": "Dim when no interaction",
|
||||
"interaction_seconds": "Seconds of inactivity before interaction state ends (0=never)",
|
||||
"issues": {
|
||||
"editor_label": "Issues/Errors",
|
||||
"interaction_mode": "How automatic retries behave when the card has human interaction",
|
||||
"retry_seconds": "Seconds between automatic retries [default=adaptive, 0=disabled]"
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"editor_label": "Keyboard shortcuts",
|
||||
"enabled": "Keyboard shortcuts enabled",
|
||||
@@ -613,11 +621,6 @@
|
||||
"actions": {
|
||||
"editor_label": "Trigger actions",
|
||||
"interaction_mode": "How to handle actions when the card has human interaction",
|
||||
"interaction_modes": {
|
||||
"active": "Only trigger actions when card has human interaction",
|
||||
"all": "Trigger actions regardless of human interaction",
|
||||
"inactive": "Only trigger actions when card has no human interaction"
|
||||
},
|
||||
"trigger": "Trigger action",
|
||||
"triggers": {
|
||||
"default": "Change to or update default view",
|
||||
@@ -723,8 +726,9 @@
|
||||
"camera_initialization": "Camera initialization failed",
|
||||
"camera_initialization_reolink": "Could not initialize Reolink camera",
|
||||
"configuration": "Check configuration",
|
||||
"could_not_render_elements": "Could not render picture elements",
|
||||
"could_not_create_elements": "Could not create picture elements",
|
||||
"diagnostics": "Card diagnostics. Please review for confidential information prior to sharing",
|
||||
"download_failed": "Download failed",
|
||||
"download_no_media": "No media to download",
|
||||
"download_sign_failed": "Could not sign media URL for download",
|
||||
"duplicate_camera_id": "Duplicate camera id for the following camera, use the 'id' parameter to uniquely identify cameras",
|
||||
@@ -736,6 +740,7 @@
|
||||
"failed_retain": "Could not retain event",
|
||||
"failed_sign": "Could not sign Home Assistant URL",
|
||||
"fetching_diagnostics": "Fetching diagnostics",
|
||||
"home_assistant_elements_load_failed": "Could not load required Home Assistant elements",
|
||||
"image_load_error": "Could not load image",
|
||||
"invalid_configuration": "Invalid configuration",
|
||||
"invalid_configuration_no_hint": "No location hint available (bad or missing type?)",
|
||||
@@ -746,15 +751,13 @@
|
||||
"live_camera_no_endpoint": "Could not get camera endpoint for this live provider (incomplete configuration?)",
|
||||
"live_camera_not_found": "The configured camera_entity was not found",
|
||||
"live_camera_unavailable": "Camera unavailable",
|
||||
"microphone_not_supported": "Microphone is not supported in this browser",
|
||||
"no_camera_engine": "Could not determine suitable engine for camera",
|
||||
"no_camera_entity": "Could not find camera entity",
|
||||
"no_camera_for_image": "No camera configured for camera image mode",
|
||||
"no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually",
|
||||
"no_camera_or_media_for_timeline": "No camera or media available for timeline",
|
||||
"no_dashboard_or_view": "Both 'dashboard_path' and 'view_path' parameters are required for the 'dashboard' cast method",
|
||||
"no_live_camera": "The camera_entity parameter must be set and valid for this live provider",
|
||||
"no_supported_camera": "The selected camera or media does not support this view",
|
||||
"no_supported_cameras": "No cameras support this view",
|
||||
"reconnecting": "Reconnecting",
|
||||
"too_many_automations": "Too many nested automation calls, please check your configuration for loops",
|
||||
"troubleshooting": "Check troubleshooting",
|
||||
@@ -762,6 +765,48 @@
|
||||
"webrtc_card_reported_error": "WebRTC Card reported an error",
|
||||
"webrtc_card_waiting": "Waiting for WebRTC Card to load..."
|
||||
},
|
||||
"issues": {
|
||||
"config_error": {
|
||||
"heading": "Configuration error",
|
||||
"text": "An error occurred evaluating the card configuration"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"connection": {
|
||||
"lost": {
|
||||
"heading": "Connection lost",
|
||||
"text": "Connection to Home Assistant lost"
|
||||
},
|
||||
"starting": {
|
||||
"heading": "Home Assistant is starting",
|
||||
"text": "Waiting for Home Assistant startup to complete"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"heading": "Initialization failed"
|
||||
},
|
||||
"legacy_resource": {
|
||||
"heading": "Legacy dashboard resource detected",
|
||||
"remove": "Remove legacy resource",
|
||||
"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"
|
||||
},
|
||||
"media_load": {
|
||||
"heading": "Media not loading",
|
||||
"text": "The media has not yet loaded. This could be for any number of reasons. For live views and if configured (and by default), there will be an image refreshing every second until the stream loads correctly or the media loading is retried"
|
||||
},
|
||||
"media_query": {
|
||||
"heading": "Media query failed",
|
||||
"text": "An error occurred fetching media (e.g. thumbnails) for this view."
|
||||
},
|
||||
"troubleshooting_guide": "Troubleshooting guide",
|
||||
"view_incompatible": {
|
||||
"heading": "View not supported",
|
||||
"text": "The selected camera or media does not support this view"
|
||||
}
|
||||
},
|
||||
"key_assigner": {
|
||||
"assign": "Assign",
|
||||
"modifiers": {
|
||||
@@ -809,28 +854,10 @@
|
||||
"media_viewer": {
|
||||
"unseekable": "Seek time not found in media"
|
||||
},
|
||||
"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": {
|
||||
"camera": "Camera",
|
||||
"download": "Download media",
|
||||
"duration": "Duration",
|
||||
"in_progress": "In Progress",
|
||||
"no_thumbnail": "No thumbnail available",
|
||||
"retain_indefinitely": "Retain media indefinitely",
|
||||
"seek": "Seek",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user