Compare commits

..
10 Commits
Author SHA1 Message Date
flan dc427c1fcb docs: Use a plain semver line for fork releases
CI / Check / HACS (push) Failing after 1m37s
CI / Test / Unit (push) Successful in 2m37s
CI / Test / Dist / Firefox (push) Successful in 1m44s
CI / Test / Dist / WebKit (push) Successful in 1m10s
Manage labels / Labeler (push) Successful in 19s
CI / Test / Browser / Firefox (push) Successful in 5m25s
CI / Check / Code (push) Successful in 2m46s
CI / Test / Dist / Chromium (push) Successful in 1m7s
CI / Test / Browser / Chromium (push) Successful in 4m39s
CI / Build (push) Failing after 42s
CI / Test / Browser / WebKit (push) Successful in 3m36s
Semantic Release / get-next-version (push) Successful in 51s
Semantic Release / release (push) Skipped
2026-09-04 18:03:37 +00:00
flan b57fd0675a docs: Add fork notes, changelog and notice 2026-09-04 17:59:21 +00:00
flan 88e1fba401 feat: Add cameras[].media.severities to filter review media by severity
Review queries were issued without a severity filter, so the thumbnail
bar, gallery and timeline showed every Frigate review item -- alerts and
detections alike -- regardless of what a camera is configured to alert
on. Severity could only be narrowed interactively, in the media filter
drawer, and that choice did not persist.

`media.severities` resolves into the default query the same way
`media.reviewed` does, and is overridden by an explicit severity filter
so the media filter drawer keeps working. Unset means all severities, so
existing behaviour is unchanged.

    cameras:
      - camera_entity: camera.office
        media:
          severities:
            - high
2026-09-04 17:56:20 +00:00
Shane Sewellanddermotduffy 54e261fd54 fix: Restore iPadOS 15 compatibility by removing unsupported regex (#2756)
## Summary

Removes an unsupported negative regex lookbehind from input-helper
entity ID validation.

Advanced Camera Card v8 fails to load on Safari/iPadOS 15 because the
browser cannot parse `(?<!_)`. This prevents the `advanced-camera-card`
custom element from registering.

The replacement preserves the existing validation behavior without
lookbehind:

- Accepts valid `input_*` helper entity IDs.
- Rejects object IDs beginning or ending with `_`.
- Rejects object IDs containing `__`.
- Restores compatibility with Safari versions before 16.4.

This closes #2753 

## Testing

- Added regression coverage for valid and invalid input-helper entity
IDs.
- TypeScript typecheck passes.
- ESLint passes for the changed files.
- Build succeeds.
- Confirmed the generated distribution contains no negative lookbehind
syntax.
- Direct validation of the replacement regex passes.

## Reproduction

- Device: iPad mini 4
- OS: iPadOS 15.8.8
- Last working release: v7.27.4
- Affected releases: v8.0.0 and v8.0.1
- Browser error:

```text
SyntaxError: Invalid regular expression: invalid group specifier name
Safari and Safari on iOS did not support regex lookbehind until version 16.4.

---------

Co-authored-by: dermotduffy <dermot.duffy@gmail.com>
2026-09-01 16:53:34 -07:00
Dermot Duffy 95b85bf608 fix: Send the WebRTC player's error text to the media_unavailable notification (#2741)
- Closes: #2723
2026-08-30 19:08:03 -07:00
Matthijsanddermotduffy 299973a1fa fix: size a newly selected grid cell in a single layout pass (#2730)
Selecting a camera in `live.display.mode: grid` lands in two visible
passes: the cell gets its 2-column width immediately but keeps its 1-row
height for ~400 ms, and the rest of the grid only settles ~700 ms after
the click (measured per-frame on `main`; v7.27.4 has the same two
passes, v8's early position resolution just leaves the late height
correction standing out as a lone vertical jump).

**Cause:** a grid cell's height follows its content in the same style
recalculation as its width (intrinsic media ratio,
`dimensions.aspect_ratio`, or the unsized 16:9 reservation), but the
slotted carousel carries an inline `max-height` from
`MediaHeightController` (debounced 0.3 s + 0.1 s transition; it sizes
the card outside grids). In a grid that cap can only ever delay growth:
it pins the freshly widened cell at its old height until the debounce
fires, and the correction then also rides the 300 ms `_throttledLayout`.
`selectCell()`'s existing `forceReflow()` + `layout()` was already
positioned to do this in one pass; the cap starves it of the final
height.

**Fix:** neutralize the cap on grid cells (`max-height: none
!important`; important is needed to beat the inline style). Covers the
live and viewer grids; non-grid behaviour is untouched. This also stops
the cap clipping cells by their border width (it was measured on the
slide's content box but applied to the cell's border box).

**Trade-off worth flagging:** in the *viewer* grid a cell's carousel
holds all of one camera's media. If those slides do not share one ratio,
the cell now sizes to its tallest slide (letterboxing shorter ones, no
re-layout per swipe) instead of tracking the selected one after a
debounce. If you would rather leave the viewer untouched, the rule can
be scoped to `::slotted(advanced-camera-card-live-carousel)` instead
(live grid cells always hold exactly one slide); say so and I will
rework the PR that way.

**Verification:**

- New browser test `tests/components/live/grid.browser.test.ts`: fails
on any frame where the newly selected cell is selected-wide but still
unselected-high. On `main` it fails with 22 such frames; with this
change there are none: the click settles in a single frame (~28 ms),
every cell at its final size and position.
- `yarn run test`, `yarn run test:browser` (chromium, firefox and webkit
for the new test), `yarn run lint`, `yarn run typecheck` pass.
- No resize oscillation in a cramped viewport with an
appearing/disappearing ancestor scrollbar (the #2306 scenario), on
window resizes, or under a narrow-screen `grid_columns: 2` override;
verified against a live HA 2026.8.3 dashboard (6 go2rtc cameras).

---------

Co-authored-by: dermotduffy <dermot.duffy@gmail.com>
2026-08-30 17:33:48 -07:00
Dermot Duffy fe7ac89982 test: Avoid test flakiness by waiting for request count (#2740) 2026-08-30 16:39:39 -07:00
Dermot Duffy bb6794ac49 fix: Give media that is still loading a grace period before rebuilding (#2739)
- Related: #2718
2026-08-30 15:40:24 -07:00
Dermot Duffy 04c2da6379 fix: Do not use bare ha-icon for call controls (#2738)
- Closes: #2735
2026-08-30 14:31:33 -07:00
Matthijsanddermotduffy e6bb0eb0e0 fix: claim pointer focus without a visible focus ring (#2733)
Since f240646 (#2639, first released in v8.0.0) the card claims focus on
any `pointerdown` inside it, so that `key` triggers receive their
keyboard events (`keyboard-state-manager.ts`). The claim is a script
call, `element.focus({ preventScroll: true })`, and script-initiated
focus comes with the browser's focus indicator: after a pointer press
while focus was outside the card, the card matches `:focus-visible` and
Chromium draws its default ring around the entire card (measured:
`outline: auto 1px rgb(238, 238, 238)`, a bright line on a dark
dashboard). The ring then persists until focus leaves the card, which
users experience as a white border that appears intermittently when they
click or tap the card.

Ordinary dashboard cards are unaffected because they rely on the
browser's native pointer focus, which shows no indicator. Isolated in
the same browser, a plain `tabindex` element gains focus from a click
without matching `:focus-visible`, while `focus()` from script does
match it. v7 did not claim focus at all, so it never showed this.

**Change:** pass the intent along with the claim: `element.focus({
preventScroll: true, focusVisible: false })`. This code path only runs
for pointer interaction, where no indicator is wanted. Keyboard focus
does not pass through it: tabbing to the card keeps its ring, and the
`key` trigger support from #2639 is unchanged. Browsers without
`FocusOptions.focusVisible` ignore the option and simply keep today's
behaviour. (`focusVisible` is not yet in the bundled TypeScript DOM
types, hence the small global augmentation.)

**Verification:**

- Unit test asserts the focus claim carries `focusVisible: false`.
- Measured in Chromium 152 on a live dashboard: before, a pointer press
on the card leaves it `:focus-visible` with the UA default ring; after,
the same press focuses the card without one, and reaching the card with
Tab still shows the ring. In the same browser, `focus({ focusVisible:
false })` verifiably suppresses `:focus-visible` where a plain `focus()`
sets it.
- `yarn run test`, `yarn run test:browser` (chromium and firefox), `yarn
run lint` and `yarn run typecheck` pass. The webkit browser run fails
one focus test in this local environment, identically on unmodified
`main`, so it is unrelated to this change.

---------

Co-authored-by: dermotduffy <dermot.duffy@gmail.com>
2026-08-30 14:04:40 -07:00
44 changed files with 1283 additions and 125 deletions
+22
View File
@@ -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.
+75
View File
@@ -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.
+10
View File
@@ -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).
+7 -6
View File
@@ -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
+4 -4
View File
@@ -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`
+9 -1
View File
@@ -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. |
+12 -15
View File
@@ -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
+1 -1
View File
@@ -141,7 +141,7 @@ export class IssueStateManager implements IssueReadOnlyState {
if (!force && !this._canRetryNow(issue)) {
continue;
}
if (issue.retry?.()) {
if (issue.retry?.(force)) {
return;
}
}
+4 -3
View File
@@ -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;
}
+3 -1
View File
@@ -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>
`;
}
+1 -1
View File
@@ -537,7 +537,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
this._imageLoadError = true;
}
this._dispatchError('not_loading');
this._dispatchError('server_error');
}}
/>
`
+9 -1
View File
@@ -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();
+1
View File
@@ -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}
+9 -1
View File
@@ -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();
+1
View File
@@ -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);
+4
View File
@@ -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
+6
View File
@@ -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;
}
+2 -1
View File
@@ -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"
},
+33 -18
View File
@@ -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;
}
}
+27 -18
View File
@@ -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;
}
}
+22 -19
View File
@@ -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
View File
@@ -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 };
+32 -3
View File
@@ -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,
+12
View File
@@ -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;
}
+50 -2
View File
@@ -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,
);
});
});
+175
View File
@@ -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(
+85
View File
@@ -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']));
+18
View File
@@ -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']),
);
});
});
});