Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc427c1fcb | ||
|
|
b57fd0675a | ||
|
|
88e1fba401 | ||
|
|
54e261fd54 | ||
|
|
95b85bf608 | ||
|
|
299973a1fa | ||
|
|
fe7ac89982 | ||
|
|
bb6794ac49 | ||
|
|
04c2da6379 | ||
|
|
e6bb0eb0e0 |
@@ -0,0 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
Fork-specific changes only. Upstream releases are documented at
|
||||
https://github.com/dermotduffy/advanced-camera-card/releases
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
Fork versions are this fork's own semver line; each entry names the upstream
|
||||
release it is built from. A `+`-suffixed or pre-release tag was avoided
|
||||
deliberately: HACS hides pre-releases unless beta versions are enabled.
|
||||
|
||||
## [8.1.0] - 2026-09-04
|
||||
|
||||
Built from upstream [v8.0.2](https://github.com/dermotduffy/advanced-camera-card/releases/tag/v8.0.2).
|
||||
|
||||
### Added
|
||||
|
||||
- `cameras[].media.severities` restricts review media to the listed severities,
|
||||
so the thumbnail bar, media gallery and timeline can be pinned to Frigate
|
||||
alerts (`[high]`) instead of showing every detection. Resolved into the
|
||||
default query alongside `media.reviewed`, and overridden by an explicit
|
||||
severity chosen in the media filter drawer. Unset means all severities.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Fork notes
|
||||
|
||||
A fork of [Advanced Camera Card](https://github.com/dermotduffy/advanced-camera-card),
|
||||
carrying one change on top of upstream `main`.
|
||||
|
||||
## What this fork adds
|
||||
|
||||
### `cameras[].media.severities`
|
||||
|
||||
Upstream issues review queries with no severity filter, so the thumbnail bar
|
||||
("side ribbon"), the media gallery and the timeline show every Frigate review
|
||||
item — alerts and detections alike — regardless of what a camera is configured
|
||||
to alert on. Severity can only be narrowed interactively, in the media filter
|
||||
drawer, and that choice does not persist.
|
||||
|
||||
`media.severities` restricts review media to the listed severities, resolved
|
||||
into the default query the same way `media.reviewed` already is:
|
||||
|
||||
```yaml
|
||||
type: custom:advanced-camera-card
|
||||
cameras:
|
||||
- camera_entity: camera.front_doorbell
|
||||
media:
|
||||
severities:
|
||||
- high
|
||||
```
|
||||
|
||||
| Value | Frigate equivalent |
|
||||
| -------- | ------------------ |
|
||||
| `high` | alert |
|
||||
| `medium` | detection |
|
||||
| `low` | (no equivalent) |
|
||||
|
||||
An explicit severity chosen in the media filter drawer still wins, so the
|
||||
interactive filter keeps working. Unset means all severities, so a config that
|
||||
does not mention `severities` behaves exactly as upstream does.
|
||||
|
||||
## Versioning
|
||||
|
||||
This fork has its own semver line, starting at `8.1.0` (upstream `v8.0.2` plus
|
||||
the feature above). Tags are plain semver — HACS hides pre-release versions
|
||||
unless the user has enabled beta versions, so a `-flan.N` style tag would make
|
||||
the release invisible to HACS.
|
||||
|
||||
## Keeping up with upstream
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/dermotduffy/advanced-camera-card.git
|
||||
git fetch upstream
|
||||
git merge upstream/main
|
||||
yarn install --immutable && yarn typecheck && yarn lint && yarn test
|
||||
```
|
||||
|
||||
`dist/` is not committed (as upstream), so a merge should never conflict on
|
||||
build output.
|
||||
|
||||
## Releasing to HACS
|
||||
|
||||
HACS installs this card from **GitHub release assets**, not from the branch.
|
||||
That is not a preference — `gather_files_to_download()` in HACS
|
||||
(`custom_components/hacs/repositories/base.py`) skips every file under `dist/`
|
||||
whose basename does not start with `dist`, and this card's build emits a chunk
|
||||
named `dist-<hash>.js`. A branch install would therefore fetch that single
|
||||
chunk and nothing else.
|
||||
|
||||
To cut a release:
|
||||
|
||||
```bash
|
||||
RELEASE_VERSION=<version> yarn build
|
||||
gh release create <tag> dist/*.js --repo <mirror> --title <tag> --notes '...'
|
||||
```
|
||||
|
||||
Every file in `dist/` must be attached, `advanced-camera-card.js` included —
|
||||
HACS matches that name to decide the repository is release-based, then
|
||||
downloads all assets of the release.
|
||||
@@ -0,0 +1,10 @@
|
||||
This repository is a fork of Advanced Camera Card by Dermot Duffy:
|
||||
|
||||
https://github.com/dermotduffy/advanced-camera-card
|
||||
|
||||
Upstream code is used under the terms of the LICENSE file in this repository,
|
||||
which is unchanged from upstream. All upstream copyright remains with its
|
||||
authors.
|
||||
|
||||
Fork-specific changes (see FORK.md) were developed with AI assistance
|
||||
(Anthropic Claude).
|
||||
@@ -180,12 +180,13 @@ cameras:
|
||||
# [...]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | `auto` | The default media type to show for this camera. One of `auto`, `events`, `recordings`, `reviews` or `folder`. See [Media Types](#media-types) for description of each. |
|
||||
| `events_type` | `all` | If `type` is `events`, what subtype of events to show. One of `clips`, `snapshots` or `all` (default). |
|
||||
| `reviewed` | `unreviewed` | Whether to filter the media based on review status. One of `unreviewed` (default, shows only unreviewed media), `reviewed` (shorts only reviewed media) or `all` (show regardless of whether reviewed or unreviewed). Only relevant when `type` is `reviews` or `auto`. |
|
||||
| `folders` | | An optional list of folder IDs to use when `type` is `folder`. If not specified, and `type` is `folder`, will default to showing the default (first) configured folder. See [Folder Configuration](../folders.md) and the [worked example](../../examples.md?id=show-a-folder-as-a-cameras-default-media). |
|
||||
| Option | Default | Description |
|
||||
| ------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | `auto` | The default media type to show for this camera. One of `auto`, `events`, `recordings`, `reviews` or `folder`. See [Media Types](#media-types) for description of each. |
|
||||
| `events_type` | `all` | If `type` is `events`, what subtype of events to show. One of `clips`, `snapshots` or `all` (default). |
|
||||
| `reviewed` | `unreviewed` | Whether to filter the media based on review status. One of `unreviewed` (default, shows only unreviewed media), `reviewed` (shorts only reviewed media) or `all` (show regardless of whether reviewed or unreviewed). Only relevant when `type` is `reviews` or `auto`. |
|
||||
| `severities` | | An optional list of severities to restrict review media to. Any of `high` (Frigate alerts), `medium` (Frigate detections) or `low`. If unset, media of every severity is shown. Only relevant when `type` is `reviews` or `auto`. Use `[high]` to keep the thumbnail bar, gallery and timeline to Frigate alerts only, so that the labels a camera is not configured to alert on do not crowd out the ones it is. |
|
||||
| `folders` | | An optional list of folder IDs to use when `type` is `folder`. If not specified, and `type` is `folder`, will default to showing the default (first) configured folder. See [Folder Configuration](../folders.md) and the [worked example](../../examples.md?id=show-a-folder-as-a-cameras-default-media). |
|
||||
|
||||
### Media Types
|
||||
|
||||
|
||||
@@ -50,10 +50,10 @@ view:
|
||||
# [...]
|
||||
```
|
||||
|
||||
| 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. |
|
||||
| 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 ~5 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. This value controls how often a retry is attempted rather than whether a given problem is _ready_ to be retried: media that has failed is retried on this schedule, whereas media that is merely still loading is left alone for a grace period first, since restarting it would discard a load that is still in progress (see [media unavailable](../troubleshooting.md?id=media-unavailable)). |
|
||||
|
||||
## `keyboard_shortcuts`
|
||||
|
||||
|
||||
@@ -85,12 +85,20 @@ appears in the status bar. The card retries automatically with a back-off (and
|
||||
the notification offers a manual retry button). A live stream additionally
|
||||
reconnects on its own once its camera becomes available again.
|
||||
|
||||
A retry rebuilds the media from scratch, discarding the prior attempt. Media
|
||||
that is merely loading slowly has not failed -- it is still loading -- so
|
||||
restarting it would throw away the very attempt that may be about to succeed. A
|
||||
camera reported as **Media not loading** is therefore left running for at least
|
||||
30 seconds after that message appears before the card rebuilds it, unlike media
|
||||
that has _actually_ failed, which is rebuilt as soon as the schedule allows. The
|
||||
manual retry button rebuilds immediately regardless.
|
||||
|
||||
Reported reasons why media may be unavailable:
|
||||
|
||||
| Reason | Meaning |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Camera entity unavailable** | The camera's `camera_entity` reported `unavailable` in Home Assistant, e.g. the camera or its integration (such as Frigate) restarted, or the camera lost power or network. A short grace period is allowed before this is reported, and the card recovers automatically once the entity returns. Set [`always_error_if_entity_unavailable`](./configuration/cameras/README.md?id=cameras) to report it immediately instead. |
|
||||
| **Media not loading** | The media did not finish loading within the expected time: a slow or failed initial load. Applies to live streams, the viewer, and image views. |
|
||||
| **Media not loading** | The media did not finish loading within the expected time: a slow or failed initial load. Applies to live streams, the viewer, and image views. Nothing has necessarily failed yet, so the card keeps waiting on the existing load attempt already underway rather than restarting it immediately. |
|
||||
| **Playback error** | The live provider reported an error while trying to play the stream. |
|
||||
| **Stream stalled** | The stream loaded and was playing, but stopped delivering new frames with no error raised (a silent freeze). The card notices the lack of progress and reconnects. |
|
||||
|
||||
|
||||
@@ -203,16 +203,6 @@ export class IssueManager {
|
||||
this._retryTimer.reset();
|
||||
return;
|
||||
}
|
||||
if (!this._stateManager.canRetryNow()) {
|
||||
// An issue still has a failing problem but cannot retry right now. Cancel
|
||||
// the pending timer so it does not fire a retry the issue cannot take --
|
||||
// but do not reset(), which is reserved for the resolved case above.
|
||||
// Consequently no retry is scheduled here; scheduling resumes on a later
|
||||
// evaluate() (any condition-state change re-runs this), once the issue
|
||||
// reports it can retry again or has resolved.
|
||||
this._retryTimer.cancel();
|
||||
return;
|
||||
}
|
||||
if (this._retryTimer.isRunning()) {
|
||||
return;
|
||||
}
|
||||
@@ -238,15 +228,19 @@ export class IssueManager {
|
||||
: retryConfig,
|
||||
);
|
||||
|
||||
// Schedule without advancing: the backoff only escalates if the retry
|
||||
// actually runs (via the explicit advance() below), not when it's gated.
|
||||
// Scheduled whether or not a retry can run right now: a hold can end with
|
||||
// nothing else happening on the card (e.g. media given time to finish
|
||||
// loading), so the timer has to come back and look rather than waiting to
|
||||
// be woken. Scheduled without advancing: the backoff only escalates if the
|
||||
// retry actually runs (via the explicit advance() below), not when it's
|
||||
// held.
|
||||
this._retryTimer.schedule(
|
||||
() => {
|
||||
if (!this._stateManager.needsRetry()) {
|
||||
this._retryTimer.reset();
|
||||
return;
|
||||
}
|
||||
if (this._isScheduledRetryAllowed()) {
|
||||
if (this._isScheduledRetryAllowed() && this._stateManager.canRetryNow()) {
|
||||
this._stateManager.retry();
|
||||
|
||||
// This attempt counts: advance the backoff so the next schedule
|
||||
@@ -256,8 +250,11 @@ export class IssueManager {
|
||||
this._retryTimer.advance();
|
||||
this.evaluate();
|
||||
} else {
|
||||
// Retry was gated (e.g. user interaction). Not a failed attempt; the
|
||||
// backoff stays put and we re-arm at the same delay.
|
||||
// Retry was gated: the user is interacting, or every issue that needs
|
||||
// one is holding off (e.g. media still loading). Nothing was
|
||||
// attempted, so the backoff stays put and we re-arm at the same delay
|
||||
// rather than escalating towards the ten minute cap for retries that
|
||||
// never ran.
|
||||
this._scheduleRetryIfNeeded();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { add } from 'date-fns';
|
||||
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
|
||||
|
||||
import type {
|
||||
@@ -58,25 +59,43 @@ declare module 'issue' {
|
||||
interface TargetError {
|
||||
reason: MediaUnavailableIssueReason;
|
||||
description?: string;
|
||||
|
||||
// The earliest the card should rebuild this target's media. Carried forward
|
||||
// across repeat reports of the same failure, and refreshed when a rebuild
|
||||
// starts a new attempt.
|
||||
rebuildNotBefore: Date;
|
||||
}
|
||||
|
||||
// The per-cause presentation (localization key + icon), shared by the
|
||||
// notification metadata and the reconnecting placeholder so each cause is
|
||||
// The per-cause presentation (localization key + icon) and handling, shared by
|
||||
// the notification metadata and the reconnecting placeholder so each cause is
|
||||
// described in exactly one place. `resetOnLoad` means a media load will reset
|
||||
// this issue reason.
|
||||
// this issue reason. `rebuildGraceSeconds` is how long the media is left alone
|
||||
// before the card rebuilds it: media that has failed has nothing to protect and
|
||||
// is rebuilt at once, so only a load still in progress asks for any.
|
||||
export const MEDIA_UNAVAILABLE_REASONS: Record<
|
||||
MediaUnavailableIssueReason,
|
||||
{ localizationKey: string; icon: string; resetOnLoad: boolean }
|
||||
{
|
||||
localizationKey: string;
|
||||
icon: string;
|
||||
resetOnLoad: boolean;
|
||||
rebuildGraceSeconds: number;
|
||||
}
|
||||
> = {
|
||||
entity_unavailable: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.entity_unavailable',
|
||||
icon: 'mdi:cctv-off',
|
||||
resetOnLoad: false,
|
||||
rebuildGraceSeconds: 0,
|
||||
},
|
||||
not_loading: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.not_loading',
|
||||
icon: 'mdi:progress-helper',
|
||||
resetOnLoad: true,
|
||||
// A load that has not arrived yet has not necessarily failed: the provider is
|
||||
// mounted and still trying, so rebuilding it destroys an attempt that may be
|
||||
// about to succeed. Hold a rebuild off for at least this long, three load
|
||||
// windows, to give that attempt time to finish.
|
||||
rebuildGraceSeconds: 30,
|
||||
},
|
||||
playback_error: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.playback_error',
|
||||
@@ -84,16 +103,19 @@ export const MEDIA_UNAVAILABLE_REASONS: Record<
|
||||
|
||||
// A player can load media and still fail to play it.
|
||||
resetOnLoad: false,
|
||||
rebuildGraceSeconds: 0,
|
||||
},
|
||||
server_error: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.server_error',
|
||||
icon: 'mdi:server-network-off',
|
||||
resetOnLoad: true,
|
||||
rebuildGraceSeconds: 0,
|
||||
},
|
||||
stalled: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.stalled',
|
||||
icon: 'mdi:motion-pause',
|
||||
resetOnLoad: false,
|
||||
rebuildGraceSeconds: 0,
|
||||
},
|
||||
unsupported: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.unsupported',
|
||||
@@ -102,6 +124,7 @@ export const MEDIA_UNAVAILABLE_REASONS: Record<
|
||||
// Substitute pictures are never announced as loaded media, so a load means
|
||||
// the requested media was delivered in some supported way after all.
|
||||
resetOnLoad: true,
|
||||
rebuildGraceSeconds: 0,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -127,12 +150,30 @@ export class MediaUnavailableIssue implements Issue {
|
||||
// =========================================================================
|
||||
|
||||
public trigger(context: IssueTriggerContext['media_unavailable']): void {
|
||||
// A target already failing this same way keeps the deadline it was given,
|
||||
// so repeat reports of one failure cannot push a rebuild out indefinitely.
|
||||
const existing = this._erroredTargets.get(context.targetID);
|
||||
this._erroredTargets.set(context.targetID, {
|
||||
reason: context.reason,
|
||||
description: context.description,
|
||||
rebuildNotBefore:
|
||||
existing?.reason === context.reason
|
||||
? existing.rebuildNotBefore
|
||||
: this._getRebuildDeadline(context.reason),
|
||||
});
|
||||
}
|
||||
|
||||
private _getRebuildDeadline(reason: MediaUnavailableIssueReason): Date {
|
||||
return add(new Date(), {
|
||||
seconds: MEDIA_UNAVAILABLE_REASONS[reason].rebuildGraceSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
// Whether this target's media has waited long enough to be rebuilt.
|
||||
private _isRebuildDue(error: TargetError): boolean {
|
||||
return new Date() >= error.rebuildNotBefore;
|
||||
}
|
||||
|
||||
public resolve(context: IssueResolveContext['media_unavailable']): void {
|
||||
const error = this._erroredTargets.get(context.targetID);
|
||||
if (!error) {
|
||||
@@ -236,8 +277,21 @@ export class MediaUnavailableIssue implements Issue {
|
||||
return this.hasIssue();
|
||||
}
|
||||
|
||||
public retry(): boolean {
|
||||
const retryTargets = this._getDisplayedErrors();
|
||||
// False while everything on screen is still within its grace period, so the
|
||||
// manager treats the moment as one where nothing was attempted rather than as
|
||||
// a failed attempt that should lengthen the wait for the next one.
|
||||
public canRetryNow(): boolean {
|
||||
return [...this._getDisplayedErrors().values()].some((error) =>
|
||||
this._isRebuildDue(error),
|
||||
);
|
||||
}
|
||||
|
||||
public retry(force?: boolean): boolean {
|
||||
const retryTargets = new Map(
|
||||
[...this._getDisplayedErrors()].filter(
|
||||
([, error]) => force || this._isRebuildDue(error),
|
||||
),
|
||||
);
|
||||
if (!retryTargets.size) {
|
||||
return false;
|
||||
}
|
||||
@@ -250,6 +304,10 @@ export class MediaUnavailableIssue implements Issue {
|
||||
mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1;
|
||||
}
|
||||
|
||||
for (const error of retryTargets.values()) {
|
||||
error.rebuildNotBefore = this._getRebuildDeadline(error.reason);
|
||||
}
|
||||
|
||||
// Intentionally keep _erroredTargets in place. The issue stays visible
|
||||
// while the provider re-attempts loading underneath. If the retry succeeds,
|
||||
// the fresh load clears a not-loading error and the rebuilt provider's
|
||||
|
||||
@@ -141,7 +141,7 @@ export class IssueStateManager implements IssueReadOnlyState {
|
||||
if (!force && !this._canRetryNow(issue)) {
|
||||
continue;
|
||||
}
|
||||
if (issue.retry?.()) {
|
||||
if (issue.retry?.(force)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +92,10 @@ export interface Issue {
|
||||
// retries bypass it. Defaults to needsRetry() when not implemented.
|
||||
canRetryNow?(): 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;
|
||||
// Called by the manager when a retry is due. `force` marks a retry the user
|
||||
// asked for by hand. Returns true to stop the retry loop (exclusive), false
|
||||
// to allow subsequent issues to also retry.
|
||||
retry?(force?: boolean): boolean;
|
||||
|
||||
// Optional user-initiated fix. Not called by the issue infrastructure --
|
||||
// callers (e.g. notification control actions) invoke this directly.
|
||||
|
||||
@@ -142,8 +142,12 @@ export class KeyboardStateManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Taking focus must not scroll the dashboard to bring the card into view.
|
||||
element.focus({ preventScroll: true });
|
||||
// Taking focus must not scroll the dashboard to bring the card into view,
|
||||
// nor summon the browser's focus ring: script-initiated focus counts as
|
||||
// keyboard-like and would draw the ring around the entire card on a plain
|
||||
// pointer press. Tabbing to the card does not pass through here, so
|
||||
// keyboard users keep their ring.
|
||||
element.focus({ preventScroll: true, focusVisible: false });
|
||||
};
|
||||
|
||||
private _handleBlur = (ev: FocusEvent): void => {
|
||||
|
||||
@@ -432,6 +432,18 @@ export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] =>
|
||||
{ value: 'all', label: localize('config.cameras.media.revieweds.all') },
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'severities',
|
||||
label: localize('config.cameras.media.severities'),
|
||||
selector: createSelectSelector(
|
||||
[
|
||||
{ value: 'high', label: localize('common.severities.high') },
|
||||
{ value: 'medium', label: localize('common.severities.medium') },
|
||||
{ value: 'low', label: localize('common.severities.low') },
|
||||
],
|
||||
{ multiple: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'folders',
|
||||
selector: createSelectSelector(options.folders, { multiple: true }),
|
||||
|
||||
@@ -55,7 +55,7 @@ export class NotificationPopupController implements ReactiveController {
|
||||
this._elementFocusedBeforePopup instanceof HTMLElement &&
|
||||
document.activeElement === document.body
|
||||
) {
|
||||
this._elementFocusedBeforePopup.focus();
|
||||
this._elementFocusedBeforePopup.focus({ focusVisible: false });
|
||||
}
|
||||
this._elementFocusedBeforePopup = null;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import { hasPopOutAnimationEnded } from '../utils/animation.js';
|
||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js';
|
||||
|
||||
import './icon.js';
|
||||
|
||||
/**
|
||||
* The on-screen overlay shown during a two-way audio call: a centered pill
|
||||
* whose contents depend on call state. Pre-answer (inbound ringing) shows
|
||||
@@ -215,7 +217,7 @@ export class AdvancedCameraCardCallControls extends LitElement {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ha-icon icon=${icon}></ha-icon>
|
||||
<advanced-camera-card-icon .icon=${{ icon }}></advanced-camera-card-icon>
|
||||
</ha-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -537,7 +537,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
this._imageLoadError = true;
|
||||
}
|
||||
|
||||
this._dispatchError('not_loading');
|
||||
this._dispatchError('server_error');
|
||||
}}
|
||||
/>
|
||||
`
|
||||
|
||||
@@ -88,6 +88,12 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public viewFilterCameraID?: string;
|
||||
|
||||
// Whether this carousel automatically sets its own height (via
|
||||
// MediaHeightController) to fit the media it shows. Counter-example: A grid
|
||||
// cell is sized by its grid instead.
|
||||
@property({ attribute: false })
|
||||
public autoHeight = true;
|
||||
|
||||
private _refCarousel: Ref<HTMLElement> = createRef();
|
||||
|
||||
private _mediaActionsController = new MediaActionsController();
|
||||
@@ -103,7 +109,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this._mediaHeightController.setRoot(this.renderRoot);
|
||||
if (this.autoHeight) {
|
||||
this._mediaHeightController.setRoot(this.renderRoot);
|
||||
}
|
||||
|
||||
// Request update in order to reinitialize the media action controller.
|
||||
this.requestUpdate();
|
||||
|
||||
@@ -74,6 +74,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
|
||||
.stateWatcher=${this.stateWatcher}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
.viewFilterCameraID=${cameraID}
|
||||
.autoHeight=${!cameraID}
|
||||
.liveConfig=${this.liveConfig}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.cameraManager=${this.cameraManager}
|
||||
|
||||
@@ -86,6 +86,12 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public showControls = true;
|
||||
|
||||
// Whether this carousel automatically sets its own height (via
|
||||
// MediaHeightController) to fit the media it shows. Counter-example: A grid
|
||||
// cell is sized by its grid instead.
|
||||
@property({ attribute: false })
|
||||
public autoHeight = true;
|
||||
|
||||
@state()
|
||||
private _selected: number | null = null;
|
||||
|
||||
@@ -106,7 +112,9 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this._mediaHeightController.setRoot(this.renderRoot);
|
||||
if (this.autoHeight) {
|
||||
this._mediaHeightController.setRoot(this.renderRoot);
|
||||
}
|
||||
|
||||
// Request update in order to reinitialize the media action controller.
|
||||
this.requestUpdate();
|
||||
|
||||
@@ -64,6 +64,7 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||
.viewFilterCameraID=${filterCamera}
|
||||
.autoHeight=${!filterCamera}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
|
||||
@@ -7,10 +7,13 @@ import type { ConditionEvaluator, ConditionOfType, EvaluatorContext } from './ty
|
||||
// Home Assistant resolves an expected value that names an `input_*` helper to
|
||||
// that helper's current state (its Lovelace state-condition behavior), on both
|
||||
// the state and the attribute path; only these helper domains are resolved.
|
||||
// Regexp directly from:
|
||||
// Matches the same entity IDs as the regexp in Home Assistant:
|
||||
// https://github.com/home-assistant/core/blob/dev/homeassistant/helpers/condition.py
|
||||
// That regexp rejects a trailing `_` with a lookbehind, which Safari before
|
||||
// 16.4 fails to parse (taking the whole card down), so the leading and trailing
|
||||
// character are spelled out here instead.
|
||||
const INPUT_ENTITY_ID =
|
||||
/^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(?<!_)$/;
|
||||
/^input_(?:select|text|number|boolean|datetime)\.(?!.+__)[\da-z](?:[\da-z_]*[\da-z])?$/;
|
||||
|
||||
const isInputHelperName = (value: unknown): value is string =>
|
||||
typeof value === 'string' && INPUT_ENTITY_ID.test(value);
|
||||
|
||||
@@ -223,6 +223,10 @@ const cameraMediaConfigSchema = z.object({
|
||||
reviewed: z
|
||||
.enum(CAMERA_MEDIA_REVIEWED_FILTERS)
|
||||
.default(cameraMediaConfigDefault.reviewed),
|
||||
|
||||
// Restrict review media to these severities (e.g. `[high]` for Frigate
|
||||
// alerts only). Unset means all severities.
|
||||
severities: severitySchema.array().min(1).optional(),
|
||||
});
|
||||
|
||||
export const cameraConfigSchema = z
|
||||
|
||||
Vendored
+6
@@ -21,3 +21,9 @@ declare module 'action' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface ActionContext {}
|
||||
}
|
||||
|
||||
// The `focusVisible` option is part of the focus specification, but is absent
|
||||
// from the bundled TypeScript DOM types.
|
||||
interface FocusOptions {
|
||||
focusVisible?: boolean;
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"reviewed": "Only reviewed",
|
||||
"unreviewed": "Only unreviewed"
|
||||
},
|
||||
"severities": "Severity filter",
|
||||
"type": "Default type"
|
||||
},
|
||||
"motioneye": {
|
||||
@@ -909,7 +910,7 @@
|
||||
"entity_unavailable": "Camera entity unavailable",
|
||||
"not_loading": "Media not loading",
|
||||
"playback_error": "Playback error",
|
||||
"server_error": "Streaming server error",
|
||||
"server_error": "Media server error",
|
||||
"stalled": "Stream stalled",
|
||||
"unsupported": "Stream not supported"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
|
||||
// ====================================================================
|
||||
// ** Keep modifications to this file to a minimum **
|
||||
//
|
||||
// Type checking is disabled since this is a modified copy-and-paste of
|
||||
// underlying render() function, but the rest of the class source it not
|
||||
// available as compilation time.
|
||||
// This is a modified copy-and-paste of the underlying render() function.
|
||||
// The base class is only registered at runtime, so the members this file
|
||||
// uses from it are declared in ./types.ts.
|
||||
// ====================================================================
|
||||
|
||||
import {
|
||||
@@ -16,8 +13,9 @@ import {
|
||||
unsafeCSS,
|
||||
type CSSResultGroup,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { HA_CAMERA_STREAM_MUTE_CHANGE_EVENT } from '../components-lib/live/ha-stream-mute-controller.js';
|
||||
import {
|
||||
@@ -28,6 +26,7 @@ import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-
|
||||
|
||||
import '../components/image-player.js';
|
||||
|
||||
import type { HomeAssistant } from '../ha/types.js';
|
||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss?inline';
|
||||
import type {
|
||||
MediaLoadedInfo,
|
||||
@@ -40,6 +39,13 @@ import { onAbort } from '../utils/abort-signal.js';
|
||||
import './ha-hls-player.js';
|
||||
import './ha-web-rtc-player.js';
|
||||
|
||||
import type {
|
||||
AdvancedCameraCardHaCameraStreamElement,
|
||||
CameraEntity,
|
||||
ConstructableHaCameraStream,
|
||||
HaStream,
|
||||
} from './types.js';
|
||||
|
||||
// A failure reported by one of the inner players. Its existence is the failure;
|
||||
// `error` carries whatever the player knew about it. `dispatched` records
|
||||
// whether it has already been announced, so a stream that fails again after
|
||||
@@ -64,14 +70,18 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
const STREAM_TYPE_HLS = 'hls';
|
||||
const STREAM_TYPE_WEB_RTC = 'web_rtc';
|
||||
const STREAM_TYPE_MJPEG = 'mjpeg';
|
||||
type StreamType = STREAM_TYPE_HLS | STREAM_TYPE_WEB_RTC | STREAM_TYPE_MJPEG;
|
||||
type StreamType =
|
||||
| typeof STREAM_TYPE_HLS
|
||||
| typeof STREAM_TYPE_WEB_RTC
|
||||
| typeof STREAM_TYPE_MJPEG;
|
||||
|
||||
const HaCameraStream = customElements.get(
|
||||
'ha-camera-stream',
|
||||
) as ConstructableHaCameraStream;
|
||||
|
||||
class AdvancedCameraCardHaCameraStream extends HaCameraStream implements MediaPlayer {
|
||||
public declare hass?: HomeAssistant;
|
||||
|
||||
@customElement('advanced-camera-card-ha-camera-stream')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
class AdvancedCameraCardHaCameraStream
|
||||
extends customElements.get('ha-camera-stream')
|
||||
implements MediaPlayer
|
||||
{
|
||||
@property({ attribute: false })
|
||||
public targetID?: string;
|
||||
|
||||
@@ -80,7 +90,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
// independently -- we suppress those at this boundary (`stopPropagation` in
|
||||
// `_captureInnerLoad`), cache the latest per type, and republish the
|
||||
// visible one's info via our own source controller in `updated()`.
|
||||
private _mediaLoadedInfoPerStream: Record<StreamType, MediaLoadedInfo> = {};
|
||||
private _mediaLoadedInfoPerStream: Partial<Record<StreamType, MediaLoadedInfo>> = {};
|
||||
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(
|
||||
this,
|
||||
{
|
||||
@@ -185,7 +195,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
protected _renderStream(stream: Stream) {
|
||||
protected _renderStream(stream: HaStream): TemplateResult | typeof nothing {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
@@ -301,7 +311,7 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
// clears their errors), so previously-recorded failures no longer describe
|
||||
// what is playing and must not suppress a fresh one.
|
||||
private _discardErrorsOnEntityChange(changedProps: PropertyValues): void {
|
||||
const previousStateObj = changedProps.get('stateObj');
|
||||
const previousStateObj: CameraEntity | undefined = changedProps.get('stateObj');
|
||||
if (!previousStateObj || previousStateObj.entity_id === this.stateObj?.entity_id) {
|
||||
return;
|
||||
}
|
||||
@@ -339,10 +349,15 @@ void customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
'advanced-camera-card-ha-camera-stream',
|
||||
AdvancedCameraCardHaCameraStream,
|
||||
);
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-ha-camera-stream': AdvancedCameraCardHaCameraStream;
|
||||
'advanced-camera-card-ha-camera-stream': AdvancedCameraCardHaCameraStreamElement;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
|
||||
// ====================================================================
|
||||
// ** Keep modifications to this file to a minimum **
|
||||
//
|
||||
// Type checking is disabled since this is a modified copy-and-paste of
|
||||
// underlying render() function, but the rest of the class source is not
|
||||
// available as compilation time.
|
||||
// This is a modified copy-and-paste of the underlying render() function.
|
||||
// The base class is only registered at runtime, so the members this file
|
||||
// uses from it are declared in ./types.ts.
|
||||
// ====================================================================
|
||||
|
||||
import {
|
||||
@@ -17,7 +14,7 @@ import {
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { query } from 'lit/decorators/query.js';
|
||||
|
||||
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js';
|
||||
@@ -39,25 +36,26 @@ import {
|
||||
dispatchMediaPlayEvent,
|
||||
dispatchMediaVolumeChangeEvent,
|
||||
} from '../utils/media-info.js';
|
||||
import type { ConstructableLitElement } from './types.js';
|
||||
import type {
|
||||
AdvancedCameraCardHaHlsPlayerElement,
|
||||
ConstructableHaHlsPlayer,
|
||||
} from './types.js';
|
||||
|
||||
void customElements.whenDefined('ha-hls-player').then(() => {
|
||||
const HaHlsPlayer = customElements.get('ha-hls-player') as ConstructableLitElement;
|
||||
const HaHlsPlayer = customElements.get('ha-hls-player') as ConstructableHaHlsPlayer;
|
||||
|
||||
@customElement('advanced-camera-card-ha-hls-player')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
class AdvancedCameraCardHaHlsPlayer extends HaHlsPlayer implements MediaPlayer {
|
||||
// Due to an obscure behavior when this card is casted, this element needs
|
||||
// to use query rather than the ref directive to find the player.
|
||||
@query('#video')
|
||||
protected _video: HTMLVideoElement;
|
||||
protected _video?: HTMLVideoElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
public targetID?: string;
|
||||
|
||||
private _mediaPlayerController = new VideoMediaPlayerController(
|
||||
this,
|
||||
() => this._video,
|
||||
() => this._video ?? null,
|
||||
() => this.controls,
|
||||
);
|
||||
|
||||
@@ -99,14 +97,14 @@ void customElements.whenDefined('ha-hls-player').then(() => {
|
||||
?playsinline=${this.playsInline}
|
||||
?controls=${this.controls}
|
||||
@loadedmetadata=${() => {
|
||||
if (this.controls) {
|
||||
if (this.controls && this._video) {
|
||||
hideMediaControlsTemporarily(
|
||||
this._video,
|
||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||
);
|
||||
}
|
||||
}}
|
||||
@loadeddata=${(ev) => this._loadedDataHandler(ev)}
|
||||
@loadeddata=${(ev: Event) => this._loadedDataHandler(ev)}
|
||||
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
|
||||
@play=${() => dispatchMediaPlayEvent(this)}
|
||||
@pause=${() => dispatchMediaPauseEvent(this)}
|
||||
@@ -136,13 +134,19 @@ void customElements.whenDefined('ha-hls-player').then(() => {
|
||||
this._lastErrored = errored;
|
||||
}
|
||||
|
||||
private _loadedDataHandler(ev: Event) {
|
||||
private _loadedDataHandler(ev: Event): void {
|
||||
super._loadedData();
|
||||
|
||||
const video = this._video;
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
const info = createMediaLoadedInfo(ev, {
|
||||
mediaPlayerController: this._mediaPlayerController,
|
||||
capabilities: {
|
||||
supportsPause: true,
|
||||
hasAudio: mayHaveAudio(this._video),
|
||||
hasAudio: mayHaveAudio(video),
|
||||
},
|
||||
technology: ['hls'],
|
||||
});
|
||||
@@ -168,10 +172,15 @@ void customElements.whenDefined('ha-hls-player').then(() => {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
'advanced-camera-card-ha-hls-player',
|
||||
AdvancedCameraCardHaHlsPlayer,
|
||||
);
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-ha-hls-player': AdvancedCameraCardHaHlsPlayer;
|
||||
'advanced-camera-card-ha-hls-player': AdvancedCameraCardHaHlsPlayerElement;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
|
||||
// ====================================================================
|
||||
// ** Keep modifications to this file to a minimum **
|
||||
//
|
||||
// Type checking is disabled since this is a modified copy-and-paste of
|
||||
// underlying render() function, but the rest of the class source it not
|
||||
// available as compilation time.
|
||||
// This is a modified copy-and-paste of the underlying render() function.
|
||||
// The base class is only registered at runtime, so the members this file
|
||||
// uses from it are declared in ./types.ts.
|
||||
// ====================================================================
|
||||
|
||||
import {
|
||||
@@ -17,7 +14,7 @@ import {
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
|
||||
import { dispatchLiveErrorEvent } from '../components-lib/live/utils/dispatch-live-error.js';
|
||||
@@ -42,15 +39,16 @@ import {
|
||||
dispatchMediaPlayEvent,
|
||||
dispatchMediaVolumeChangeEvent,
|
||||
} from '../utils/media-info.js';
|
||||
import type { ConstructableLitElement } from './types.js';
|
||||
import type {
|
||||
AdvancedCameraCardHaWebRtcPlayerElement,
|
||||
ConstructableHaWebRtcPlayer,
|
||||
} from './types.js';
|
||||
|
||||
void customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
const HaWebRtcPlayer = customElements.get(
|
||||
'ha-web-rtc-player',
|
||||
) as ConstructableLitElement;
|
||||
) as ConstructableHaWebRtcPlayer;
|
||||
|
||||
@customElement('advanced-camera-card-ha-web-rtc-player')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
class AdvancedCameraCardHaWebRtcPlayer extends HaWebRtcPlayer implements MediaPlayer {
|
||||
@property({ attribute: false })
|
||||
public targetID?: string;
|
||||
@@ -76,7 +74,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
return this._mediaPlayerController;
|
||||
}
|
||||
|
||||
private async _startWebRtc(): Promise<void> {
|
||||
protected async _startWebRtc(): Promise<void> {
|
||||
// There is a race condition in the underlying HA frontend code between
|
||||
// the element connection and the async start of the WebRTC session. If
|
||||
// the element is rapidly connected and disconnected, the RTC connection
|
||||
@@ -94,7 +92,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
}
|
||||
}
|
||||
|
||||
private _addTrack = async (event: RTCTrackEvent) => {
|
||||
protected _addTrack = async (event: RTCTrackEvent) => {
|
||||
if (!this._remoteStream) {
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +141,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
);
|
||||
}
|
||||
}}
|
||||
@loadeddata=${(ev) => this._loadedDataHandler(ev)}
|
||||
@loadeddata=${(ev: Event) => this._loadedDataHandler(ev)}
|
||||
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
|
||||
@play=${() => dispatchMediaPlayEvent(this)}
|
||||
@pause=${() => dispatchMediaPauseEvent(this)}
|
||||
@@ -166,12 +164,12 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
// player can fail more than once and every failure must be reported.
|
||||
const errored = !!this._error;
|
||||
if (errored && !this._lastErrored) {
|
||||
dispatchLiveErrorEvent(this, { detail: this._error });
|
||||
dispatchLiveErrorEvent(this, { description: this._error });
|
||||
}
|
||||
this._lastErrored = errored;
|
||||
}
|
||||
|
||||
private _loadedDataHandler(ev: Event) {
|
||||
private _loadedDataHandler(ev: Event): void {
|
||||
super._loadedData();
|
||||
const info = createMediaLoadedInfo(ev, {
|
||||
mediaPlayerController: this._mediaPlayerController,
|
||||
@@ -189,7 +187,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
// capabilities reflect the current state.
|
||||
this._audioTracksMuteStateCleanup?.();
|
||||
this._audioTracksMuteStateCleanup = addAudioTracksMuteStateListener(
|
||||
this._peerConnection,
|
||||
this._peerConnection ?? null,
|
||||
() => {
|
||||
const info = createMediaLoadedInfo(this._videoEl, {
|
||||
mediaPlayerController: this._mediaPlayerController,
|
||||
@@ -206,7 +204,7 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
);
|
||||
}
|
||||
|
||||
private _cleanUp(): void {
|
||||
protected _cleanUp(): void {
|
||||
super._cleanUp();
|
||||
this._audioTracksMuteStateCleanup?.();
|
||||
this._audioTracksMuteStateCleanup = null;
|
||||
@@ -229,10 +227,15 @@ void customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
'advanced-camera-card-ha-web-rtc-player',
|
||||
AdvancedCameraCardHaWebRtcPlayer,
|
||||
);
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-ha-web-rtc-player': AdvancedCameraCardHaWebRtcPlayer;
|
||||
'advanced-camera-card-ha-web-rtc-player': AdvancedCameraCardHaWebRtcPlayerElement;
|
||||
}
|
||||
}
|
||||
|
||||
+124
-3
@@ -1,5 +1,126 @@
|
||||
import type { LitElement } from 'lit';
|
||||
import type { HassEntity } from 'home-assistant-js-websocket';
|
||||
import type { CSSResultGroup, LitElement, nothing, TemplateResult } from 'lit';
|
||||
|
||||
export interface ConstructableLitElement {
|
||||
new (...args: unknown[]): LitElement;
|
||||
import type { HomeAssistant } from '../ha/types.js';
|
||||
import type { MediaPlayer } from '../types.js';
|
||||
|
||||
// The Home Assistant elements the card subclasses are only registered at
|
||||
// runtime, so their source is unavailable at compilation time. The declarations
|
||||
// below name the members each patch uses, taken from the Home Assistant
|
||||
// frontend source linked at the top of each patch. They deliberately describe
|
||||
// only that subset.
|
||||
|
||||
// A camera entity, whose `access_token` attribute the MJPEG stream URL is built
|
||||
// from.
|
||||
export interface CameraEntity extends HassEntity {
|
||||
attributes: HassEntity['attributes'] & {
|
||||
access_token?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// The stream types Home Assistant can serve from a camera entity. MJPEG is not
|
||||
// among them: it is the fallback `ha-camera-stream` renders itself.
|
||||
type HaStreamType = 'hls' | 'web_rtc';
|
||||
|
||||
// What a player reports about the stream it loaded, on the `streams` event.
|
||||
interface HaStreamStatus {
|
||||
hasAudio: boolean;
|
||||
hasVideo: boolean;
|
||||
}
|
||||
|
||||
// One of the candidate streams `ha-camera-stream` renders. Only one is visible;
|
||||
// the rest are rendered hidden so they are ready to be promoted.
|
||||
export interface HaStream {
|
||||
type: HaStreamType | 'mjpeg';
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
|
||||
declare class HaHlsPlayerElement extends LitElement {
|
||||
static override styles: CSSResultGroup;
|
||||
|
||||
entityid?: string;
|
||||
url?: string;
|
||||
posterUrl?: string;
|
||||
controls: boolean;
|
||||
muted: boolean;
|
||||
autoPlay: boolean;
|
||||
playsInline: boolean;
|
||||
allowExoPlayer: boolean;
|
||||
|
||||
protected _error?: string;
|
||||
|
||||
// Whether `_error` stopped the stream, as opposed to one the player went on
|
||||
// to recover from by itself.
|
||||
protected _errorIsFatal: boolean;
|
||||
|
||||
protected _loadedData(): void;
|
||||
}
|
||||
|
||||
// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
|
||||
declare class HaWebRtcPlayerElement extends LitElement {
|
||||
static override styles: CSSResultGroup;
|
||||
|
||||
entityid?: string;
|
||||
posterUrl?: string;
|
||||
controls: boolean;
|
||||
muted: boolean;
|
||||
autoPlay: boolean;
|
||||
playsInline: boolean;
|
||||
|
||||
protected _error?: string;
|
||||
|
||||
protected _videoEl: HTMLVideoElement;
|
||||
protected _peerConnection?: RTCPeerConnection;
|
||||
protected _remoteStream?: MediaStream;
|
||||
|
||||
protected _startWebRtc(): Promise<void>;
|
||||
protected _addTrack: (event: RTCTrackEvent) => Promise<void>;
|
||||
protected _cleanUp(): void;
|
||||
protected _loadedData(): void;
|
||||
}
|
||||
|
||||
// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-camera-stream.ts
|
||||
declare class HaCameraStreamElement extends LitElement {
|
||||
static override styles: CSSResultGroup;
|
||||
|
||||
stateObj?: CameraEntity;
|
||||
controls: boolean;
|
||||
muted: boolean;
|
||||
allowExoPlayer: boolean;
|
||||
|
||||
protected _posterUrl?: string;
|
||||
protected _connected: boolean;
|
||||
protected _capabilities?: { frontend_stream_types: HaStreamType[] };
|
||||
protected _hlsStreams?: HaStreamStatus;
|
||||
protected _webRtcStreams?: HaStreamStatus;
|
||||
|
||||
protected _handleHlsStreams(ev: CustomEvent): void;
|
||||
protected _handleWebRtcStreams(ev: CustomEvent): void;
|
||||
|
||||
// Picks which of the camera's streams to render and which one is visible.
|
||||
protected _streams(
|
||||
supportedTypes?: HaStreamType[],
|
||||
hlsStreams?: HaStreamStatus,
|
||||
webRtcStreams?: HaStreamStatus,
|
||||
muted?: boolean,
|
||||
): HaStream[];
|
||||
|
||||
protected _renderStream(stream: HaStream): TemplateResult | typeof nothing;
|
||||
}
|
||||
|
||||
// `customElements.get()` cannot know which element a tag resolves to, so each
|
||||
// patch casts its base to the matching constructor.
|
||||
export type ConstructableHaHlsPlayer = typeof HaHlsPlayerElement;
|
||||
export type ConstructableHaWebRtcPlayer = typeof HaWebRtcPlayerElement;
|
||||
export type ConstructableHaCameraStream = typeof HaCameraStreamElement;
|
||||
|
||||
// The elements the card registers: the Home Assistant element plus the card's
|
||||
// own additions. `hass` is set by the card rather than declared by the Home
|
||||
// Assistant element, which takes its connection from a context instead.
|
||||
export type AdvancedCameraCardHaHlsPlayerElement = HaHlsPlayerElement &
|
||||
MediaPlayer & { hass?: HomeAssistant; targetID?: string };
|
||||
export type AdvancedCameraCardHaWebRtcPlayerElement = HaWebRtcPlayerElement &
|
||||
MediaPlayer & { hass?: HomeAssistant; targetID?: string };
|
||||
export type AdvancedCameraCardHaCameraStreamElement = HaCameraStreamElement &
|
||||
MediaPlayer & { hass?: HomeAssistant; targetID?: string; outputMute: boolean };
|
||||
|
||||
@@ -16,7 +16,10 @@ import { QuerySource, type QueryFilters } from '../query-source.js';
|
||||
import { VIEW_MEDIA_TYPES, type ViewMediaType } from '../types';
|
||||
import { arrayify } from '../utils/basic';
|
||||
import { UnifiedQuery, type QueryNode } from '../view/unified-query';
|
||||
import { getReviewedQueryFilterFromConfig } from './utils/query-filter';
|
||||
import {
|
||||
getReviewedQueryFilterFromConfig,
|
||||
getSeverityQueryFilterFromConfig,
|
||||
} from './utils/query-filter';
|
||||
|
||||
interface MediaQueryBuildOptions extends QueryFilters {
|
||||
start?: Date;
|
||||
@@ -356,7 +359,7 @@ export class UnifiedQueryBuilder {
|
||||
return this._buildQueryNodesCapabilityUnchecked(
|
||||
spec,
|
||||
cameraID,
|
||||
this._resolveReviewedFilter(spec, cameraID, options),
|
||||
this._resolveConfiguredFilters(spec, cameraID, options),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -404,7 +407,7 @@ export class UnifiedQueryBuilder {
|
||||
this._buildQueryNodesCapabilityUnchecked(
|
||||
cameraSpec,
|
||||
cameraID,
|
||||
this._resolveReviewedFilter(cameraSpec, cameraID, options),
|
||||
this._resolveConfiguredFilters(cameraSpec, cameraID, options),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -522,6 +525,32 @@ export class UnifiedQueryBuilder {
|
||||
};
|
||||
}
|
||||
|
||||
private _resolveConfiguredFilters(
|
||||
spec: MediaTypeSpec,
|
||||
cameraID: string,
|
||||
options?: QueryFiltersOptions,
|
||||
): QueryFiltersOptions | undefined {
|
||||
return this._resolveSeverityFilter(
|
||||
spec,
|
||||
cameraID,
|
||||
this._resolveReviewedFilter(spec, cameraID, options),
|
||||
);
|
||||
}
|
||||
|
||||
private _resolveSeverityFilter(
|
||||
spec: MediaTypeSpec,
|
||||
cameraID: string,
|
||||
options?: QueryFiltersOptions,
|
||||
): QueryFiltersOptions | undefined {
|
||||
if (spec.mediaType !== 'reviews' || options?.severity !== undefined) {
|
||||
return options;
|
||||
}
|
||||
const severity = getSeverityQueryFilterFromConfig(
|
||||
this._cameraManager.getStore().getCameraConfig(cameraID)?.media?.severities,
|
||||
);
|
||||
return severity ? { ...options, severity } : options;
|
||||
}
|
||||
|
||||
private _resolveReviewedFilter(
|
||||
spec: MediaTypeSpec,
|
||||
cameraID: string,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CameraMediaReviewedFilter } from '../../config/schema/cameras';
|
||||
import type { Severity } from '../../severity';
|
||||
import type { ViewItem } from '../item';
|
||||
import { ViewItemClassifier } from '../item-classifier';
|
||||
import type { UnifiedQuery } from '../unified-query';
|
||||
@@ -44,3 +45,14 @@ export function getReviewedQueryFilterFromConfig(
|
||||
): boolean | undefined {
|
||||
return reviewed === 'reviewed' ? true : reviewed === 'all' ? undefined : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a severities config value to a query filter.
|
||||
* @param severities The config value (an array of severities, or undefined)
|
||||
* @returns A set of severities to filter on, or undefined for all severities.
|
||||
*/
|
||||
export function getSeverityQueryFilterFromConfig(
|
||||
severities?: Severity[],
|
||||
): Set<Severity> | undefined {
|
||||
return severities?.length ? new Set(severities) : undefined;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ const TEST_MEDIA_PATH = '/test-media';
|
||||
// page runs one test file, so nothing here is shared with another file.
|
||||
const requestCounts = new Map<string, number>();
|
||||
|
||||
// Tests waiting for a token to reach a request count, resolved by the worker
|
||||
// below as it counts.
|
||||
const requestWaiters = new Map<string, { count: number; resolve: () => void }[]>();
|
||||
|
||||
// Whether this page's tests asked for the worker. Recorded so that a URL only
|
||||
// the worker can answer cannot be built without it.
|
||||
let inUse = false;
|
||||
@@ -75,15 +79,49 @@ export const createUnansweredMediaURL = (): string => createTestMediaURL([]);
|
||||
export const createStallingMediaURL = (filename?: string): string =>
|
||||
createTestMediaURL([HTTP_OK], false, filename);
|
||||
|
||||
const getToken = (url: string): string | null =>
|
||||
new URL(url, window.location.href).searchParams.get('token');
|
||||
|
||||
/**
|
||||
* How many requests a media URL has been asked for, so a test can count what
|
||||
* the card actually fetched rather than only what it displayed.
|
||||
*/
|
||||
export const getTestMediaRequestCount = (url: string): number => {
|
||||
const token = new URL(url, window.location.href).searchParams.get('token');
|
||||
const token = getToken(url);
|
||||
return (token ? requestCounts.get(token) : null) ?? 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait until a media URL has been asked for the given number of times.
|
||||
*
|
||||
* A request is put on the page by the browser and answered by a service worker
|
||||
* on a thread of its own, so how long it takes to be counted is real time that
|
||||
* running the card's fake clock forward does not cover.
|
||||
*/
|
||||
export const waitForTestMediaRequestCount = async (
|
||||
url: string,
|
||||
count: number,
|
||||
): Promise<void> => {
|
||||
const token = getToken(url);
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
'Requests are only counted for a media URL created by this file, ' +
|
||||
'e.g. createUnansweredMediaURL().',
|
||||
);
|
||||
}
|
||||
|
||||
if (getTestMediaRequestCount(url) >= count) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
requestWaiters.set(token, [
|
||||
...(requestWaiters.get(token) ?? []),
|
||||
{ count, resolve },
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves a fixture at `/test-media/<file>`, behaving as the query asks:
|
||||
*
|
||||
@@ -120,7 +158,17 @@ const worker = setupWorker(
|
||||
.map(Number);
|
||||
|
||||
const answered = requestCounts.get(token) ?? 0;
|
||||
requestCounts.set(token, answered + 1);
|
||||
const counted = answered + 1;
|
||||
requestCounts.set(token, counted);
|
||||
|
||||
const waiters = requestWaiters.get(token) ?? [];
|
||||
requestWaiters.set(
|
||||
token,
|
||||
waiters.filter((waiter) => waiter.count > counted),
|
||||
);
|
||||
waiters
|
||||
.filter((waiter) => waiter.count <= counted)
|
||||
.forEach((waiter) => waiter.resolve());
|
||||
|
||||
const isPastEnd = answered >= responses.length;
|
||||
if (isPastEnd && url.searchParams.get('repeat') !== 'true') {
|
||||
|
||||
@@ -841,8 +841,8 @@ describe('IssueManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('in-flight retry', () => {
|
||||
it('should hold the backoff and not arm a timer while a retry is in flight', () => {
|
||||
describe('when an issue cannot retry yet', () => {
|
||||
it('should not retry while one is in flight without exponentially backing off', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
const api = createCardAPI();
|
||||
const config = createConfig();
|
||||
@@ -872,8 +872,8 @@ describe('IssueManager', () => {
|
||||
expect(issue.retry).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The attempt is now in flight: the problem is still unresolved
|
||||
// (needsRetry) but cannot be retried right now (canRetryNow). The running
|
||||
// timer is canceled and no further attempt fires, however long we wait.
|
||||
// (needsRetry) but cannot be retried right now (canRetryNow). No further
|
||||
// attempt fires, however long we wait.
|
||||
canRetryNow.mockReturnValue(false);
|
||||
manager.evaluate();
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000);
|
||||
@@ -890,6 +890,39 @@ describe('IssueManager', () => {
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000);
|
||||
expect(issue.retry).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on its own once the issue becomes retryable', () => {
|
||||
const api = createCardAPI();
|
||||
const config = createConfig();
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue({
|
||||
...config,
|
||||
view: {
|
||||
...config.view,
|
||||
issues: { interaction_mode: 'all', retry_seconds: 'auto' },
|
||||
},
|
||||
});
|
||||
const manager = new IssueManager(api);
|
||||
|
||||
const canRetryNow = vi.fn().mockReturnValue(false);
|
||||
const issue = createIssue('media_unavailable', {
|
||||
hasIssue: vi.fn().mockReturnValue(true),
|
||||
needsRetry: vi.fn().mockReturnValue(true),
|
||||
canRetryNow,
|
||||
retry: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
manager.addIssue(issue);
|
||||
|
||||
manager.evaluate();
|
||||
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000);
|
||||
expect(issue.retry).not.toHaveBeenCalled();
|
||||
|
||||
canRetryNow.mockReturnValue(true);
|
||||
|
||||
// Deliberately no evaluate() here: nothing on the card has changed.
|
||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 1000);
|
||||
expect(issue.retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RETRY_EXPONENTIAL_BASE_SECONDS } from '../../../../src/card-controller/issues/issue-manager';
|
||||
import { MEDIA_UNAVAILABLE_REASONS } from '../../../../src/card-controller/issues/issues/media-unavailable';
|
||||
import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability';
|
||||
import { MEDIA_LOADING_TIMEOUT_SECONDS } from '../../../../src/components-lib/media-load-watchdog-controller';
|
||||
import { FRAME_STALL_SECONDS } from '../../../../src/components-lib/media-player/frame-stall-watchdog';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
createUnansweredMediaURL,
|
||||
getTestMediaRequestCount,
|
||||
useTestMedia,
|
||||
waitForTestMediaRequestCount,
|
||||
} from '../../../browser/test-media';
|
||||
import {
|
||||
CAMERA_ENTITY,
|
||||
@@ -437,6 +439,34 @@ describe('MediaUnavailableIssue', () => {
|
||||
expect(isIssueReported(card)).toBe(true);
|
||||
});
|
||||
|
||||
it('should wait out the grace period before rebuilding a slow camera', async () => {
|
||||
const retrySeconds = 5;
|
||||
const mediaURL = createUnansweredMediaURL();
|
||||
const card = await mountCard({
|
||||
view: { issues: { retry_seconds: retrySeconds } },
|
||||
cameras: [createStillImageCameraConfig(CAMERA_ENTITY, mediaURL)],
|
||||
});
|
||||
|
||||
await waitForTestMediaRequestCount(mediaURL, 1);
|
||||
|
||||
await card.advanceSeconds(MEDIA_LOADING_TIMEOUT_SECONDS);
|
||||
expect(isIssueReported(card)).toBe(true);
|
||||
|
||||
// Several retries fall due during the grace period, but rebuilding would
|
||||
// discard a load that may still complete.
|
||||
await card.advanceSeconds(
|
||||
MEDIA_UNAVAILABLE_REASONS.not_loading.rebuildGraceSeconds - 1,
|
||||
);
|
||||
expect(getTestMediaRequestCount(mediaURL)).toBe(1);
|
||||
|
||||
// Past the grace period the attempt has had long enough, and the next retry
|
||||
// replaces it.
|
||||
await card.advanceSeconds(retrySeconds + 1);
|
||||
await waitForTestMediaRequestCount(mediaURL, 2);
|
||||
|
||||
expect(getTestMediaRequestCount(mediaURL)).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('should keep retrying a camera that is still broken', async () => {
|
||||
// Must use the real clock: fake time moves the card's timers instantly, so
|
||||
// a request would never get a chance to answer between one retry and the
|
||||
@@ -470,12 +500,14 @@ describe('MediaUnavailableIssue', () => {
|
||||
expect(isLiveMediaShowing(card.card)).toBe(true);
|
||||
|
||||
// Exactly the two attempts that failed, so the retry ran once rather than
|
||||
// spinning until something happened to work.
|
||||
// spinning until something happened to work. A refused request is reported
|
||||
// as a server error rather than a slow load, which is what allows it to be
|
||||
// retried without waiting.
|
||||
expect(
|
||||
card.events
|
||||
.getEntries('advanced-camera-card:issue:trigger')
|
||||
.map((entry) => getIssueReason(entry.detail)),
|
||||
).toEqual(['not_loading', 'not_loading']);
|
||||
).toEqual(['server_error', 'server_error']);
|
||||
});
|
||||
|
||||
it('should re-attempt when the retry control is used', async () => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CardController } from '../../../../src/card-controller/controller';
|
||||
import { MediaUnavailableIssue } from '../../../../src/card-controller/issues/issues/media-unavailable';
|
||||
import {
|
||||
MEDIA_UNAVAILABLE_REASONS,
|
||||
MediaUnavailableIssue,
|
||||
} from '../../../../src/card-controller/issues/issues/media-unavailable';
|
||||
import type { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal';
|
||||
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../../src/view/target-id';
|
||||
import type { View } from '../../../../src/view/view';
|
||||
@@ -13,6 +16,9 @@ import {
|
||||
import { createCardAPI } from '../../../test-utils';
|
||||
import { createView } from '../../../view/test-utils';
|
||||
|
||||
// Read from the policy itself rather than restated here.
|
||||
const LOADING_GRACE_SECONDS = MEDIA_UNAVAILABLE_REASONS.not_loading.rebuildGraceSeconds;
|
||||
|
||||
const createAPIWithView = (view: View | null): CardController => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(view);
|
||||
@@ -339,7 +345,7 @@ describe('MediaUnavailableIssue', () => {
|
||||
['entity_unavailable' as const, 'Camera entity unavailable', 'mdi:cctv-off'],
|
||||
['not_loading' as const, 'Media not loading', 'mdi:progress-helper'],
|
||||
['playback_error' as const, 'Playback error', 'mdi:alert-circle'],
|
||||
['server_error' as const, 'Streaming server error', 'mdi:server-network-off'],
|
||||
['server_error' as const, 'Media server error', 'mdi:server-network-off'],
|
||||
['stalled' as const, 'Stream stalled', 'mdi:motion-pause'],
|
||||
['unsupported' as const, 'Stream not supported', 'mdi:video-off-outline'],
|
||||
])('should give the %s cause its own text and icon', (reason, text, icon) => {
|
||||
@@ -485,6 +491,154 @@ describe('MediaUnavailableIssue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('rebuilding a load that has not arrived', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not rebuild media that is still loading', () => {
|
||||
const api = createAPIDisplaying('camera-1');
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||
|
||||
vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 1000 - 1);
|
||||
|
||||
expect(issue.retry()).toBe(false);
|
||||
expect(api.getViewManager().setViewWithMergedContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should rebuild media that has still not arrived once the grace period has passed', () => {
|
||||
const api = createAPIDisplaying('camera-1');
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||
|
||||
vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 1000);
|
||||
|
||||
issue.retry();
|
||||
|
||||
expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({
|
||||
mediaEpoch: { 'camera-1': 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should rebuild media that has failed at once', () => {
|
||||
const api = createAPIDisplaying('camera-1');
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
|
||||
issue.retry();
|
||||
|
||||
expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({
|
||||
mediaEpoch: { 'camera-1': 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should rebuild media that is still loading when the user asks', () => {
|
||||
const api = createAPIDisplaying('camera-1');
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||
|
||||
issue.retry(true);
|
||||
|
||||
expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({
|
||||
mediaEpoch: { 'camera-1': 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should only rebuild media that is ready for a retry', () => {
|
||||
const api = createAPIDisplaying('camera-1', 'camera-2');
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||
issue.trigger({ targetID: 'camera-2', reason: 'stalled' });
|
||||
|
||||
issue.retry();
|
||||
|
||||
expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({
|
||||
mediaEpoch: { 'camera-2': 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not let repeated reports of one failure postpone the rebuild', () => {
|
||||
const api = createAPIDisplaying('camera-1');
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||
|
||||
vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 0.5 * 1000);
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||
vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 0.5 * 1000);
|
||||
|
||||
issue.retry();
|
||||
|
||||
expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({
|
||||
mediaEpoch: { 'camera-1': 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should rebuild at once when media that was still loading is reported as failed', () => {
|
||||
const api = createAPIDisplaying('camera-1');
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||
|
||||
issue.retry();
|
||||
|
||||
expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({
|
||||
mediaEpoch: { 'camera-1': 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow a retry while everything on screen is still loading', () => {
|
||||
const api = createAPIDisplaying('camera-1');
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||
|
||||
expect(issue.canRetryNow()).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 1000);
|
||||
|
||||
expect(issue.canRetryNow()).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow a retry when any media has failed', () => {
|
||||
const api = createAPIDisplaying('camera-1', 'camera-2');
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||
issue.trigger({ targetID: 'camera-2', reason: 'stalled' });
|
||||
|
||||
expect(issue.canRetryNow()).toBe(true);
|
||||
});
|
||||
|
||||
it('should give a rebuilt load a fresh grace period', () => {
|
||||
const api = createAPIDisplaying('camera-1');
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||
vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 1000);
|
||||
issue.retry();
|
||||
|
||||
vi.mocked(api.getViewManager().setViewWithMergedContext).mockClear();
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
|
||||
vi.advanceTimersByTime(LOADING_GRACE_SECONDS * 1000 - 1);
|
||||
issue.retry();
|
||||
|
||||
expect(api.getViewManager().setViewWithMergedContext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should forget every failure', () => {
|
||||
const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1'));
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getFocusedElement,
|
||||
holdKey,
|
||||
pressKey,
|
||||
pressTab,
|
||||
releaseKey,
|
||||
} from '../browser/dom';
|
||||
import {
|
||||
@@ -232,6 +233,31 @@ describe('KeyboardStateManager', () => {
|
||||
await card.console.waitForMessage(KEY_MESSAGE);
|
||||
});
|
||||
|
||||
it('should not draw a focus indicator when it takes focus', async () => {
|
||||
const card = await mountCard();
|
||||
|
||||
await clickMedia(card);
|
||||
|
||||
expect(getFocusedElement()).toBe(card.card);
|
||||
|
||||
// Focus taken by script counts as keyboard-driven, and the browser rings
|
||||
// the whole card for it: a bright border around a card the user only
|
||||
// pressed.
|
||||
expect(card.card.matches(':focus-visible')).toBe(false);
|
||||
});
|
||||
|
||||
it('should draw a focus indicator when it is reached with the keyboard', async () => {
|
||||
const card = await mountCard();
|
||||
|
||||
await pressTab();
|
||||
|
||||
expect(getFocusedElement()).toBe(card.card);
|
||||
|
||||
// The card is in the tab order, and a user who arrives on it that way needs
|
||||
// to be able to see where they are.
|
||||
expect(card.card.matches(':focus-visible')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not scroll the page when it takes focus', async () => {
|
||||
// Well below the window, so the card is out of sight until the page is
|
||||
// scrolled to it.
|
||||
|
||||
@@ -243,13 +243,13 @@ describe('KeyboardStateManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should take focus on pointerdown', () => {
|
||||
it('should take focus on pointerdown without a visible focus ring', () => {
|
||||
const { element } = createManager();
|
||||
const focus = vi.spyOn(element, 'focus');
|
||||
|
||||
element.dispatchEvent(new Event('pointerdown'));
|
||||
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true, focusVisible: false });
|
||||
});
|
||||
|
||||
it('should not take focus on pointerdown when focus is already within the card', () => {
|
||||
|
||||
@@ -57,6 +57,14 @@ const showNotification = async (card: MountedCard): Promise<HTMLElement> => {
|
||||
return await card.waitForSelector<HTMLElement>('.notification');
|
||||
};
|
||||
|
||||
const dismissNotification = async (card: MountedCard): Promise<void> => {
|
||||
await pressKey('Escape');
|
||||
await card.waitForRender(
|
||||
() => (deepQuery(card.card, '.notification') ? null : true),
|
||||
'the notification to be removed',
|
||||
);
|
||||
};
|
||||
|
||||
describe('NotificationPopupController', () => {
|
||||
it('should keep the notification open when its own text is pressed', async () => {
|
||||
const card = await mount();
|
||||
@@ -132,15 +140,29 @@ describe('NotificationPopupController', () => {
|
||||
await showNotification(card);
|
||||
expect(getFocusedElement()).not.toBe(elsewhere);
|
||||
|
||||
await pressKey('Escape');
|
||||
await card.waitForRender(
|
||||
() => (deepQuery(card.card, '.notification') ? null : true),
|
||||
'the notification to be removed',
|
||||
);
|
||||
await dismissNotification(card);
|
||||
|
||||
expect(getFocusedElement()).toBe(elsewhere);
|
||||
});
|
||||
|
||||
it('should return focus without a visible focus ring', async () => {
|
||||
const card = await mount();
|
||||
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE);
|
||||
|
||||
const elsewhere = document.createElement('button');
|
||||
document.body.appendChild(elsewhere);
|
||||
|
||||
// Focused as a pointer press leaves it: with no ring, which is the state
|
||||
// the return of focus must not change.
|
||||
elsewhere.focus({ focusVisible: false });
|
||||
|
||||
await showNotification(card);
|
||||
await dismissNotification(card);
|
||||
|
||||
expect(getFocusedElement()).toBe(elsewhere);
|
||||
expect(elsewhere.matches(':focus-visible')).toBe(false);
|
||||
});
|
||||
|
||||
it('should activate a notification control from the keyboard', async () => {
|
||||
const card = await mount({
|
||||
...NOTIFICATION,
|
||||
|
||||
@@ -133,6 +133,22 @@ describe('NotificationPopupController', () => {
|
||||
expect(document.activeElement).toBe(before);
|
||||
});
|
||||
|
||||
it('should return focus without a visible focus ring', () => {
|
||||
const before = document.createElement('button');
|
||||
document.body.appendChild(before);
|
||||
before.focus();
|
||||
const focus = vi.spyOn(before, 'focus');
|
||||
|
||||
const popup = createFocusablePopup();
|
||||
const { controller } = create(() => popup);
|
||||
controller.hostUpdated();
|
||||
popup.remove();
|
||||
|
||||
controller.hostDisconnected();
|
||||
|
||||
expect(focus).toHaveBeenCalledWith({ focusVisible: false });
|
||||
});
|
||||
|
||||
it('should leave focus alone when something else has taken it', () => {
|
||||
const before = document.createElement('button');
|
||||
document.body.appendChild(before);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import '../../src/components/call-controls';
|
||||
|
||||
import type { AdvancedCameraCardCallControls } from '../../src/components/call-controls';
|
||||
import { deepQueryAll } from '../browser/dom';
|
||||
import { defineHAElementStubs } from '../browser/ha-element-stubs';
|
||||
|
||||
const mount = async (options?: {
|
||||
answered?: boolean;
|
||||
}): Promise<AdvancedCameraCardCallControls> => {
|
||||
defineHAElementStubs();
|
||||
|
||||
const controls = document.createElement('advanced-camera-card-call-controls');
|
||||
controls.active = true;
|
||||
controls.answered = options?.answered ?? true;
|
||||
document.body.append(controls);
|
||||
|
||||
await controls.updateComplete;
|
||||
return controls;
|
||||
};
|
||||
|
||||
const getButtonIcons = (controls: AdvancedCameraCardCallControls): (string | null)[] =>
|
||||
deepQueryAll(controls, 'ha-icon-button').map((button) => {
|
||||
// Verify the icons are not ha-icon.
|
||||
expect(button.querySelector('ha-icon')).toBeNull();
|
||||
|
||||
const icon = button.querySelector('advanced-camera-card-icon');
|
||||
return icon?.icon?.icon ?? null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('advanced-camera-card-call-controls').forEach((controls) => {
|
||||
controls.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdvancedCameraCardCallControls', () => {
|
||||
it('should render the answered buttons with the card icon component', async () => {
|
||||
const controls = await mount();
|
||||
|
||||
expect(getButtonIcons(controls)).toEqual([
|
||||
'mdi:phone-hangup',
|
||||
'mdi:microphone-off',
|
||||
'mdi:volume-off',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render the unanswered buttons with the card icon component', async () => {
|
||||
const controls = await mount({ answered: false });
|
||||
|
||||
expect(getButtonIcons(controls)).toEqual(['mdi:phone-hangup', 'mdi:phone']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
|
||||
import { deepQuery } from '../../browser/dom';
|
||||
import { MountedCardFactory } from '../../browser/mounted-card';
|
||||
import {
|
||||
createGenericCameraHASS,
|
||||
createStillImageCardConfig,
|
||||
} from '../../browser/test-utils';
|
||||
|
||||
describe('AdvancedCameraCardLiveCarousel', () => {
|
||||
it('should cap its own height to fit the media it shows', async () => {
|
||||
const card = await MountedCardFactory.createFromSource(
|
||||
createStillImageCardConfig(),
|
||||
createGenericCameraHASS(),
|
||||
);
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
const carousel = deepQuery<HTMLElement>(
|
||||
card.card,
|
||||
'advanced-camera-card-live-carousel',
|
||||
);
|
||||
assert(carousel);
|
||||
|
||||
await card.waitForRender(
|
||||
() => carousel.style.maxHeight || null,
|
||||
'the carousel capping its own height',
|
||||
);
|
||||
|
||||
// Outside a grid the carousel fills the card, so the cap is what gives the
|
||||
// card the height of its media rather than of whatever contains it.
|
||||
expect(parseFloat(carousel.style.maxHeight)).toBe(
|
||||
carousel.getBoundingClientRect().height,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PartialAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||
import { clickElement, deepQueryAll } from '../../browser/dom';
|
||||
import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card';
|
||||
import {
|
||||
CAMERA_ENTITY,
|
||||
createGenericCameraHASS,
|
||||
createStillImageCameraConfig,
|
||||
createStillImageCardConfig,
|
||||
} from '../../browser/test-utils';
|
||||
|
||||
// Enough cameras to fill a 2-column grid beyond a single row.
|
||||
const CAMERA_ENTITIES = [CAMERA_ENTITY, 'camera.two', 'camera.three'];
|
||||
|
||||
// Every camera declares the shape of its media, so a cell's height follows
|
||||
// from its width alone and never depends on when media happens to load.
|
||||
const CELL_RATIO_PARTS = [16, 9];
|
||||
const CELL_RATIO = CELL_RATIO_PARTS[0] / CELL_RATIO_PARTS[1];
|
||||
|
||||
// How many frames a wait may take before the grid is called stuck. Frames
|
||||
// rather than time, so a slow machine gets proportionally more patience.
|
||||
const STUCK_FRAME_BUDGET = 600;
|
||||
|
||||
// How many consecutive frames of unchanged geometry mean the grid has stopped
|
||||
// moving. Any change restarts the count, so work the cells defer is waited out
|
||||
// however late it lands.
|
||||
const QUIET_FRAME_COUNT = 20;
|
||||
|
||||
// How many frames to watch a selection transition for intermediate states. The
|
||||
// regression this guards against parked the selected cell at the wrong height
|
||||
// for several hundred milliseconds, so it spans comfortably more than that.
|
||||
const TRANSITION_FRAME_SAMPLES = 40;
|
||||
|
||||
const GRID_CONFIG: PartialAdvancedCameraCardConfig = {
|
||||
live: {
|
||||
display: {
|
||||
mode: 'grid',
|
||||
grid_columns: 2,
|
||||
grid_selected_position: 'first',
|
||||
grid_selected_width_factor: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mountGrid = async (): Promise<MountedCard> =>
|
||||
await MountedCardFactory.createFromSource(
|
||||
createStillImageCardConfig({
|
||||
cameras: CAMERA_ENTITIES.map((cameraEntity) => ({
|
||||
...createStillImageCameraConfig(cameraEntity),
|
||||
dimensions: { aspect_ratio: CELL_RATIO_PARTS },
|
||||
})),
|
||||
...GRID_CONFIG,
|
||||
}),
|
||||
createGenericCameraHASS({ cameras: CAMERA_ENTITIES.slice(1) }),
|
||||
{
|
||||
width: '600px',
|
||||
|
||||
// A cell resize can resize the grid host and vice versa, and a browser
|
||||
// reports each round it has to defer as an uncaught error. How many
|
||||
// rounds that takes follows the browser's frame scheduling, not the card,
|
||||
// so it cannot be counted, only tolerated.
|
||||
toleratedConsoleErrors: [/ResizeObserver loop completed/],
|
||||
},
|
||||
);
|
||||
|
||||
const nextFrame = async (): Promise<void> =>
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
|
||||
const getCells = (card: MountedCard): HTMLElement[] =>
|
||||
deepQueryAll<HTMLElement>(card.card, 'advanced-camera-card-live-carousel');
|
||||
|
||||
const getCell = (card: MountedCard, cameraEntity: string): HTMLElement | null =>
|
||||
getCells(card).find((cell) => cell.getAttribute('grid-id') === cameraEntity) ?? null;
|
||||
|
||||
// The height a cell at the configured ratio should have at a given width. The
|
||||
// ratio applies to the content box; the grid draws its border around it.
|
||||
const getExpectedCellHeight = (cell: HTMLElement, width: number): number => {
|
||||
const styles = getComputedStyle(cell);
|
||||
const vertical =
|
||||
parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
|
||||
const horizontal =
|
||||
parseFloat(styles.borderLeftWidth) + parseFloat(styles.borderRightWidth);
|
||||
return (width - horizontal) / CELL_RATIO + vertical;
|
||||
};
|
||||
|
||||
const isCellAtRatio = (cell: HTMLElement): boolean => {
|
||||
const rect = cell.getBoundingClientRect();
|
||||
return (
|
||||
rect.width > 0 && Math.abs(rect.height - getExpectedCellHeight(cell, rect.width)) < 2
|
||||
);
|
||||
};
|
||||
|
||||
const getGeometry = (card: MountedCard): string =>
|
||||
getCells(card)
|
||||
.map((cell) => {
|
||||
const rect = cell.getBoundingClientRect();
|
||||
return `${cell.getAttribute('grid-id')}:${rect.width}x${rect.height}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
// Wait for the cells to stop changing size. Geometry can change with no DOM
|
||||
// mutation, so this polls frames rather than waiting on the card to render.
|
||||
const waitForQuietGrid = async (card: MountedCard): Promise<HTMLElement[]> => {
|
||||
let previous: string | null = null;
|
||||
let quietFrames = 0;
|
||||
|
||||
for (let frame = 0; frame < STUCK_FRAME_BUDGET; frame++) {
|
||||
const geometry = getGeometry(card);
|
||||
quietFrames = geometry === previous ? quietFrames + 1 : 0;
|
||||
previous = geometry;
|
||||
|
||||
if (quietFrames >= QUIET_FRAME_COUNT) {
|
||||
return getCells(card);
|
||||
}
|
||||
await nextFrame();
|
||||
}
|
||||
throw new Error('The grid cells never stopped changing size');
|
||||
};
|
||||
|
||||
describe('AdvancedCameraCardLiveGrid', () => {
|
||||
it('should apply the height of a newly selected cell in the same pass as its width', async () => {
|
||||
const card = await mountGrid();
|
||||
const cells = await waitForQuietGrid(card);
|
||||
expect(cells).toHaveLength(CAMERA_ENTITIES.length);
|
||||
expect(cells.every(isCellAtRatio)).toBe(true);
|
||||
|
||||
const selected = getCell(card, CAMERA_ENTITY);
|
||||
const target = getCell(card, 'camera.two');
|
||||
assert(selected && target);
|
||||
|
||||
// Thresholds derived from the two laid out sizes rather than constants, so
|
||||
// a changed gutter or border width does not invalidate the test.
|
||||
const selectedWidth = selected.getBoundingClientRect().width;
|
||||
const unselectedRect = target.getBoundingClientRect();
|
||||
|
||||
// Every cell spans the full width until the grid controller applies column
|
||||
// sizes, so a quiet grid is not necessarily a laid out one.
|
||||
expect(unselectedRect.width).toBeLessThan(selectedWidth);
|
||||
const wideThreshold = (selectedWidth + unselectedRect.width) / 2;
|
||||
const shortThreshold =
|
||||
(getExpectedCellHeight(target, selectedWidth) + unselectedRect.height) / 2;
|
||||
|
||||
await clickElement(target);
|
||||
await card.waitForRender(
|
||||
() => target.hasAttribute('selected') || null,
|
||||
'the clicked cell being selected',
|
||||
);
|
||||
|
||||
// A cell that is already at its selected width but still at an unselected
|
||||
// height is the intermediate state the user sees as a two-step layout.
|
||||
const wideButShort: { width: number; height: number }[] = [];
|
||||
for (let frame = 0; frame < TRANSITION_FRAME_SAMPLES; frame++) {
|
||||
await nextFrame();
|
||||
const rect = target.getBoundingClientRect();
|
||||
if (rect.width > wideThreshold && rect.height < shortThreshold) {
|
||||
wideButShort.push({ width: rect.width, height: rect.height });
|
||||
}
|
||||
}
|
||||
expect(wideButShort).toEqual([]);
|
||||
|
||||
// The transition did complete: the clicked cell holds the selected size.
|
||||
const finalRect = target.getBoundingClientRect();
|
||||
expect(Math.abs(finalRect.width - selectedWidth)).toBeLessThan(2);
|
||||
expect(
|
||||
Math.abs(finalRect.height - getExpectedCellHeight(target, finalRect.width)),
|
||||
).toBeLessThan(2);
|
||||
|
||||
// Outside a grid a carousel caps its own height with an inline
|
||||
// `max-height`. A grid cell must not carry that cap.
|
||||
expect(getCells(card).map((cell) => cell.style.maxHeight)).toEqual(
|
||||
CAMERA_ENTITIES.map(() => ''),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -226,4 +226,25 @@ describe('AdvancedCameraCardViewerCarousel', () => {
|
||||
await card.waitForSelector('advanced-camera-card-gallery');
|
||||
expect(deepQuery(card.card, 'advanced-camera-card-viewer-carousel')).toBeNull();
|
||||
});
|
||||
|
||||
it('should cap its own height to fit the media it shows', async () => {
|
||||
const card = await mountViewer();
|
||||
|
||||
const carousel = deepQuery<HTMLElement>(
|
||||
card.card,
|
||||
'advanced-camera-card-viewer-carousel',
|
||||
);
|
||||
assert(carousel);
|
||||
|
||||
await card.waitForRender(
|
||||
() => carousel.style.maxHeight || null,
|
||||
'the carousel capping its own height',
|
||||
);
|
||||
|
||||
// Outside a grid the carousel fills the card, so the cap is what gives the
|
||||
// card the height of its media rather than of whatever contains it.
|
||||
expect(parseFloat(carousel.style.maxHeight)).toBe(
|
||||
carousel.getBoundingClientRect().height,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -734,6 +734,34 @@ describe('state condition', () => {
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['input_text.expected', true],
|
||||
['input_text.a_1', true],
|
||||
['input_text.', false],
|
||||
['input_text._expected', false],
|
||||
['input_text.__expected', false],
|
||||
['input_text.expected_', false],
|
||||
['input_text.expected__name', false],
|
||||
])('should resolve only a valid input helper ID: %s', (entityID, shouldResolve) => {
|
||||
const evaluator = createConditionEvaluator(
|
||||
{
|
||||
condition: 'state' as const,
|
||||
entity_id: 'binary_sensor.foo',
|
||||
state: entityID,
|
||||
},
|
||||
createEvaluatorContext(),
|
||||
);
|
||||
|
||||
expect(
|
||||
evaluator.evaluate({
|
||||
hass: createHASS({
|
||||
'binary_sensor.foo': createStateEntity({ state: 'armed' }),
|
||||
[entityID]: createStateEntity({ state: 'armed' }),
|
||||
}),
|
||||
}).result,
|
||||
).toBe(shouldResolve);
|
||||
});
|
||||
|
||||
it('should not resolve a non-input entity name', () => {
|
||||
// Only `input_*` helpers are resolved; other entity names compare literally.
|
||||
const evaluator = createConditionEvaluator(
|
||||
|
||||
@@ -700,6 +700,91 @@ describe('UnifiedQueryBuilder', () => {
|
||||
expect(query.getNodes()[0]).not.toHaveProperty('reviewed');
|
||||
});
|
||||
|
||||
it('should apply severities filter from config for reviews query', () => {
|
||||
const { cameraManager, foldersManager, store } = createMocks();
|
||||
store.getCameraIDs.mockReturnValue(new Set(['camera.office']));
|
||||
store.getCameraConfig.mockReturnValue(
|
||||
createCameraConfig({ media: { type: 'auto', severities: ['high'] } }),
|
||||
);
|
||||
|
||||
cameraManager.getCameraCapabilities.mockReturnValue(
|
||||
createCapabilities({ reviews: true }),
|
||||
);
|
||||
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const query = builder.buildDefaultCameraQuery();
|
||||
|
||||
assert(query);
|
||||
expect(query.getNodes()[0]).toMatchObject({
|
||||
type: QueryType.Review,
|
||||
severity: new Set(['high']),
|
||||
});
|
||||
});
|
||||
|
||||
it('should not apply severities filter when unset in config', () => {
|
||||
const { cameraManager, foldersManager, store } = createMocks();
|
||||
store.getCameraIDs.mockReturnValue(new Set(['camera.office']));
|
||||
store.getCameraConfig.mockReturnValue(
|
||||
createCameraConfig({ media: { type: 'auto' } }),
|
||||
);
|
||||
|
||||
cameraManager.getCameraCapabilities.mockReturnValue(
|
||||
createCapabilities({ reviews: true }),
|
||||
);
|
||||
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const query = builder.buildDefaultCameraQuery();
|
||||
|
||||
assert(query);
|
||||
assert(isReviewQuery(query.getNodes()[0]));
|
||||
expect(query.getNodes()[0]).not.toHaveProperty('severity');
|
||||
});
|
||||
|
||||
it('should not apply severities filter to non-review queries', () => {
|
||||
const { cameraManager, foldersManager, store } = createMocks();
|
||||
store.getCameraIDs.mockReturnValue(new Set(['camera.office']));
|
||||
store.getCameraConfig.mockReturnValue(
|
||||
createCameraConfig({ media: { type: 'auto', severities: ['high'] } }),
|
||||
);
|
||||
|
||||
cameraManager.getCameraCapabilities.mockReturnValue(
|
||||
createCapabilities({ clips: true }),
|
||||
);
|
||||
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const query = builder.buildDefaultCameraQuery();
|
||||
|
||||
assert(query);
|
||||
expect(query.getNodes()[0]).toMatchObject({
|
||||
type: QueryType.Event,
|
||||
hasClip: true,
|
||||
});
|
||||
expect(query.getNodes()[0]).not.toHaveProperty('severity');
|
||||
});
|
||||
|
||||
it('should prefer an explicit severity option over the config', () => {
|
||||
const { cameraManager, foldersManager, store } = createMocks();
|
||||
store.getCameraIDs.mockReturnValue(new Set(['camera.office']));
|
||||
store.getCameraConfig.mockReturnValue(
|
||||
createCameraConfig({ media: { type: 'auto', severities: ['high'] } }),
|
||||
);
|
||||
|
||||
cameraManager.getCameraCapabilities.mockReturnValue(
|
||||
createCapabilities({ reviews: true }),
|
||||
);
|
||||
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const query = builder.buildDefaultCameraQuery(undefined, {
|
||||
severity: new Set(['medium'] as const),
|
||||
});
|
||||
|
||||
assert(query);
|
||||
expect(query.getNodes()[0]).toMatchObject({
|
||||
type: QueryType.Review,
|
||||
severity: new Set(['medium']),
|
||||
});
|
||||
});
|
||||
|
||||
it('should build clips query for camera with clips capability', () => {
|
||||
const { cameraManager, foldersManager, store } = createMocks();
|
||||
store.getCameraIDs.mockReturnValue(new Set(['camera.office']));
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { UnifiedQuery } from '../../../src/view/unified-query';
|
||||
import {
|
||||
getReviewedQueryFilterFromConfig,
|
||||
getReviewedQueryFilterFromQuery,
|
||||
getSeverityQueryFilterFromConfig,
|
||||
} from '../../../src/view/utils/query-filter';
|
||||
import { createFolder } from '../../test-utils';
|
||||
import { createEventQuery, TestViewMedia } from '../test-utils';
|
||||
@@ -81,4 +82,21 @@ describe('query-filter', () => {
|
||||
expect(getReviewedQueryFilterFromConfig('unreviewed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSeverityQueryFilterFromConfig', () => {
|
||||
it('should return undefined when unset', () => {
|
||||
expect(getSeverityQueryFilterFromConfig(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when empty', () => {
|
||||
expect(getSeverityQueryFilterFromConfig([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return a set of the configured severities', () => {
|
||||
expect(getSeverityQueryFilterFromConfig(['high'])).toEqual(new Set(['high']));
|
||||
expect(getSeverityQueryFilterFromConfig(['high', 'medium'])).toEqual(
|
||||
new Set(['high', 'medium']),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user