diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index a276f365..a94fc411 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -15,13 +15,13 @@ import { Timer } from './utils/timer.js'; export interface ActionHandlerInterface extends HTMLElement { holdTime: number; connectedCallback(): void; - bind(element: Element, options): void; + bind(element: Element, options?: AdvancedCameraCardActionHandlerOptions): void; } interface ActionHandlerElement extends HTMLElement { actionHandlerOptions?: AdvancedCameraCardActionHandlerOptions; } -interface AdvancedCameraCardActionHandlerOptions extends ActionHandlerOptions { +export interface AdvancedCameraCardActionHandlerOptions extends ActionHandlerOptions { allowPropagation?: boolean; } diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts index f1f9d8b2..3554efac 100644 --- a/src/camera-manager/frigate/camera.ts +++ b/src/camera-manager/frigate/camera.ts @@ -5,7 +5,6 @@ import type { PTZAction, PTZActionPhase } from '../../config/schema/actions/cust import type { CameraConfig } from '../../config/schema/cameras'; import type { Entity, EntityRegistryManager } from '../../ha/registry/entity/types'; import type { HomeAssistant } from '../../ha/types'; -import { SEVERITIES } from '../../severity'; import { PTZMovementType, type CapabilitiesRaw, @@ -24,7 +23,7 @@ import { import { getPTZCapabilitiesFromCameraConfig, mergePTZCapabilities } from '../utils/ptz'; import { getPTZInfo } from './requests'; import { - FRIGATE_SEVERITY_MAP, + CARD_SEVERITY_MAP, type FrigateEventChange, type FrigateReviewChange, type PTZInfo, @@ -610,10 +609,7 @@ export class FrigateCamera extends Camera { const reviewConfig = config.triggers.reviews; - // Map Frigate severity to card severity. - const cardSeverity = SEVERITIES.find( - (key) => FRIGATE_SEVERITY_MAP[key] === review.after.severity, - ); + const cardSeverity = CARD_SEVERITY_MAP[review.after.severity]; // Check if this is a description update (GenAI added/changed title or scene) const isDescriptionUpdate = diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts index a9c3592b..ce23de37 100644 --- a/src/camera-manager/frigate/types.ts +++ b/src/camera-manager/frigate/types.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import type { Severity } from '../../severity'; import { dayToDate } from '../../utils/basic'; import type { Engine, @@ -153,6 +154,13 @@ export const FRIGATE_SEVERITY_MAP = { export type FrigateReviewSeverity = (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; + // Review data schema (only fields we need for display) const frigateReviewDataSchema = z.object({ objects: z.string().array().optional(), diff --git a/src/camera-manager/utils/ptz.ts b/src/camera-manager/utils/ptz.ts index 7639ec6b..9cf68c03 100644 --- a/src/camera-manager/utils/ptz.ts +++ b/src/camera-manager/utils/ptz.ts @@ -7,6 +7,25 @@ import type { ActionConfig } from '../../config/schema/actions/types'; import type { CameraConfig } from '../../config/schema/cameras'; 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 = ( cameraConfig: CameraConfig, action: PTZAction, @@ -16,7 +35,9 @@ export const getConfiguredPTZAction = ( }, ): ActionConfig | ActionConfig[] | null => { 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) { diff --git a/src/card-controller/actions/actions/ptz.ts b/src/card-controller/actions/actions/ptz.ts index e943d8f0..b35f7a8b 100644 --- a/src/card-controller/actions/actions/ptz.ts +++ b/src/card-controller/actions/actions/ptz.ts @@ -1,3 +1,4 @@ +import { getConfiguredPTZPresetAction } from '../../../camera-manager/utils/ptz'; import type { PTZActionConfig } from '../../../config/schema/actions/custom/ptz'; import { PTZMovementType } from '../../../types'; import { getPTZTarget, ptzActionToCapabilityKey } from '../../../utils/ptz'; @@ -62,7 +63,7 @@ export class PTZAction extends AdvancedCameraCardAction { // and the home button always targets `presets[0]`, ignoring the // configured action. See: // https://github.com/dermotduffy/advanced-camera-card/issues/2525 - if (ptzConfiguration.presets?.['home']) { + if (getConfiguredPTZPresetAction(ptzConfiguration, 'home')) { await api.getCameraManager().executePTZAction(ptzCameraID, 'preset', { phase: action.ptz_phase, preset: 'home', diff --git a/src/card-controller/style-manager.ts b/src/card-controller/style-manager.ts index 98893b9c..0d05218b 100644 --- a/src/card-controller/style-manager.ts +++ b/src/card-controller/style-manager.ts @@ -97,29 +97,25 @@ export class StyleManager { } private _setPerformance(): void { - const STYLE_DISABLE_MAP = { - box_shadow: { - cssKey: '--advanced-camera-card-box-shadow-override', - value: 'none', - }, - border_radius: { - cssKey: '--advanced-camera-card-border-radius-override', - value: '0px', - }, - }; - 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( - element, - !styles[configKey], - mapping.cssKey, - mapping.value, - ); + const styles = this._api.getConfigManager().getCardWideConfig()?.performance?.style; + if (!styles) { + return; } + + const element = this._api.getCardElementManager().getElement(); + + setOrRemoveStyleProperty( + element, + !styles.box_shadow, + '--advanced-camera-card-box-shadow-override', + 'none', + ); + setOrRemoveStyleProperty( + element, + !styles.border_radius, + '--advanced-camera-card-border-radius-override', + '0px', + ); } private _isAspectRatioEnforced( diff --git a/src/card-controller/templates/index.ts b/src/card-controller/templates/index.ts index 2b510619..1ec4a055 100644 --- a/src/card-controller/templates/index.ts +++ b/src/card-controller/templates/index.ts @@ -153,7 +153,7 @@ export class TemplateManager implements TemplateRenderer { this._renderTemplateRecursively(hass, item, templateContext), ); } else if (isRecord(data)) { - const result = {}; + const result: Record = {}; for (const key in data) { result[key] = this._renderTemplateRecursively(hass, data[key], templateContext); } diff --git a/src/card-controller/view/modifiers/remove-context-property.ts b/src/card-controller/view/modifiers/remove-context-property.ts index e75b501d..027da90a 100644 --- a/src/card-controller/view/modifiers/remove-context-property.ts +++ b/src/card-controller/view/modifiers/remove-context-property.ts @@ -3,11 +3,13 @@ import type { ViewContext } from 'view'; import type { View } from '../../../view/view'; import type { ViewModifier } from '../types'; -export class RemoveContextPropertyViewModifier implements ViewModifier { - private _key: keyof ViewContext; - private _property: PropertyKey; +export class RemoveContextPropertyViewModifier + implements ViewModifier +{ + private _key: T; + private _property: keyof NonNullable; - constructor(key: keyof ViewContext, property: PropertyKey) { + constructor(key: T, property: keyof NonNullable) { this._key = key; this._property = property; } diff --git a/src/components-lib/live/providers/jsmpeg/jsmpeg-player.d.ts b/src/components-lib/live/providers/jsmpeg/jsmpeg-player.d.ts new file mode 100644 index 00000000..82e9c360 --- /dev/null +++ b/src/components-lib/live/providers/jsmpeg/jsmpeg-player.d.ts @@ -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; +} diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts index ccc386ba..fb281364 100644 --- a/src/components/live/carousel.ts +++ b/src/components/live/carousel.ts @@ -396,7 +396,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { .autoHideState=${resolveAutoHideState(!!this.call)} ?disabled=${!neighbor} ?locked=${!!this.locked} - @click=${(ev) => { + @click=${(ev: Event) => { this._setViewCameraID(neighbor?.id); stopEventFromActivatingCardWideActions(ev); }} diff --git a/src/components/live/providers/jsmpeg.ts b/src/components/live/providers/jsmpeg.ts index d04562b3..67d34cf1 100644 --- a/src/components/live/providers/jsmpeg.ts +++ b/src/components/live/providers/jsmpeg.ts @@ -90,7 +90,7 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla } } - private async _createJSMPEGPlayer(url: string): Promise { + private async _createJSMPEGPlayer(url: string): Promise { this._jsmpegVideoPlayer = await new Promise((resolve) => { let videoDecoded = false; const player = new JSMpeg.VideoElement( diff --git a/src/components/menu.ts b/src/components/menu.ts index bb49af50..97631078 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -18,8 +18,10 @@ import { getEntityTitle } from '../ha/get-entity-title.js'; import type { EntityRegistryManager } from '../ha/registry/entity/types.js'; import type { HomeAssistant } from '../ha/types.js'; import menuStyle from '../scss/menu.scss?inline'; +import type { Interaction } from '../types.js'; import { hasAction } from '../utils/action.js'; import { contentsChanged } from '../utils/basic.js'; +import type { SubmenuInteraction } from './submenu/types.js'; import './icon.js'; import './submenu/select-button.js'; @@ -76,7 +78,8 @@ export class AdvancedCameraCardMenu extends LitElement { .hass=${this.hass} .submenu=${button} .lockManagerEpoch=${this.lockManagerEpoch} - @action=${(ev) => this._controller.handleAction(ev, button)} + @action=${(ev: CustomEvent) => + this._controller.handleAction(ev, button)} > `; } else if (button.type === 'custom:advanced-camera-card-menu-submenu-select') { @@ -85,7 +88,8 @@ export class AdvancedCameraCardMenu extends LitElement { .submenuSelect=${button} .entityRegistryManager=${this.entityRegistryManager} .lockManagerEpoch=${this.lockManagerEpoch} - @action=${(ev) => this._controller.handleAction(ev, button)} + @action=${(ev: CustomEvent) => + this._controller.handleAction(ev, button)} > `; } @@ -104,7 +108,8 @@ export class AdvancedCameraCardMenu extends LitElement { })} .label=${title ?? ''} ?disabled=${this._controller.shouldButtonBeInert(button)} - @action=${(ev) => this._controller.handleAction(ev, button)} + @action=${(ev: CustomEvent) => + this._controller.handleAction(ev, button)} > this._controller.actionHandler(ev, item.actions)} + @action=${(ev: CustomEvent) => + this._controller.actionHandler(ev, item.actions)} > ${item.string} `; @@ -125,7 +127,8 @@ export class AdvancedCameraCardStatusBar extends LitElement { class="${classes}" title=${item.title ?? nothing} data-severity=${item.severity ?? ''} - @action=${(ev) => this._controller.actionHandler(ev, item.actions)} + @action=${(ev: CustomEvent) => + this._controller.actionHandler(ev, item.actions)} >`; } else if (item.type === 'custom:advanced-camera-card-status-bar-image') { return html` this._controller.actionHandler(ev, item.actions)} + @action=${(ev: CustomEvent) => + this._controller.actionHandler(ev, item.actions)} />`; } })} diff --git a/src/components/submenu/select-button.ts b/src/components/submenu/select-button.ts index 536f6669..fcec3e10 100644 --- a/src/components/submenu/select-button.ts +++ b/src/components/submenu/select-button.ts @@ -67,7 +67,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement { const entity = (await this.entityRegistryManager?.getEntity(this.hass, entityID)) ?? null; - const optionTitles = {}; + const optionTitles: Record = {}; for (const option of options) { const title = getEntityStateTranslation(this.hass, entityID, { ...(entity && { entity: entity }), diff --git a/src/condition-trigger/conditions/state-manager.ts b/src/condition-trigger/conditions/state-manager.ts index 1178579d..f8cc1135 100644 --- a/src/condition-trigger/conditions/state-manager.ts +++ b/src/condition-trigger/conditions/state-manager.ts @@ -1,4 +1,4 @@ -import { isEqual } from 'lodash-es'; +import { isEqual, pickBy } from 'lodash-es'; import { SerialRunner } from '../../utils/concurrency/serial-runner'; import type { @@ -55,15 +55,17 @@ export class ConditionStateManager implements ConditionStateManagerReadonlyInter } private _calculateTrueChange(change: ConditionState): ConditionState { - const changeState: ConditionState = {}; - - for (const key of Object.keys(change)) { - if (!isEqual(change[key], this._state[key])) { - changeState[key] = change[key]; - } - } - - return changeState; + return pickBy( + change, + (value, key) => + !isEqual( + value, + this._state[ + // lodash widens the key to `string`, which cannot index ConditionState. + key as keyof ConditionState + ], + ), + ); } private _callListeners = (stateChange: ConditionStateChange): void => { diff --git a/src/config/management.ts b/src/config/management.ts index 2a8e2e95..f0f86012 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -24,7 +24,6 @@ import { CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, } from './const'; -import type { Condition } from './schema/condition-trigger/conditions/types'; import type { RawAdvancedCameraCardConfig, RawAdvancedCameraCardConfigArray, @@ -430,17 +429,15 @@ export const deleteTransform = function (_value: unknown): number | null | undef * @returns `true` if the configuration was modified. */ const conditionToConditionsTransform = (data: unknown): boolean => { - if ( - typeof data !== 'object' || - !data || - typeof data['conditions'] !== 'object' || - !data['conditions'] - ) { + if (!isRecord(data) || !isRecord(data['conditions'])) { return false; } 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) { newConditions.push({ @@ -475,23 +472,18 @@ const conditionToConditionsTransform = (data: unknown): boolean => { if (oldConditions['state'] !== undefined && Array.isArray(oldConditions['state'])) { for (const stateCondition of oldConditions['state']) { if ( - typeof stateCondition === 'object' && - stateCondition && + isRecord(stateCondition) && (stateCondition['state'] !== undefined || stateCondition['state_not'] !== undefined || stateCondition['entity'] !== undefined) ) { newConditions.push({ condition: 'state' as const, - ...(stateCondition['state'] && { - state: stateCondition['state'], - }), - ...(stateCondition['state_not'] && { + ...(!!stateCondition['state'] && { state: stateCondition['state'] }), + ...(!!stateCondition['state_not'] && { state_not: stateCondition['state_not'], }), - ...(stateCondition['entity'] && { - entity_id: stateCondition['entity'], - }), + ...(!!stateCondition['entity'] && { entity_id: stateCondition['entity'] }), }); } } @@ -570,8 +562,7 @@ const dropTriggerOnlyConditions = (conditions: unknown[]): unknown[] => { for (const condition of conditions) { if ( isCompositeCondition(condition) && - typeof condition === 'object' && - condition && + isRecord(condition) && Array.isArray(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 // (`enabled`, and the fields it already shares with its trigger) is preserved, // so promoting never silently discards user configuration. - const withoutKeys = (...keys: string[]): Record => { + const withoutKeys = (...keys: string[]): RawAdvancedCameraCardConfig => { const rest = { ...condition }; for (const key of keys) { delete rest[key]; @@ -1100,8 +1091,7 @@ const callServiceToPerformActionTransform = (data: unknown): boolean => { */ const serviceDataToDataTransform = (data: unknown): boolean => { if ( - typeof data === 'object' && - data && + isRecord(data) && data['action'] === 'call-service' && data['service'] !== undefined && data['service_data'] !== undefined && @@ -1205,7 +1195,7 @@ const ptzIncorrectDataToWebRTCDataTransform = (data: unknown): unknown => { }; const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => { - if (typeof data !== 'object' || !data) { + if (!isRecord(data)) { return undefined; } @@ -1242,7 +1232,7 @@ const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => { return undefined; } - const output = {}; + const output: RawAdvancedCameraCardConfig = {}; NON_PRESET_DATA_KEYS.filter((key) => key in data).reduce((obj, key) => { obj[key] = data[key]; @@ -1250,36 +1240,31 @@ const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => { }, output); NON_PRESET_ACTION_KEYS.filter((key) => key in data).reduce((obj, key) => { - if (typeof data[key] === 'object' && 'tap_action' in data[key]) { - obj[key] = data[key]['tap_action']; + const action = data[key]; + if (isRecord(action) && 'tap_action' in action) { + obj[key] = action['tap_action']; } return obj; }, output); - const createPresets = () => { - output['presets'] = - 'presets' in data && typeof data['presets'] === 'object' && !!data['presets'] - ? data['presets'] - : {}; + // Returns the preset collection so callers can add to it after it is + // attached to the output. + const createPresets = (): RawAdvancedCameraCardConfig => { + const existing = data['presets']; + const presets = isRecord(existing) ? existing : {}; + output['presets'] = presets; + return presets; }; - if ( - 'actions_home' in data && - typeof data['actions_home'] === 'object' && - data['actions_home'] && - 'tap_action' in data['actions_home'] - ) { - createPresets(); - output['presets']['home'] = data['actions_home']['tap_action']; - } else if ( - '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']; + const actionsHome = data['actions_home']; + const dataHome = data['data_home']; + + if (isRecord(actionsHome) && 'tap_action' in actionsHome) { + createPresets()['home'] = actionsHome['tap_action']; + } else if (isRecord(dataHome) && typeof data['service'] === 'string') { + const presets = createPresets(); + presets['service'] = data['service']; + presets['data_home'] = dataHome; } return output; @@ -1310,7 +1295,7 @@ const ptzControlSettingsTransform = (data: unknown): unknown => { return keys .filter((key) => TRANSFORM_KEYS.includes(key)) - .reduce((obj, key) => { + .reduce((obj, key) => { obj[key] = data[key]; return obj; }, {}); @@ -1593,13 +1578,15 @@ const UPGRADES = [ deleteWithOverrides('image.layout'), upgradeArrayOfObjects(CONF_OVERRIDES, conditionToConditionsTransform), (data: unknown): boolean => { + const elements = isRecord(data) ? data[CONF_ELEMENTS] : null; return upgradeObjectRecursively(conditionToConditionsTransform)( - typeof data === 'object' && data ? data[CONF_ELEMENTS] : {}, + isRecord(elements) ? elements : {}, ); }, (data: unknown): boolean => { + const automations = isRecord(data) ? data[CONF_AUTOMATIONS] : null; return upgradeObjectRecursively(conditionToConditionsTransform)( - typeof data === 'object' && data ? data[CONF_AUTOMATIONS] : {}, + isRecord(automations) ? automations : {}, ); }, upgradeArrayOfObjects( diff --git a/src/config/schema/camera/ptz.ts b/src/config/schema/camera/ptz.ts index a189d8fc..ba0bdaeb 100644 --- a/src/config/schema/camera/ptz.ts +++ b/src/config/schema/camera/ptz.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { isRecord } from '../../../utils/basic'; +import type { RawAdvancedCameraCardConfig } from '../../types'; import { performActionActionSchema } from '../actions/stock/perform-action'; export const ptzCameraConfigDefaults = { @@ -14,12 +16,12 @@ export const ptzCameraConfigDefaults = { const dataPTZFormatToFullFormat = (suffix: string) => (data: unknown): unknown => { - if (!data || typeof data !== 'object' || !data['service']) { + if (!isRecord(data) || !data['service']) { return data; } const service = data['service']; - const out = { ...data }; + const out: RawAdvancedCameraCardConfig = { ...data }; for (const key of Object.keys(data)) { 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 // home button (which activates the first preset) uses it. if (suffix && name === 'home') { - const presets = - out['presets'] && typeof out['presets'] === 'object' ? out['presets'] : {}; + const presets = isRecord(out['presets']) ? out['presets'] : {}; if (!('home' in presets)) { out['presets'] = { home: { diff --git a/src/ha/types.ts b/src/ha/types.ts index b870b33f..b8065360 100644 --- a/src/ha/types.ts +++ b/src/ha/types.ts @@ -151,7 +151,7 @@ export interface HomeAssistant { [key: string]: unknown; }, ) => Promise; - hassUrl(path?): string; + hassUrl(path?: string): string; sendWS: (msg: MessageBase) => Promise; callWS: (msg: MessageBase) => Promise; } diff --git a/src/scoped-elements/gr-select.js b/src/scoped-elements/gr-select.ts similarity index 100% rename from src/scoped-elements/gr-select.js rename to src/scoped-elements/gr-select.ts diff --git a/src/utils/basic.ts b/src/utils/basic.ts index a3a8146e..7be58ca3 100644 --- a/src/utils/basic.ts +++ b/src/utils/basic.ts @@ -270,13 +270,15 @@ export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => { export const recursivelyMergeObjectsNotArrays = ( ...srcs: (Partial | undefined | null)[] ): 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 = ( ...srcs: (Partial | undefined | null)[] ): T => { - return mergeWith({}, ...srcs, (a, b) => + return mergeWith({}, ...srcs, (a: unknown, b: unknown) => Array.isArray(a) ? uniq(a.concat(b)) : undefined, ); }; diff --git a/src/utils/camera.ts b/src/utils/camera.ts index f17b2285..d67c554d 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -1,5 +1,6 @@ import type { CameraConfig } from '../config/schema/cameras'; import type { RawAdvancedCameraCardConfig } from '../config/types'; +import { isRecord } from './basic'; /** * Get a camera id. @@ -12,20 +13,17 @@ export function getCameraID( return ( (typeof config?.id === 'string' && config.id) || (typeof config?.camera_entity === 'string' && config.camera_entity) || - (typeof config?.webrtc_card === 'object' && - config.webrtc_card && + (isRecord(config?.webrtc_card) && ((typeof config.webrtc_card['entity'] === 'string' && config.webrtc_card['entity']) || (typeof config.webrtc_card['url'] === 'string' && config.webrtc_card['url']))) || - (typeof config?.go2rtc === 'object' && - config.go2rtc && + (isRecord(config?.go2rtc) && typeof config.go2rtc['url'] === 'string' && typeof config.go2rtc['stream'] === 'string' && // Artifical identifier that includes both url / stream. `${config.go2rtc['url']}#${config.go2rtc['stream']}`) || - (typeof config?.frigate === 'object' && - config.frigate && - typeof config?.frigate['camera_name'] === 'string' && + (isRecord(config?.frigate) && + typeof config.frigate['camera_name'] === 'string' && config.frigate['camera_name']) || '' ); diff --git a/src/utils/media-layout.ts b/src/utils/media-layout.ts index e53c1896..cb99afff 100644 --- a/src/utils/media-layout.ts +++ b/src/utils/media-layout.ts @@ -1,6 +1,17 @@ import type { MediaLayoutConfig } from '../config/schema/camera/media-layout'; import { setOrRemoveStyleProperty } from './basic'; +const POSITION_DIMENSIONS: (keyof NonNullable)[] = [ + 'x', + 'y', +]; +const VIEW_BOX_EDGES: (keyof NonNullable)[] = [ + 'top', + 'bottom', + 'left', + 'right', +]; + /** * Update element style from a media configuration. * @param element The element to update the style for. @@ -17,7 +28,7 @@ export const updateElementStyleFromMediaLayoutConfig = ( mediaLayoutConfig?.fit, ); - for (const dimension of ['x', 'y']) { + for (const dimension of POSITION_DIMENSIONS) { setOrRemoveStyleProperty( element, !!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( element, !!mediaLayoutConfig?.view_box?.[dimension], diff --git a/src/view/view.ts b/src/view/view.ts index 92d4f79c..d1b2ab57 100644 --- a/src/view/view.ts +++ b/src/view/view.ts @@ -143,13 +143,15 @@ export class View { return this; } - public removeContextProperty( - contextKey: keyof ViewContext, - removeKey: PropertyKey, + public removeContextProperty( + contextKey: T, + removeKey: keyof NonNullable, ): View { const contextObj = this.context?.[contextKey]; 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; } diff --git a/tests/action-handler-directive.test.ts b/tests/action-handler-directive.test.ts index 84c0c6a8..a96c6b31 100644 --- a/tests/action-handler-directive.test.ts +++ b/tests/action-handler-directive.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { actionHandler, type ActionHandlerInterface, + type AdvancedCameraCardActionHandlerOptions, } from '../src/action-handler-directive'; import { fireHASSEvent } from '../src/ha/fire-hass-event'; import type { ActionHandlerDetail } from '../src/ha/types'; @@ -23,7 +24,9 @@ const getActionHandler = (): ActionHandlerInterface => { return el as ActionHandlerInterface; }; -const createBoundElement = (options?: Record): HTMLElement => { +const createBoundElement = ( + options?: AdvancedCameraCardActionHandlerOptions, +): HTMLElement => { const handler = getActionHandler(); const element = document.createElement('div'); handler.bind(element, options); diff --git a/tests/camera-manager/manager.test.ts b/tests/camera-manager/manager.test.ts index 0b46b7f5..66fe32f7 100644 --- a/tests/camera-manager/manager.test.ts +++ b/tests/camera-manager/manager.test.ts @@ -27,6 +27,9 @@ import { type EventQueryResults, type MediaMetadata, type QueryResults, + type RecordingQuery, + type RecordingSegmentsQuery, + type ReviewQuery, } from '../../src/camera-manager/types.js'; import type { CardController } from '../../src/card-controller/controller.js'; import type { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js'; @@ -752,46 +755,65 @@ describe('CameraManager', () => { }); describe('generate default queries', () => { - it.each([ - [ - 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(); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); + const setupManagerWithEngine = async () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - const engine = mock(); - const manager = createCameraManager(api, engine); - await manager.initializeCamerasFromConfig(); + const engine = mock(); + const manager = createCameraManager(api, engine); + await manager.initializeCamerasFromConfig(); - const queries = [{ type: queryType, cameraIDs: new Set(['id']) }]; - engine[engineMethodName].mockReturnValue(queries); - expect(manager[managerMethodName]('id')).toEqual(queries); - }, - ); + return { engine, manager }; + }; + + 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 () => { const api = createCardAPI(); diff --git a/tests/components-lib/media-actions-controller.test.ts b/tests/components-lib/media-actions-controller.test.ts index 8d968fb3..fa48960e 100644 --- a/tests/components-lib/media-actions-controller.test.ts +++ b/tests/components-lib/media-actions-controller.test.ts @@ -42,13 +42,18 @@ const getActionSpy = ( }; const createPlayerElement = (controller?: MediaPlayerController): MediaPlayerElement => { - const player = document.createElement('video'); - player['getMediaPlayerController'] = vi - .fn() - .mockResolvedValue( - controller ?? mock({ playback: mock() }), - ); - return player as unknown as MediaPlayerElement; + const player: MediaPlayerElement = Object.assign( + document.createElement('video'), + { + getMediaPlayerController: vi + .fn() + .mockResolvedValue( + controller ?? + mock({ playback: mock() }), + ), + }, + ); + return player; }; const createPlayerSlideNodes = (n = 10): HTMLElement[] => { @@ -914,13 +919,17 @@ describe('MediaActionsController', () => { // A player whose media player controller is not ready on first request. const mediaPlayerController = mock(); - const player = document.createElement('video'); - player['getMediaPlayerController'] = vi - .fn() - .mockResolvedValueOnce(null) - .mockResolvedValue(mediaPlayerController); + const player: MediaPlayerElement = Object.assign( + document.createElement('video'), + { + getMediaPlayerController: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValue(mediaPlayerController), + }, + ); const child = createTestSlideNodes({ n: 1 })[0]; - child.appendChild(player as unknown as MediaPlayerElement); + child.appendChild(player); controller.setRoot(createParent({ children: [child] })); await controller.setTarget(0, true); diff --git a/tsconfig.json b/tsconfig.json index 1353f18c..b61ef456 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "es2021", "module": "es2020", "moduleResolution": "bundler", + "allowJs": true, "verbatimModuleSyntax": true, "isolatedModules": true, "lib": ["es2021", "dom", "dom.iterable"], @@ -12,7 +13,6 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "strict": true, - "noImplicitAny": false, "skipLibCheck": true, "resolveJsonModule": true, "experimentalDecorators": true,