chore: Enable noImplicitAny across the codebase (#2667)

This commit is contained in:
Dermot Duffy
2026-08-09 13:22:57 -07:00
committed by GitHub
parent 6807fd7690
commit a8ce6acb44
27 changed files with 311 additions and 180 deletions
+2 -2
View File
@@ -15,13 +15,13 @@ import { Timer } from './utils/timer.js';
export interface ActionHandlerInterface extends HTMLElement { export interface ActionHandlerInterface extends HTMLElement {
holdTime: number; holdTime: number;
connectedCallback(): void; connectedCallback(): void;
bind(element: Element, options): void; bind(element: Element, options?: AdvancedCameraCardActionHandlerOptions): void;
} }
interface ActionHandlerElement extends HTMLElement { interface ActionHandlerElement extends HTMLElement {
actionHandlerOptions?: AdvancedCameraCardActionHandlerOptions; actionHandlerOptions?: AdvancedCameraCardActionHandlerOptions;
} }
interface AdvancedCameraCardActionHandlerOptions extends ActionHandlerOptions { export interface AdvancedCameraCardActionHandlerOptions extends ActionHandlerOptions {
allowPropagation?: boolean; allowPropagation?: boolean;
} }
+2 -6
View File
@@ -5,7 +5,6 @@ import type { PTZAction, PTZActionPhase } from '../../config/schema/actions/cust
import type { CameraConfig } from '../../config/schema/cameras'; import type { CameraConfig } from '../../config/schema/cameras';
import type { Entity, EntityRegistryManager } from '../../ha/registry/entity/types'; import type { Entity, EntityRegistryManager } from '../../ha/registry/entity/types';
import type { HomeAssistant } from '../../ha/types'; import type { HomeAssistant } from '../../ha/types';
import { SEVERITIES } from '../../severity';
import { import {
PTZMovementType, PTZMovementType,
type CapabilitiesRaw, type CapabilitiesRaw,
@@ -24,7 +23,7 @@ import {
import { getPTZCapabilitiesFromCameraConfig, mergePTZCapabilities } from '../utils/ptz'; import { getPTZCapabilitiesFromCameraConfig, mergePTZCapabilities } from '../utils/ptz';
import { getPTZInfo } from './requests'; import { getPTZInfo } from './requests';
import { import {
FRIGATE_SEVERITY_MAP, CARD_SEVERITY_MAP,
type FrigateEventChange, type FrigateEventChange,
type FrigateReviewChange, type FrigateReviewChange,
type PTZInfo, type PTZInfo,
@@ -610,10 +609,7 @@ export class FrigateCamera extends Camera<FrigateCameraInitializationOptions> {
const reviewConfig = config.triggers.reviews; const reviewConfig = config.triggers.reviews;
// Map Frigate severity to card severity. const cardSeverity = CARD_SEVERITY_MAP[review.after.severity];
const cardSeverity = SEVERITIES.find(
(key) => FRIGATE_SEVERITY_MAP[key] === review.after.severity,
);
// Check if this is a description update (GenAI added/changed title or scene) // Check if this is a description update (GenAI added/changed title or scene)
const isDescriptionUpdate = const isDescriptionUpdate =
+8
View File
@@ -1,5 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import type { Severity } from '../../severity';
import { dayToDate } from '../../utils/basic'; import { dayToDate } from '../../utils/basic';
import type { import type {
Engine, Engine,
@@ -153,6 +154,13 @@ export const FRIGATE_SEVERITY_MAP = {
export type FrigateReviewSeverity = export type FrigateReviewSeverity =
(typeof FRIGATE_SEVERITY_MAP)[keyof typeof FRIGATE_SEVERITY_MAP]; (typeof FRIGATE_SEVERITY_MAP)[keyof typeof FRIGATE_SEVERITY_MAP];
// Maps Frigate severity to card severity. Frigate has no equivalent of the
// card's `low` severity.
export const CARD_SEVERITY_MAP = {
alert: 'high',
detection: 'medium',
} as const satisfies Record<FrigateReviewSeverity, Severity>;
// Review data schema (only fields we need for display) // Review data schema (only fields we need for display)
const frigateReviewDataSchema = z.object({ const frigateReviewDataSchema = z.object({
objects: z.string().array().optional(), objects: z.string().array().optional(),
+22 -1
View File
@@ -7,6 +7,25 @@ import type { ActionConfig } from '../../config/schema/actions/types';
import type { CameraConfig } from '../../config/schema/cameras'; import type { CameraConfig } from '../../config/schema/cameras';
import { PTZMovementType, type PTZCapabilities } from '../../types'; import { PTZMovementType, type PTZCapabilities } from '../../types';
/**
* Get the action configured for a named PTZ preset.
* @param ptzConfig The camera's PTZ config.
* @param preset The preset name.
* @returns The configured action, or `null` if the preset is not configured.
*/
export const getConfiguredPTZPresetAction = (
ptzConfig: CameraConfig['ptz'],
preset: string,
): ActionConfig | null => {
const presets = ptzConfig.presets;
if (!presets) {
return null;
}
const action = Object.entries(presets).find(([name]) => name === preset)?.[1];
return typeof action === 'object' ? action : null;
};
export const getConfiguredPTZAction = ( export const getConfiguredPTZAction = (
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
action: PTZAction, action: PTZAction,
@@ -16,7 +35,9 @@ export const getConfiguredPTZAction = (
}, },
): ActionConfig | ActionConfig[] | null => { ): ActionConfig | ActionConfig[] | null => {
if (action === 'preset') { if (action === 'preset') {
return (options?.preset ? cameraConfig.ptz.presets?.[options.preset] : null) ?? null; return options?.preset
? getConfiguredPTZPresetAction(cameraConfig.ptz, options.preset)
: null;
} }
if (options?.phase) { if (options?.phase) {
+2 -1
View File
@@ -1,3 +1,4 @@
import { getConfiguredPTZPresetAction } from '../../../camera-manager/utils/ptz';
import type { PTZActionConfig } from '../../../config/schema/actions/custom/ptz'; import type { PTZActionConfig } from '../../../config/schema/actions/custom/ptz';
import { PTZMovementType } from '../../../types'; import { PTZMovementType } from '../../../types';
import { getPTZTarget, ptzActionToCapabilityKey } from '../../../utils/ptz'; import { getPTZTarget, ptzActionToCapabilityKey } from '../../../utils/ptz';
@@ -62,7 +63,7 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
// and the home button always targets `presets[0]`, ignoring the // and the home button always targets `presets[0]`, ignoring the
// configured action. See: // configured action. See:
// https://github.com/dermotduffy/advanced-camera-card/issues/2525 // https://github.com/dermotduffy/advanced-camera-card/issues/2525
if (ptzConfiguration.presets?.['home']) { if (getConfiguredPTZPresetAction(ptzConfiguration, 'home')) {
await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', { await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', {
phase: action.ptz_phase, phase: action.ptz_phase,
preset: 'home', preset: 'home',
+14 -18
View File
@@ -97,29 +97,25 @@ export class StyleManager {
} }
private _setPerformance(): void { private _setPerformance(): void {
const STYLE_DISABLE_MAP = { const styles = this._api.getConfigManager().getCardWideConfig()?.performance?.style;
box_shadow: { if (!styles) {
cssKey: '--advanced-camera-card-box-shadow-override', return;
value: 'none', }
},
border_radius: {
cssKey: '--advanced-camera-card-border-radius-override',
value: '0px',
},
};
const element = this._api.getCardElementManager().getElement(); const element = this._api.getCardElementManager().getElement();
const performance = this._api.getConfigManager().getCardWideConfig()?.performance;
const styles = performance?.style ?? {};
for (const configKey of Object.keys(styles)) {
const mapping = STYLE_DISABLE_MAP[configKey];
setOrRemoveStyleProperty( setOrRemoveStyleProperty(
element, element,
!styles[configKey], !styles.box_shadow,
mapping.cssKey, '--advanced-camera-card-box-shadow-override',
mapping.value, 'none',
);
setOrRemoveStyleProperty(
element,
!styles.border_radius,
'--advanced-camera-card-border-radius-override',
'0px',
); );
}
} }
private _isAspectRatioEnforced( private _isAspectRatioEnforced(
+1 -1
View File
@@ -153,7 +153,7 @@ export class TemplateManager implements TemplateRenderer {
this._renderTemplateRecursively(hass, item, templateContext), this._renderTemplateRecursively(hass, item, templateContext),
); );
} else if (isRecord(data)) { } else if (isRecord(data)) {
const result = {}; const result: Record<string, unknown> = {};
for (const key in data) { for (const key in data) {
result[key] = this._renderTemplateRecursively(hass, data[key], templateContext); result[key] = this._renderTemplateRecursively(hass, data[key], templateContext);
} }
@@ -3,11 +3,13 @@ import type { ViewContext } from 'view';
import type { View } from '../../../view/view'; import type { View } from '../../../view/view';
import type { ViewModifier } from '../types'; import type { ViewModifier } from '../types';
export class RemoveContextPropertyViewModifier implements ViewModifier { export class RemoveContextPropertyViewModifier<T extends keyof ViewContext>
private _key: keyof ViewContext; implements ViewModifier
private _property: PropertyKey; {
private _key: T;
private _property: keyof NonNullable<ViewContext[T]>;
constructor(key: keyof ViewContext, property: PropertyKey) { constructor(key: T, property: keyof NonNullable<ViewContext[T]>) {
this._key = key; this._key = key;
this._property = property; this._property = property;
} }
@@ -0,0 +1,61 @@
// The package ships no types of its own, and no DefinitelyTyped package exists.
declare module '@cycjimmy/jsmpeg-player' {
namespace JSMpeg {
// Options forwarded to the underlying JSMpeg player.
// See: https://github.com/phoboslab/jsmpeg#usage
interface PlayerOptions {
audio?: boolean;
audioBufferSize?: number;
autoplay?: boolean;
chunkSize?: number;
disableGl?: boolean;
disableWebAssembly?: boolean;
maxAudioLag?: number;
pauseWhenHidden?: boolean;
preserveDrawingBuffer?: boolean;
progressive?: boolean;
protocols?: string[];
reconnectInterval?: number;
throttled?: boolean;
video?: boolean;
videoBufferSize?: number;
onPause?: (player: Player) => void;
onPlay?: (player: Player) => void;
onVideoDecode?: (decoder: unknown, elapsedTime: number) => void;
}
// Options for the wrapper element that hosts the canvas and play button.
interface VideoElementOptions {
autoplay?: boolean;
canvas?: HTMLCanvasElement;
poster?: string;
}
class Player {
paused: boolean;
volume: number;
play(): void;
pause(): void;
stop(): void;
destroy(): void;
}
class VideoElement {
constructor(
wrapper: HTMLElement | string,
videoUrl: string,
videoOptions?: VideoElementOptions,
playerOptions?: PlayerOptions,
);
player: Player | null;
play(): void;
pause(): void;
stop(): void;
destroy(): void;
}
}
export default JSMpeg;
}
+1 -1
View File
@@ -396,7 +396,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
.autoHideState=${resolveAutoHideState(!!this.call)} .autoHideState=${resolveAutoHideState(!!this.call)}
?disabled=${!neighbor} ?disabled=${!neighbor}
?locked=${!!this.locked} ?locked=${!!this.locked}
@click=${(ev) => { @click=${(ev: Event) => {
this._setViewCameraID(neighbor?.id); this._setViewCameraID(neighbor?.id);
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
}} }}
+1 -1
View File
@@ -90,7 +90,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
} }
} }
private async _createJSMPEGPlayer(url: string): Promise<JSMpeg.VideoElement> { private async _createJSMPEGPlayer(url: string): Promise<void> {
this._jsmpegVideoPlayer = await new Promise<JSMpeg.VideoElement>((resolve) => { this._jsmpegVideoPlayer = await new Promise<JSMpeg.VideoElement>((resolve) => {
let videoDecoded = false; let videoDecoded = false;
const player = new JSMpeg.VideoElement( const player = new JSMpeg.VideoElement(
+8 -3
View File
@@ -18,8 +18,10 @@ import { getEntityTitle } from '../ha/get-entity-title.js';
import type { EntityRegistryManager } from '../ha/registry/entity/types.js'; import type { EntityRegistryManager } from '../ha/registry/entity/types.js';
import type { HomeAssistant } from '../ha/types.js'; import type { HomeAssistant } from '../ha/types.js';
import menuStyle from '../scss/menu.scss?inline'; import menuStyle from '../scss/menu.scss?inline';
import type { Interaction } from '../types.js';
import { hasAction } from '../utils/action.js'; import { hasAction } from '../utils/action.js';
import { contentsChanged } from '../utils/basic.js'; import { contentsChanged } from '../utils/basic.js';
import type { SubmenuInteraction } from './submenu/types.js';
import './icon.js'; import './icon.js';
import './submenu/select-button.js'; import './submenu/select-button.js';
@@ -76,7 +78,8 @@ export class AdvancedCameraCardMenu extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.submenu=${button} .submenu=${button}
.lockManagerEpoch=${this.lockManagerEpoch} .lockManagerEpoch=${this.lockManagerEpoch}
@action=${(ev) => this._controller.handleAction(ev, button)} @action=${(ev: CustomEvent<SubmenuInteraction>) =>
this._controller.handleAction(ev, button)}
> >
</advanced-camera-card-submenu-button>`; </advanced-camera-card-submenu-button>`;
} else if (button.type === 'custom:advanced-camera-card-menu-submenu-select') { } else if (button.type === 'custom:advanced-camera-card-menu-submenu-select') {
@@ -85,7 +88,8 @@ export class AdvancedCameraCardMenu extends LitElement {
.submenuSelect=${button} .submenuSelect=${button}
.entityRegistryManager=${this.entityRegistryManager} .entityRegistryManager=${this.entityRegistryManager}
.lockManagerEpoch=${this.lockManagerEpoch} .lockManagerEpoch=${this.lockManagerEpoch}
@action=${(ev) => this._controller.handleAction(ev, button)} @action=${(ev: CustomEvent<SubmenuInteraction>) =>
this._controller.handleAction(ev, button)}
> >
</advanced-camera-card-submenu-select-button>`; </advanced-camera-card-submenu-select-button>`;
} }
@@ -104,7 +108,8 @@ export class AdvancedCameraCardMenu extends LitElement {
})} })}
.label=${title ?? ''} .label=${title ?? ''}
?disabled=${this._controller.shouldButtonBeInert(button)} ?disabled=${this._controller.shouldButtonBeInert(button)}
@action=${(ev) => this._controller.handleAction(ev, button)} @action=${(ev: CustomEvent<Interaction>) =>
this._controller.handleAction(ev, button)}
> >
<advanced-camera-card-icon <advanced-camera-card-icon
?allow-override-non-active-styles=${true} ?allow-override-non-active-styles=${true}
+7 -3
View File
@@ -16,6 +16,7 @@ import { StatusBarController } from '../components-lib/status-bar-controller';
import type { StatusBarItem } from '../config/schema/actions/types.js'; import type { StatusBarItem } from '../config/schema/actions/types.js';
import type { StatusBarConfig } from '../config/schema/status-bar.js'; import type { StatusBarConfig } from '../config/schema/status-bar.js';
import statusStyle from '../scss/status.scss?inline'; import statusStyle from '../scss/status.scss?inline';
import type { Interaction } from '../types.js';
import { hasAction } from '../utils/action'; import { hasAction } from '../utils/action';
import { contentsChanged } from '../utils/basic.js'; import { contentsChanged } from '../utils/basic.js';
@@ -114,7 +115,8 @@ export class AdvancedCameraCardStatusBar extends LitElement {
class="${classes}" class="${classes}"
title=${item.title ?? nothing} title=${item.title ?? nothing}
data-severity=${item.severity ?? ''} data-severity=${item.severity ?? ''}
@action=${(ev) => this._controller.actionHandler(ev, item.actions)} @action=${(ev: CustomEvent<Interaction>) =>
this._controller.actionHandler(ev, item.actions)}
> >
${item.string} ${item.string}
</div>`; </div>`;
@@ -125,7 +127,8 @@ export class AdvancedCameraCardStatusBar extends LitElement {
class="${classes}" class="${classes}"
title=${item.title ?? nothing} title=${item.title ?? nothing}
data-severity=${item.severity ?? ''} data-severity=${item.severity ?? ''}
@action=${(ev) => this._controller.actionHandler(ev, item.actions)} @action=${(ev: CustomEvent<Interaction>) =>
this._controller.actionHandler(ev, item.actions)}
></advanced-camera-card-icon>`; ></advanced-camera-card-icon>`;
} else if (item.type === 'custom:advanced-camera-card-status-bar-image') { } else if (item.type === 'custom:advanced-camera-card-status-bar-image') {
return html`<img return html`<img
@@ -134,7 +137,8 @@ export class AdvancedCameraCardStatusBar extends LitElement {
title=${item.title ?? nothing} title=${item.title ?? nothing}
src="${item.image}" src="${item.image}"
data-severity=${item.severity ?? ''} data-severity=${item.severity ?? ''}
@action=${(ev) => this._controller.actionHandler(ev, item.actions)} @action=${(ev: CustomEvent<Interaction>) =>
this._controller.actionHandler(ev, item.actions)}
/>`; />`;
} }
})} })}
+1 -1
View File
@@ -67,7 +67,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement {
const entity = const entity =
(await this.entityRegistryManager?.getEntity(this.hass, entityID)) ?? null; (await this.entityRegistryManager?.getEntity(this.hass, entityID)) ?? null;
const optionTitles = {}; const optionTitles: Record<string, string> = {};
for (const option of options) { for (const option of options) {
const title = getEntityStateTranslation(this.hass, entityID, { const title = getEntityStateTranslation(this.hass, entityID, {
...(entity && { entity: entity }), ...(entity && { entity: entity }),
@@ -1,4 +1,4 @@
import { isEqual } from 'lodash-es'; import { isEqual, pickBy } from 'lodash-es';
import { SerialRunner } from '../../utils/concurrency/serial-runner'; import { SerialRunner } from '../../utils/concurrency/serial-runner';
import type { import type {
@@ -55,15 +55,17 @@ export class ConditionStateManager implements ConditionStateManagerReadonlyInter
} }
private _calculateTrueChange(change: ConditionState): ConditionState { private _calculateTrueChange(change: ConditionState): ConditionState {
const changeState: ConditionState = {}; return pickBy(
change,
for (const key of Object.keys(change)) { (value, key) =>
if (!isEqual(change[key], this._state[key])) { !isEqual(
changeState[key] = change[key]; value,
} this._state[
} // lodash widens the key to `string`, which cannot index ConditionState.
key as keyof ConditionState
return changeState; ],
),
);
} }
private _callListeners = (stateChange: ConditionStateChange): void => { private _callListeners = (stateChange: ConditionStateChange): void => {
+38 -51
View File
@@ -24,7 +24,6 @@ import {
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS,
} from './const'; } from './const';
import type { Condition } from './schema/condition-trigger/conditions/types';
import type { import type {
RawAdvancedCameraCardConfig, RawAdvancedCameraCardConfig,
RawAdvancedCameraCardConfigArray, RawAdvancedCameraCardConfigArray,
@@ -430,17 +429,15 @@ export const deleteTransform = function (_value: unknown): number | null | undef
* @returns `true` if the configuration was modified. * @returns `true` if the configuration was modified.
*/ */
const conditionToConditionsTransform = (data: unknown): boolean => { const conditionToConditionsTransform = (data: unknown): boolean => {
if ( if (!isRecord(data) || !isRecord(data['conditions'])) {
typeof data !== 'object' ||
!data ||
typeof data['conditions'] !== 'object' ||
!data['conditions']
) {
return false; return false;
} }
const oldConditions = data['conditions']; const oldConditions = data['conditions'];
const newConditions: Condition[] = [];
// The legacy values are copied across unvalidated; the schema rejects
// anything malformed when the migrated configuration is later parsed.
const newConditions: RawAdvancedCameraCardConfig[] = [];
if (oldConditions['view'] !== undefined) { if (oldConditions['view'] !== undefined) {
newConditions.push({ newConditions.push({
@@ -475,23 +472,18 @@ const conditionToConditionsTransform = (data: unknown): boolean => {
if (oldConditions['state'] !== undefined && Array.isArray(oldConditions['state'])) { if (oldConditions['state'] !== undefined && Array.isArray(oldConditions['state'])) {
for (const stateCondition of oldConditions['state']) { for (const stateCondition of oldConditions['state']) {
if ( if (
typeof stateCondition === 'object' && isRecord(stateCondition) &&
stateCondition &&
(stateCondition['state'] !== undefined || (stateCondition['state'] !== undefined ||
stateCondition['state_not'] !== undefined || stateCondition['state_not'] !== undefined ||
stateCondition['entity'] !== undefined) stateCondition['entity'] !== undefined)
) { ) {
newConditions.push({ newConditions.push({
condition: 'state' as const, condition: 'state' as const,
...(stateCondition['state'] && { ...(!!stateCondition['state'] && { state: stateCondition['state'] }),
state: stateCondition['state'], ...(!!stateCondition['state_not'] && {
}),
...(stateCondition['state_not'] && {
state_not: stateCondition['state_not'], state_not: stateCondition['state_not'],
}), }),
...(stateCondition['entity'] && { ...(!!stateCondition['entity'] && { entity_id: stateCondition['entity'] }),
entity_id: stateCondition['entity'],
}),
}); });
} }
} }
@@ -570,8 +562,7 @@ const dropTriggerOnlyConditions = (conditions: unknown[]): unknown[] => {
for (const condition of conditions) { for (const condition of conditions) {
if ( if (
isCompositeCondition(condition) && isCompositeCondition(condition) &&
typeof condition === 'object' && isRecord(condition) &&
condition &&
Array.isArray(condition['conditions']) Array.isArray(condition['conditions'])
) { ) {
const inner = dropTriggerOnlyConditions(condition['conditions']); const inner = dropTriggerOnlyConditions(condition['conditions']);
@@ -594,7 +585,7 @@ const rewriteConditionAsTrigger = (condition: unknown): unknown => {
// Only the renamed fields are consumed; anything else the condition carries // Only the renamed fields are consumed; anything else the condition carries
// (`enabled`, and the fields it already shares with its trigger) is preserved, // (`enabled`, and the fields it already shares with its trigger) is preserved,
// so promoting never silently discards user configuration. // so promoting never silently discards user configuration.
const withoutKeys = (...keys: string[]): Record<string, unknown> => { const withoutKeys = (...keys: string[]): RawAdvancedCameraCardConfig => {
const rest = { ...condition }; const rest = { ...condition };
for (const key of keys) { for (const key of keys) {
delete rest[key]; delete rest[key];
@@ -1100,8 +1091,7 @@ const callServiceToPerformActionTransform = (data: unknown): boolean => {
*/ */
const serviceDataToDataTransform = (data: unknown): boolean => { const serviceDataToDataTransform = (data: unknown): boolean => {
if ( if (
typeof data === 'object' && isRecord(data) &&
data &&
data['action'] === 'call-service' && data['action'] === 'call-service' &&
data['service'] !== undefined && data['service'] !== undefined &&
data['service_data'] !== undefined && data['service_data'] !== undefined &&
@@ -1205,7 +1195,7 @@ const ptzIncorrectDataToWebRTCDataTransform = (data: unknown): unknown => {
}; };
const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => { const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => {
if (typeof data !== 'object' || !data) { if (!isRecord(data)) {
return undefined; return undefined;
} }
@@ -1242,7 +1232,7 @@ const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => {
return undefined; return undefined;
} }
const output = {}; const output: RawAdvancedCameraCardConfig = {};
NON_PRESET_DATA_KEYS.filter((key) => key in data).reduce((obj, key) => { NON_PRESET_DATA_KEYS.filter((key) => key in data).reduce((obj, key) => {
obj[key] = data[key]; obj[key] = data[key];
@@ -1250,36 +1240,31 @@ const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => {
}, output); }, output);
NON_PRESET_ACTION_KEYS.filter((key) => key in data).reduce((obj, key) => { NON_PRESET_ACTION_KEYS.filter((key) => key in data).reduce((obj, key) => {
if (typeof data[key] === 'object' && 'tap_action' in data[key]) { const action = data[key];
obj[key] = data[key]['tap_action']; if (isRecord(action) && 'tap_action' in action) {
obj[key] = action['tap_action'];
} }
return obj; return obj;
}, output); }, output);
const createPresets = () => { // Returns the preset collection so callers can add to it after it is
output['presets'] = // attached to the output.
'presets' in data && typeof data['presets'] === 'object' && !!data['presets'] const createPresets = (): RawAdvancedCameraCardConfig => {
? data['presets'] const existing = data['presets'];
: {}; const presets = isRecord(existing) ? existing : {};
output['presets'] = presets;
return presets;
}; };
if ( const actionsHome = data['actions_home'];
'actions_home' in data && const dataHome = data['data_home'];
typeof data['actions_home'] === 'object' &&
data['actions_home'] && if (isRecord(actionsHome) && 'tap_action' in actionsHome) {
'tap_action' in data['actions_home'] createPresets()['home'] = actionsHome['tap_action'];
) { } else if (isRecord(dataHome) && typeof data['service'] === 'string') {
createPresets(); const presets = createPresets();
output['presets']['home'] = data['actions_home']['tap_action']; presets['service'] = data['service'];
} else if ( presets['data_home'] = dataHome;
'data_home' in data &&
typeof data['data_home'] === 'object' &&
data['data_home'] &&
typeof data['service'] === 'string'
) {
createPresets();
output['presets']['service'] = data['service'];
output['presets']['data_home'] = data['data_home'];
} }
return output; return output;
@@ -1310,7 +1295,7 @@ const ptzControlSettingsTransform = (data: unknown): unknown => {
return keys return keys
.filter((key) => TRANSFORM_KEYS.includes(key)) .filter((key) => TRANSFORM_KEYS.includes(key))
.reduce((obj, key) => { .reduce<RawAdvancedCameraCardConfig>((obj, key) => {
obj[key] = data[key]; obj[key] = data[key];
return obj; return obj;
}, {}); }, {});
@@ -1593,13 +1578,15 @@ const UPGRADES = [
deleteWithOverrides('image.layout'), deleteWithOverrides('image.layout'),
upgradeArrayOfObjects(CONF_OVERRIDES, conditionToConditionsTransform), upgradeArrayOfObjects(CONF_OVERRIDES, conditionToConditionsTransform),
(data: unknown): boolean => { (data: unknown): boolean => {
const elements = isRecord(data) ? data[CONF_ELEMENTS] : null;
return upgradeObjectRecursively(conditionToConditionsTransform)( return upgradeObjectRecursively(conditionToConditionsTransform)(
typeof data === 'object' && data ? data[CONF_ELEMENTS] : {}, isRecord(elements) ? elements : {},
); );
}, },
(data: unknown): boolean => { (data: unknown): boolean => {
const automations = isRecord(data) ? data[CONF_AUTOMATIONS] : null;
return upgradeObjectRecursively(conditionToConditionsTransform)( return upgradeObjectRecursively(conditionToConditionsTransform)(
typeof data === 'object' && data ? data[CONF_AUTOMATIONS] : {}, isRecord(automations) ? automations : {},
); );
}, },
upgradeArrayOfObjects( upgradeArrayOfObjects(
+5 -4
View File
@@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { isRecord } from '../../../utils/basic';
import type { RawAdvancedCameraCardConfig } from '../../types';
import { performActionActionSchema } from '../actions/stock/perform-action'; import { performActionActionSchema } from '../actions/stock/perform-action';
export const ptzCameraConfigDefaults = { export const ptzCameraConfigDefaults = {
@@ -14,12 +16,12 @@ export const ptzCameraConfigDefaults = {
const dataPTZFormatToFullFormat = const dataPTZFormatToFullFormat =
(suffix: string) => (suffix: string) =>
(data: unknown): unknown => { (data: unknown): unknown => {
if (!data || typeof data !== 'object' || !data['service']) { if (!isRecord(data) || !data['service']) {
return data; return data;
} }
const service = data['service']; const service = data['service'];
const out = { ...data }; const out: RawAdvancedCameraCardConfig = { ...data };
for (const key of Object.keys(data)) { for (const key of Object.keys(data)) {
const webrtc = key.match(/^data_(start|end)_(.+)$/); const webrtc = key.match(/^data_(start|end)_(.+)$/);
@@ -34,8 +36,7 @@ const dataPTZFormatToFullFormat =
// Route `data_home` into a `home` preset listed first so the PTZ // Route `data_home` into a `home` preset listed first so the PTZ
// home button (which activates the first preset) uses it. // home button (which activates the first preset) uses it.
if (suffix && name === 'home') { if (suffix && name === 'home') {
const presets = const presets = isRecord(out['presets']) ? out['presets'] : {};
out['presets'] && typeof out['presets'] === 'object' ? out['presets'] : {};
if (!('home' in presets)) { if (!('home' in presets)) {
out['presets'] = { out['presets'] = {
home: { home: {
+1 -1
View File
@@ -151,7 +151,7 @@ export interface HomeAssistant {
[key: string]: unknown; [key: string]: unknown;
}, },
) => Promise<Response>; ) => Promise<Response>;
hassUrl(path?): string; hassUrl(path?: string): string;
sendWS: (msg: MessageBase) => Promise<void>; sendWS: (msg: MessageBase) => Promise<void>;
callWS: <T>(msg: MessageBase) => Promise<T>; callWS: <T>(msg: MessageBase) => Promise<T>;
} }
+4 -2
View File
@@ -270,13 +270,15 @@ export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => {
export const recursivelyMergeObjectsNotArrays = <T>( export const recursivelyMergeObjectsNotArrays = <T>(
...srcs: (Partial<T> | undefined | null)[] ...srcs: (Partial<T> | undefined | null)[]
): T => { ): T => {
return mergeWith({}, ...srcs, (_a, b) => (Array.isArray(b) ? b : undefined)); return mergeWith({}, ...srcs, (_a: unknown, b: unknown) =>
Array.isArray(b) ? b : undefined,
);
}; };
export const recursivelyMergeObjectsConcatenatingArraysUniquely = <T>( export const recursivelyMergeObjectsConcatenatingArraysUniquely = <T>(
...srcs: (Partial<T> | undefined | null)[] ...srcs: (Partial<T> | undefined | null)[]
): T => { ): T => {
return mergeWith({}, ...srcs, (a, b) => return mergeWith({}, ...srcs, (a: unknown, b: unknown) =>
Array.isArray(a) ? uniq(a.concat(b)) : undefined, Array.isArray(a) ? uniq(a.concat(b)) : undefined,
); );
}; };
+5 -7
View File
@@ -1,5 +1,6 @@
import type { CameraConfig } from '../config/schema/cameras'; import type { CameraConfig } from '../config/schema/cameras';
import type { RawAdvancedCameraCardConfig } from '../config/types'; import type { RawAdvancedCameraCardConfig } from '../config/types';
import { isRecord } from './basic';
/** /**
* Get a camera id. * Get a camera id.
@@ -12,20 +13,17 @@ export function getCameraID(
return ( return (
(typeof config?.id === 'string' && config.id) || (typeof config?.id === 'string' && config.id) ||
(typeof config?.camera_entity === 'string' && config.camera_entity) || (typeof config?.camera_entity === 'string' && config.camera_entity) ||
(typeof config?.webrtc_card === 'object' && (isRecord(config?.webrtc_card) &&
config.webrtc_card &&
((typeof config.webrtc_card['entity'] === 'string' && ((typeof config.webrtc_card['entity'] === 'string' &&
config.webrtc_card['entity']) || config.webrtc_card['entity']) ||
(typeof config.webrtc_card['url'] === 'string' && config.webrtc_card['url']))) || (typeof config.webrtc_card['url'] === 'string' && config.webrtc_card['url']))) ||
(typeof config?.go2rtc === 'object' && (isRecord(config?.go2rtc) &&
config.go2rtc &&
typeof config.go2rtc['url'] === 'string' && typeof config.go2rtc['url'] === 'string' &&
typeof config.go2rtc['stream'] === 'string' && typeof config.go2rtc['stream'] === 'string' &&
// Artifical identifier that includes both url / stream. // Artifical identifier that includes both url / stream.
`${config.go2rtc['url']}#${config.go2rtc['stream']}`) || `${config.go2rtc['url']}#${config.go2rtc['stream']}`) ||
(typeof config?.frigate === 'object' && (isRecord(config?.frigate) &&
config.frigate && typeof config.frigate['camera_name'] === 'string' &&
typeof config?.frigate['camera_name'] === 'string' &&
config.frigate['camera_name']) || config.frigate['camera_name']) ||
'' ''
); );
+13 -2
View File
@@ -1,6 +1,17 @@
import type { MediaLayoutConfig } from '../config/schema/camera/media-layout'; import type { MediaLayoutConfig } from '../config/schema/camera/media-layout';
import { setOrRemoveStyleProperty } from './basic'; import { setOrRemoveStyleProperty } from './basic';
const POSITION_DIMENSIONS: (keyof NonNullable<MediaLayoutConfig['position']>)[] = [
'x',
'y',
];
const VIEW_BOX_EDGES: (keyof NonNullable<MediaLayoutConfig['view_box']>)[] = [
'top',
'bottom',
'left',
'right',
];
/** /**
* Update element style from a media configuration. * Update element style from a media configuration.
* @param element The element to update the style for. * @param element The element to update the style for.
@@ -17,7 +28,7 @@ export const updateElementStyleFromMediaLayoutConfig = (
mediaLayoutConfig?.fit, mediaLayoutConfig?.fit,
); );
for (const dimension of ['x', 'y']) { for (const dimension of POSITION_DIMENSIONS) {
setOrRemoveStyleProperty( setOrRemoveStyleProperty(
element, element,
!!mediaLayoutConfig?.position?.[dimension], !!mediaLayoutConfig?.position?.[dimension],
@@ -26,7 +37,7 @@ export const updateElementStyleFromMediaLayoutConfig = (
); );
} }
for (const dimension of ['top', 'bottom', 'left', 'right']) { for (const dimension of VIEW_BOX_EDGES) {
setOrRemoveStyleProperty( setOrRemoveStyleProperty(
element, element,
!!mediaLayoutConfig?.view_box?.[dimension], !!mediaLayoutConfig?.view_box?.[dimension],
+6 -4
View File
@@ -143,13 +143,15 @@ export class View {
return this; return this;
} }
public removeContextProperty( public removeContextProperty<T extends keyof ViewContext>(
contextKey: keyof ViewContext, contextKey: T,
removeKey: PropertyKey, removeKey: keyof NonNullable<ViewContext[T]>,
): View { ): View {
const contextObj = this.context?.[contextKey]; const contextObj = this.context?.[contextKey];
if (contextObj) { if (contextObj) {
delete contextObj[removeKey]; // Cannot use a regular 'delete' here as TypeScript cannot directly index
// `contextObj` while its type is still generic.
Reflect.deleteProperty(contextObj, removeKey);
} }
return this; return this;
} }
+4 -1
View File
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
actionHandler, actionHandler,
type ActionHandlerInterface, type ActionHandlerInterface,
type AdvancedCameraCardActionHandlerOptions,
} from '../src/action-handler-directive'; } from '../src/action-handler-directive';
import { fireHASSEvent } from '../src/ha/fire-hass-event'; import { fireHASSEvent } from '../src/ha/fire-hass-event';
import type { ActionHandlerDetail } from '../src/ha/types'; import type { ActionHandlerDetail } from '../src/ha/types';
@@ -23,7 +24,9 @@ const getActionHandler = (): ActionHandlerInterface => {
return el as ActionHandlerInterface; return el as ActionHandlerInterface;
}; };
const createBoundElement = (options?: Record<string, unknown>): HTMLElement => { const createBoundElement = (
options?: AdvancedCameraCardActionHandlerOptions,
): HTMLElement => {
const handler = getActionHandler(); const handler = getActionHandler();
const element = document.createElement('div'); const element = document.createElement('div');
handler.bind(element, options); handler.bind(element, options);
+54 -32
View File
@@ -27,6 +27,9 @@ import {
type EventQueryResults, type EventQueryResults,
type MediaMetadata, type MediaMetadata,
type QueryResults, type QueryResults,
type RecordingQuery,
type RecordingSegmentsQuery,
type ReviewQuery,
} from '../../src/camera-manager/types.js'; } from '../../src/camera-manager/types.js';
import type { CardController } from '../../src/card-controller/controller.js'; import type { CardController } from '../../src/card-controller/controller.js';
import type { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; import type { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
@@ -752,34 +755,7 @@ describe('CameraManager', () => {
}); });
describe('generate default queries', () => { describe('generate default queries', () => {
it.each([ const setupManagerWithEngine = async () => {
[
QueryType.Event as const,
'generateDefaultEventQuery',
'generateDefaultEventQueries',
],
[
QueryType.Recording as const,
'generateDefaultRecordingQuery',
'generateDefaultRecordingQueries',
],
[
QueryType.RecordingSegments as const,
'generateDefaultRecordingSegmentsQuery',
'generateDefaultRecordingSegmentsQueries',
],
[
QueryType.Review as const,
'generateDefaultReviewQuery',
'generateDefaultReviewQueries',
],
])(
'basic %s',
async (
queryType: string,
engineMethodName: string,
managerMethodName: string,
) => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
@@ -787,11 +763,57 @@ describe('CameraManager', () => {
const manager = createCameraManager(api, engine); const manager = createCameraManager(api, engine);
await manager.initializeCamerasFromConfig(); await manager.initializeCamerasFromConfig();
const queries = [{ type: queryType, cameraIDs: new Set(['id']) }]; return { engine, manager };
engine[engineMethodName].mockReturnValue(queries); };
expect(manager[managerMethodName]('id')).toEqual(queries);
it('should generate default event queries', async () => {
const { engine, manager } = await setupManagerWithEngine();
const queries: EventQuery[] = [baseEventQuery];
engine.generateDefaultEventQuery.mockReturnValue(queries);
expect(manager.generateDefaultEventQueries('id')).toEqual(queries);
});
it('should generate default recording queries', async () => {
const { engine, manager } = await setupManagerWithEngine();
const queries: RecordingQuery[] = [baseRecordingQuery];
engine.generateDefaultRecordingQuery.mockReturnValue(queries);
expect(manager.generateDefaultRecordingQueries('id')).toEqual(queries);
});
it('should generate default recording segments queries', async () => {
const { engine, manager } = await setupManagerWithEngine();
const queries: RecordingSegmentsQuery[] = [
{
type: QueryType.RecordingSegments,
cameraIDs: new Set(['id']),
start: new Date(),
end: new Date(),
}, },
); ];
engine.generateDefaultRecordingSegmentsQuery.mockReturnValue(queries);
expect(manager.generateDefaultRecordingSegmentsQueries('id')).toEqual(queries);
});
it('should generate default review queries', async () => {
const { engine, manager } = await setupManagerWithEngine();
const queries: ReviewQuery[] = [
{
source: QuerySource.Camera,
type: QueryType.Review,
cameraIDs: new Set(['id']),
},
];
engine.generateDefaultReviewQuery.mockReturnValue(queries);
expect(manager.generateDefaultReviewQueries('id')).toEqual(queries);
});
it('should handle missing camera', async () => { it('should handle missing camera', async () => {
const api = createCardAPI(); const api = createCardAPI();
@@ -42,13 +42,18 @@ const getActionSpy = (
}; };
const createPlayerElement = (controller?: MediaPlayerController): MediaPlayerElement => { const createPlayerElement = (controller?: MediaPlayerController): MediaPlayerElement => {
const player = document.createElement('video'); const player: MediaPlayerElement<HTMLVideoElement> = Object.assign(
player['getMediaPlayerController'] = vi document.createElement('video'),
{
getMediaPlayerController: vi
.fn() .fn()
.mockResolvedValue( .mockResolvedValue(
controller ?? mock<MediaPlayerController>({ playback: mock<PlaybackControl>() }), controller ??
mock<MediaPlayerController>({ playback: mock<PlaybackControl>() }),
),
},
); );
return player as unknown as MediaPlayerElement; return player;
}; };
const createPlayerSlideNodes = (n = 10): HTMLElement[] => { const createPlayerSlideNodes = (n = 10): HTMLElement[] => {
@@ -914,13 +919,17 @@ describe('MediaActionsController', () => {
// A player whose media player controller is not ready on first request. // A player whose media player controller is not ready on first request.
const mediaPlayerController = mock<MediaPlayerController>(); const mediaPlayerController = mock<MediaPlayerController>();
const player = document.createElement('video'); const player: MediaPlayerElement<HTMLVideoElement> = Object.assign(
player['getMediaPlayerController'] = vi document.createElement('video'),
{
getMediaPlayerController: vi
.fn() .fn()
.mockResolvedValueOnce(null) .mockResolvedValueOnce(null)
.mockResolvedValue(mediaPlayerController); .mockResolvedValue(mediaPlayerController),
},
);
const child = createTestSlideNodes({ n: 1 })[0]; const child = createTestSlideNodes({ n: 1 })[0];
child.appendChild(player as unknown as MediaPlayerElement); child.appendChild(player);
controller.setRoot(createParent({ children: [child] })); controller.setRoot(createParent({ children: [child] }));
await controller.setTarget(0, true); await controller.setTarget(0, true);
+1 -1
View File
@@ -3,6 +3,7 @@
"target": "es2021", "target": "es2021",
"module": "es2020", "module": "es2020",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowJs": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"isolatedModules": true, "isolatedModules": true,
"lib": ["es2021", "dom", "dom.iterable"], "lib": ["es2021", "dom", "dom.iterable"],
@@ -12,7 +13,6 @@
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"strict": true, "strict": true,
"noImplicitAny": false,
"skipLibCheck": true, "skipLibCheck": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"experimentalDecorators": true, "experimentalDecorators": true,