From b10b0e607213190fa81f7a5d66e00275acb21127 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 16 Aug 2022 21:27:10 -0700 Subject: [PATCH] Add native PTZ element. --- README.md | 83 ++++++++++++++++++- src/components/elements.ts | 144 ++++++++++++++++++++++++++++++--- src/localize/languages/en.json | 11 +++ src/scss/elements-ptz.scss | 135 +++++++++++++++++++++++++++++++ src/types.ts | 42 ++++++++++ 5 files changed, 401 insertions(+), 14 deletions(-) create mode 100644 src/scss/elements-ptz.scss diff --git a/README.md b/README.md index 71a81f71..ddb828e2 100644 --- a/README.md +++ b/README.md @@ -803,6 +803,7 @@ elements to add special Frigate card functionality. | `custom:frigate-card-menu-submenu` | Add a configurable submenu dropdown. See [configuration below](#frigate-card-menu-submenu).| | `custom:frigate-card-menu-submenu-select` | Add a submenu based on a `select` or `input_select`. See [configuration below](#frigate-card-submenu-select).| | `custom:frigate-card-conditional` | Restrict a set of elements to only render when the card is showing particular a particular [view](#views). See [configuration below](#frigate-card-conditional).| +| `custom:frigate-card-ptz` | Add a PTZ (Pan Tilt Zoom) controller overlay. See [configuration below](#frigate-card-ptz).| **Note**: ℹ️ Manual positioning of custom menu icons or submenus via the `style` @@ -864,6 +865,19 @@ Parameters for the `custom:frigate-card-conditional` element: `elements` | The elements to render. Can be any supported element, include additional condition or custom elements. | | `conditions` | A set of conditions that must evaluate to true in order for the elements to be rendered. See [Frigate Card Conditions](#frigate-card-conditions). | +#### `custom:frigate-card-ptz` + +Parameters for the `custom:frigate-card-ptz` element: + +| Parameter | Default | Description | +| ------------- | - | -------------------------------------------- | +| `type` | | Must be `custom:frigate-card-ptz`. | +| `style` | `translate(-50%, -50%)` | Position and style the element using CSS. See [Picture Element styling](https://www.home-assistant.io/dashboards/picture-elements/#how-to-use-the-style-object). | +| `mode` | `vertical` | Whether to show a `vertical` or `horizontal` PTZ control. | +| `actions_left`, `actions_right`, `actions_up`, `actions_down`, `actions_zoom_in`, `actions_zoom_out`, `actions_home` | The [Home Assistant actions](https://www.home-assistant.io/dashboards/actions/) to call when this icon is interacted with. | +| `data_left`, `data_right`, `data_up`, `data_down`, `data_zoom_in`, `data_zoom_out`, `data_home` | Shorthand for a `tap_action` that calls the `service` with the data provided in this argument. Internally, this is just translated into the longer-form `actions_[button]`. If both `actions_X` and `data_X` are specified, `actions_X` takes priority. This is compatible with [AlexxIT's WebRTC Card PTZ configuration](https://github.com/AlexxIT/WebRTC/wiki/PTZ-Config-Examples). | +| `service` | | An optional Home Assistant service to call when the `data_` parameters are used. | + ### Special Actions #### `custom:frigate-card-action` @@ -1663,6 +1677,31 @@ elements: state: on state_not: off mediaLoaded: true + # Full form PTZ actions (only left button shown). + - type: custom:frigate-card-ptz + mode: vertical + style: + transform: none + right: 20px + top: 180px + actions_left: + tap_action: + action: call-service + service: sonoff.send_command + service_data: + device: '048123' + cmd: left + # Equivalent short form PTZ actions (only left button shown) + - type: custom:frigate-card-ptz + mode: vertical + style: + transform: none + right: 20px + top: 180px + service: sonoff.send_command + data_left: + device: '048123' + cmd: left ``` @@ -2147,7 +2186,7 @@ You can restrict icons to only show for certain [views](#views) using a `custom:frigate-card-conditional` element (e.g. PTZ controls)
- Expand: View-based conditions (e.g. PTZ controls) + Expand: View-based conditions (e.g. custom PTZ controls) This example shows PTZ icons that call a PTZ service, but only in the `live` view. @@ -2491,7 +2530,6 @@ overrides: ```
- ### Refreshing a static image
@@ -2532,6 +2570,47 @@ elements: ```
+### Using a PTZ picture element + +The card supports a custom PTZ element (`custom:frigate-card-ptz`) to conveniently control pan, tilt and zoom for cameras. + +
+ Expand: Using the native PTZ picture element + +This example shows the native PTZ element when the `live` or `image` view is displayed and the stream (media) has loaded. + +```yaml +[...] +elements: + - type: custom:frigate-card-conditional + conditions: + mediaLoaded: true + view: + - live + - image + elements: + - type: custom:frigate-card-ptz + mode: horizontal + style: + transform: none + right: 20px + top: 180px + service: sonoff.send_command + data_left: + device: '048123' + cmd: left + data_right: + device: '048123' + cmd: right + data_up: + device: '048123' + cmd: up + data_down: + device: '048123' + cmd: down +``` +
+ ### Using `card-mod` to style the card diff --git a/src/components/elements.ts b/src/components/elements.ts index e09c48ef..6061e181 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -1,28 +1,39 @@ -import { HomeAssistant } from 'custom-card-helpers'; +import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, html, LitElement, PropertyValues, TemplateResult, - unsafeCSS + unsafeCSS, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { ConditionState, fetchStateAndEvaluateCondition } from '../card-condition.js'; import { localize } from '../localize/localize.js'; import elementsStyle from '../scss/elements.scss'; +import ptzStyle from '../scss/elements-ptz.scss'; import { + Actions, + ActionsConfig, FrigateCardError, + FrigateCardPTZConfig, FrigateConditional, MenuButton, MenuIcon, MenuStateIcon, MenuSubmenu, MenuSubmenuSelect, - PictureElements + PictureElements, } from '../types.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardErrorEvent } from './message.js'; +import { actionHandler } from '../action-handler-directive.js'; +import { + frigateCardHandleActionConfig, + frigateCardHasAction, + getActionConfigGivenAction, +} from '../utils/action.js'; +import { classMap } from 'lit/directives/class-map.js'; /* A note on picture element rendering: * @@ -343,14 +354,123 @@ export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuI @customElement('frigate-card-menu-submenu-select') export class FrigateCardElementsMenuSubmenuSelect extends FrigateCardElementsBaseMenuIcon {} +@customElement('frigate-card-ptz') +export class FrigateCardPTZ extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @state() + protected _config: FrigateCardPTZConfig | null = null; + + /** + * Set the card config. + * @param config The configuration. + */ + public setConfig(config: FrigateCardPTZConfig): void { + this._config = config; + } + + /** + * Called before each update. + */ + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('_config')) { + this.setAttribute('data-orientation', this._config?.orientation ?? 'vertical'); + } + } + + /** + * Handle a PTZ action. + * @param ev The actionHandler event. + * @param config The action configuration. + */ + protected _actionHandler( + ev: HASSDomEvent<{ action: string }>, + config?: ActionsConfig, + ): void { + // Nothing else has the configuration for this action, so don't let it + // propagate further. + ev.stopPropagation(); + + const interaction: string = ev.detail.action; + const action = getActionConfigGivenAction(interaction, config); + if (config && action && this.hass) { + frigateCardHandleActionConfig(this, this.hass, config, interaction, action); + } + } + + /** + * Render the elements. + * @returns A rendered template or void. + */ + protected render(): TemplateResult | void { + if (!this._config) { + return; + } + const renderIcon = ( + name: string, + icon: string, + actions?: Actions, + ): TemplateResult => { + const hasHold = frigateCardHasAction(actions?.hold_action); + const hasDoubleClick = frigateCardHasAction(actions?.double_tap_action); + const classes = { + [name]: true, + disabled: !actions, + }; + + return html` this._actionHandler(ev, actions)} + >`; + }; + + return html`
+
+ ${renderIcon('right', 'mdi:arrow-right', this._config.actions_right)} + ${renderIcon('left', 'mdi:arrow-left', this._config.actions_left)} + ${renderIcon('up', 'mdi:arrow-up', this._config.actions_up)} + ${renderIcon('down', 'mdi:arrow-down', this._config.actions_down)} +
+ ${this._config.actions_zoom_in || this._config.actions_zoom_out + ? html`
+ ${renderIcon('zoom_in', 'mdi:plus', this._config.actions_zoom_in)} + ${renderIcon('zoom_out', 'mdi:minus', this._config.actions_zoom_out)} +
` + : html``} + ${this._config.actions_home + ? html` +
+ ${renderIcon('home', 'mdi:home', this._config.actions_home)} +
+ ` + : html``} +
`; + } + + /** + * Return compiled CSS styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(ptzStyle); + } +} + declare global { - interface HTMLElementTagNameMap { - "frigate-card-conditional": FrigateCardElementsConditional - "frigate-card-elements": FrigateCardElements - "frigate-card-menu-submenu-select": FrigateCardElementsMenuSubmenuSelect - "frigate-card-menu-submenu": FrigateCardElementsMenuSubmenu - "frigate-card-menu-state-icon": FrigateCardElementsMenuStateIcon - "frigate-card-menu-icon": FrigateCardElementsMenuIcon - "frigate-card-elements-core": FrigateCardElementsCore - } + interface HTMLElementTagNameMap { + 'frigate-card-conditional': FrigateCardElementsConditional; + 'frigate-card-elements': FrigateCardElements; + 'frigate-card-menu-submenu-select': FrigateCardElementsMenuSubmenuSelect; + 'frigate-card-menu-submenu': FrigateCardElementsMenuSubmenu; + 'frigate-card-menu-state-icon': FrigateCardElementsMenuStateIcon; + 'frigate-card-menu-icon': FrigateCardElementsMenuIcon; + 'frigate-card-elements-core': FrigateCardElementsCore; + 'frigate-card-ptz': FrigateCardPTZ; + } } diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index edd0f093..fb13f480 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -372,5 +372,16 @@ "no_thumbnail": "No thumbnail available", "retain_indefinitely": "Event will be indefinitely retained", "timeline": "See event in timeline" + }, + "elements": { + "ptz": { + "up": "Up", + "down": "Down", + "left": "Left", + "right": "Right", + "zoom_in": "Zoom In", + "zoom_out": "Zoom Out", + "home": "Home" + } } } diff --git a/src/scss/elements-ptz.scss b/src/scss/elements-ptz.scss new file mode 100644 index 00000000..34bfbb4a --- /dev/null +++ b/src/scss/elements-ptz.scss @@ -0,0 +1,135 @@ +// Modified from / inspired by: +// https://github.com/AlexxIT/WebRTC/blob/master/custom_components/webrtc/www/webrtc-camera.js +:host { + position: relative; + + width: fit-content; + height: fit-content; + + --frigate-card-ptz-icon-size: 24px; +} + +/***************** + * Main Containers + *****************/ +.ptz { + display: flex; + gap: 10px; + + color: var(--light-primary-color); + opacity: 0.4; + + transition: opacity 0.3s ease-in-out; +} + +:host([data-orientation='vertical']) .ptz { + flex-direction: column; +} +:host([data-orientation='horizontal']) .ptz { + flex-direction: row; +} + +.ptz:hover { + opacity: 1; +} + +:host([data-orientation='vertical']) .ptz div { + width: calc(var(--frigate-card-ptz-icon-size) * 3); +} +:host([data-orientation='horizontal']) .ptz div { + height: calc(var(--frigate-card-ptz-icon-size) * 3); +} + +.ptz-move, +.ptz-zoom, +.ptz-home { + position: relative; + background-color: rgba(0, 0, 0, 0.3); +} + +.ptz-move { + height: calc(var(--frigate-card-ptz-icon-size) * 3); + width: calc(var(--frigate-card-ptz-icon-size) * 3); + + border-radius: 50%; +} + +:host([data-orientation='horizontal']) .ptz .ptz-zoom, +:host([data-orientation='horizontal']) .ptz .ptz-home { + width: calc(var(--frigate-card-ptz-icon-size) * 1.5); +} +:host([data-orientation='vertical']) .ptz .ptz-zoom, +:host([data-orientation='vertical']) .ptz .ptz-home { + height: calc(var(--frigate-card-ptz-icon-size) * 1.5); +} + +.ptz-zoom, +.ptz-home { + border-radius: var(--ha-card-border-radius, 4px); +} + +/*********** + * PTZ Icons + ***********/ +ha-icon { + position: absolute; + --mdc-icon-size: var(--frigate-card-ptz-icon-size); +} +ha-icon:not(.disabled) { + cursor: pointer; +} +.disabled { + color: var(--disabled-text-color); +} +.up { + top: 5px; + left: 50%; + transform: translateX(-50%); +} +.down { + bottom: 5px; + left: 50%; + transform: translateX(-50%); +} +.left { + left: 5px; + top: 50%; + transform: translateY(-50%); +} +.right { + right: 5px; + top: 50%; + transform: translateY(-50%); +} + +:host([data-orientation='vertical']) .zoom_in { + right: 5px; + top: 50%; +} +:host([data-orientation='vertical']) .zoom_out { + left: 5px; + top: 50%; +} +:host([data-orientation='horizontal']) .zoom_in { + left: 50%; + top: 5px; +} +:host([data-orientation='horizontal']) .zoom_out { + left: 50%; + bottom: 5px; +} + +:host([data-orientation='vertical']) .zoom_in, +:host([data-orientation='vertical']) .zoom_out { + transform: translateY(-50%); +} +:host([data-orientation='horizontal']) .zoom_in, +:host([data-orientation='horizontal']) .zoom_out { + transform: translateX(-50%); +} + +.home { + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); +} diff --git a/src/types.ts b/src/types.ts index a7d4046c..9749f550 100644 --- a/src/types.ts +++ b/src/types.ts @@ -499,6 +499,47 @@ const frigateConditionalSchema = z.object({ }); export type FrigateConditional = z.infer; +const frigateCardPTZSchema = z.preprocess( + // To avoid lots of YAML duplication, provide an easy way to just specify the + // service data as actions for each PTZ icon, and it will be preprocessed into + // the full form. This also provides compatability with the AlexIT/WebRTC PTZ + // configuration. + (data) => { + if (!data || typeof data !== 'object' || !data['service']) { + return data; + } + const out = { ...data }; + ['left', 'right', 'up', 'down', 'zoom_in', 'zoom_out', 'home'].forEach((name) => { + if (`data_${name}` in data && !(`actions_${name}` in data)) { + out[`actions_${name}`] = { + tap_action: { + action: 'call-service', + service: data['service'], + service_data: data[`data_${name}`], + }, + }; + delete out[`data_${name}`]; + } + }); + return out; + }, + z.object({ + type: z.literal('custom:frigate-card-ptz'), + style: z.object({}).passthrough().optional(), + orientation: z.enum(['vertical', 'horizontal']).default('vertical').optional(), + service: z.string().optional(), + actions_left: actionsBaseSchema.optional(), + actions_right: actionsBaseSchema.optional(), + actions_up: actionsBaseSchema.optional(), + actions_down: actionsBaseSchema.optional(), + actions_zoom_in: actionsBaseSchema.optional(), + actions_zoom_out: actionsBaseSchema.optional(), + actions_home: actionsBaseSchema.optional(), + }), +); + +export type FrigateCardPTZConfig = z.infer; + // Cannot use discriminatedUnion since customSchema uses a superRefine, which // causes false rejections. const pictureElementSchema = z.union([ @@ -507,6 +548,7 @@ const pictureElementSchema = z.union([ menuSubmenuSchema, menuSubmenuSelectSchema, frigateConditionalSchema, + frigateCardPTZSchema, stateBadgeIconSchema, stateIconSchema, stateLabelSchema,