diff --git a/.devcontainer/ui-lovelace.yaml b/.devcontainer/ui-lovelace.yaml index 1d4526bb..d24ca157 100644 --- a/.devcontainer/ui-lovelace.yaml +++ b/.devcontainer/ui-lovelace.yaml @@ -1,9 +1,7 @@ resources: - - url: http://127.0.0.1:5000/boilerplate-card.js + - url: http://127.0.0.1:5000/frigate-card.js type: module views: - cards: - - type: custom:boilerplate-card - name: Boilerplate Card Development - entity: sun.sun - test_gui: true + - type: custom:frigate-card + name: Frigate Card Development diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f61adf07..9d63b8f8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,12 +1,16 @@ +--- name: 'Build' on: push: branches: - master + - main + - dev pull_request: - branches: - - master + schedule: + - cron: "17 6 * * *" + workflow_dispatch: jobs: build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce76682f..72a5f64a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: # Build - name: Build the file run: | - cd /home/runner/work/boilerplate-card/boilerplate-card + cd /home/runner/work/frigate-card/frigate-card npm install npm run build @@ -24,7 +24,7 @@ jobs: with: repo_token: ${{ secrets.GITHUB_TOKEN }} - file: /home/runner/work/boilerplate-card/boilerplate-card/dist/boilerplate-card.js - asset_name: boilerplate-card.js + file: /home/runner/work/frigate-card/frigate-card/dist/frigate-card.js + asset_name: frigate-card.js tag: ${{ github.ref }} overwrite: true \ No newline at end of file diff --git a/README.md b/README.md index 61ba495c..3390aa1e 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,3 @@ -# Boilerplate Card by [@iantrich](https://www.github.com/iantrich) +# Frigate Lovelace Card -A community driven boilerplate of best practices for Home Assistant Lovelace custom cards - -[![GitHub Release][releases-shield]][releases] -[![License][license-shield]](LICENSE.md) -[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) - -![Project Maintenance][maintenance-shield] -[![GitHub Activity][commits-shield]][commits] - -[![Discord][discord-shield]][discord] -[![Community Forum][forum-shield]][forum] - -## Support - -Hey dude! Help me out for a couple of :beers: or a :coffee:! - -[![coffee](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://www.buymeacoffee.com/zJtVxUAgH) - -## Options - -| Name | Type | Requirement | Description | Default | -| ----------------- | ------- | ------------ | ------------------------------------------- | ------------------- | -| type | string | **Required** | `custom:boilerplate-card` | -| name | string | **Optional** | Card name | `Boilerplate` | -| show_error | boolean | **Optional** | Show what an error looks like for the card | `false` | -| show_warning | boolean | **Optional** | Show what a warning looks like for the card | `false` | -| entity | string | **Optional** | Home Assistant entity ID. | `none` | -| tap_action | object | **Optional** | Action to take on tap | `action: more-info` | -| hold_action | object | **Optional** | Action to take on hold | `none` | -| double_tap_action | object | **Optional** | Action to take on double tap | `none` | - -## Action Options - -| Name | Type | Requirement | Description | Default | -| --------------- | ------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| action | string | **Required** | Action to perform (more-info, toggle, call-service, navigate url, none) | `more-info` | -| navigation_path | string | **Optional** | Path to navigate to (e.g. /lovelace/0/) when action defined as navigate | `none` | -| url | string | **Optional** | URL to open on click when action is url. The URL will open in a new tab | `none` | -| service | string | **Optional** | Service to call (e.g. media_player.media_play_pause) when action defined as call-service | `none` | -| service_data | object | **Optional** | Service data to include (e.g. entity_id: media_player.bedroom) when action defined as call-service | `none` | -| haptic | string | **Optional** | Haptic feedback for the [Beta IOS App](http://home-assistant.io/ios/beta) _success, warning, failure, light, medium, heavy, selection_ | `none` | -| repeat | number | **Optional** | How often to repeat the `hold_action` in milliseconds. | `non` | - -## Starting a new card from boilerplate-card - -### Step 1 - -Clone this repository - -### Step 2 - -Install necessary modules (verified to work in node 8.x) -`yarn install` or `npm install` - -### Step 3 - -Do a test lint & build on the project. You can see available scripts in the package.json -`npm run build` - -### Step 4 - -Search the repository for all instances of "TODO" and handle the changes/suggestions - -### Step 5 - -Customize to suit your needs and contribute it back to the community - -## Starting a new card from boilerplate-card with [devcontainer][devcontainer] - -Note: this is available only in vscode ensure you have the [Remote Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension installed. - -1. Fork and clone the repository. -2. Open a [devcontainer][devcontainer] terminal and run `npm start` when it's ready. -3. The compiled `.js` file will be accessible on - `http://127.0.0.1:5000/boilerplate-card.js`. -4. On a running Home Assistant installation add this to your Lovelace - `resources:` - -```yaml -- url: 'http://127.0.0.1:5000/boilerplate-card.js' - type: module -``` - -_Change "127.0.0.1" to the IP of your development machine._ - -### Bonus - -If you need a fresh test instance you can install a fresh Home Assistant instance inside the devcontainer as well. - -1. Run the command `container start`. -2. Home Assistant will install and will eventually be running on port `9123` - -## [Troubleshooting](https://github.com/thomasloven/hass-config/wiki/Lovelace-Plugins) - -NB This will not work with node 9.x if you see the following errors try installing node 8.10.0 - -```yarn install -yarn install v1.3.2 -[1/4] 🔍 Resolving packages... -warning rollup-plugin-commonjs@10.1.0: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-commonjs. -[2/4] 🚚 Fetching packages... -error @typescript-eslint/eslint-plugin@2.6.0: The engine "node" is incompatible with this module. Expected version "^8.10.0 || ^10.13.0 || >=11.10.1". -error Found incompatible module -info Visit https://yarnpkg.com/en/docs/cli/install for documentation about this command. -``` - -[commits-shield]: https://img.shields.io/github/commit-activity/y/custom-cards/boilerplate-card.svg?style=for-the-badge -[commits]: https://github.com/custom-cards/boilerplate-card/commits/master -[devcontainer]: https://code.visualstudio.com/docs/remote/containers -[discord]: https://discord.gg/5e9yvq -[discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge -[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge -[forum]: https://community.home-assistant.io/c/projects/frontend -[license-shield]: https://img.shields.io/github/license/custom-cards/boilerplate-card.svg?style=for-the-badge -[maintenance-shield]: https://img.shields.io/maintenance/yes/2020.svg?style=for-the-badge -[releases-shield]: https://img.shields.io/github/release/custom-cards/boilerplate-card.svg?style=for-the-badge -[releases]: https://github.com/custom-cards/boilerplate-card/releases +A pre-alpha Frigate Lovelace card. \ No newline at end of file diff --git a/hacs.json b/hacs.json index 6f4c662b..64f04cf4 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { - "name": "Boilerplate Card", + "name": "Frigate Card", "render_readme": true, - "filename": "boilerplate-card.js" -} + "filename": "frigate-card.js" +} \ No newline at end of file diff --git a/package.json b/package.json index 7536c891..eef11833 100644 --- a/package.json +++ b/package.json @@ -1,47 +1,50 @@ { - "name": "boilerplate-card", - "version": "1.3.2", - "description": "Lovelace boilerplate-card", + "name": "frigate-hass-card", + "version": "0.0.1", + "description": "Frigate Lovelace Card for Home Assistant", "keywords": [ + "frigate", "home-assistant", "homeassistant", "hass", - "automation", - "lovelace", - "custom-cards" + "lovelace" ], - "module": "boilerplate-card.js", - "repository": "git@github.com:custom_cards/boilerplate-card.git", - "author": "Ian Richardson ", + "module": "frigate-card.js", + "repository": "git@github.com:dermotduffy/frigate-hass-card.git", + "author": "Dermot Duffy ", "license": "MIT", "dependencies": { - "custom-card-helpers": "^1.6.6", - "home-assistant-js-websocket": "^4.5.0", - "lit-element": "^2.4.0", - "lit-html": "^1.3.0" + "@material/image-list": "^12.0.0", + "custom-card-helpers": "^1.7.1", + "home-assistant-js-websocket": "^5.11.1", + "lit-element": "^2.5.1", + "lit-html": "^1.4.1" }, "devDependencies": { - "@babel/core": "^7.12.3", - "@babel/plugin-proposal-class-properties": "^7.12.1", - "@babel/plugin-proposal-decorators": "^7.12.1", + "@babel/core": "^7.15.0", + "@babel/plugin-proposal-class-properties": "^7.14.5", + "@babel/plugin-proposal-decorators": "^7.14.5", "@rollup/plugin-json": "^4.1.0", - "@typescript-eslint/eslint-plugin": "^2.34.0", - "@typescript-eslint/parser": "^2.34.0", - "eslint": "^6.8.0", + "@typescript-eslint/eslint-plugin": "^4.29.0", + "@typescript-eslint/parser": "^4.29.0", + "eslint": "^7.32.0", "eslint-config-airbnb-base": "^14.2.1", - "eslint-config-prettier": "^6.15.0", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-prettier": "^3.1.4", - "prettier": "^1.19.1", - "rollup": "^1.32.1", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-import": "^2.23.4", + "eslint-plugin-prettier": "^3.4.0", + "npm-check-updates": "^11.8.3", + "prettier": "^2.3.2", + "rollup": "^2.56.0", + "rollup-plugin-styles": "^3.14.1", "rollup-plugin-babel": "^4.4.0", "rollup-plugin-commonjs": "^10.1.0", "rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-serve": "^1.1.0", - "rollup-plugin-terser": "^5.3.1", - "rollup-plugin-typescript2": "^0.24.3", + "rollup-plugin-terser": "^7.0.2", + "rollup-plugin-typescript2": "^0.30.0", "rollup-plugin-uglify": "^6.0.4", - "typescript": "^3.9.7" + "sass": "^1.37.5", + "typescript": "^4.3.5" }, "scripts": { "start": "rollup -c rollup.config.dev.js --watch", @@ -49,4 +52,4 @@ "lint": "eslint src/*.ts", "rollup": "rollup -c" } -} \ No newline at end of file +} diff --git a/rollup.config.dev.js b/rollup.config.dev.js deleted file mode 100644 index 6a680247..00000000 --- a/rollup.config.dev.js +++ /dev/null @@ -1,32 +0,0 @@ -import resolve from "rollup-plugin-node-resolve"; -import typescript from "rollup-plugin-typescript2"; -import babel from "rollup-plugin-babel"; -import serve from "rollup-plugin-serve"; -import { terser } from "rollup-plugin-terser"; -import json from '@rollup/plugin-json'; - -export default { - input: ["src/boilerplate-card.ts"], - output: { - dir: "./dist", - format: "es", - }, - plugins: [ - resolve(), - typescript(), - json(), - babel({ - exclude: "node_modules/**", - }), - terser(), - serve({ - contentBase: "./dist", - host: "0.0.0.0", - port: 5000, - allowCrossOrigin: true, - headers: { - "Access-Control-Allow-Origin": "*", - }, - }), - ], -}; diff --git a/rollup.config.js b/rollup.config.js index 5586c3cb..1b2c1315 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -5,6 +5,7 @@ import babel from 'rollup-plugin-babel'; import { terser } from 'rollup-plugin-terser'; import serve from 'rollup-plugin-serve'; import json from '@rollup/plugin-json'; +import styles from "rollup-plugin-styles"; const dev = process.env.ROLLUP_WATCH; @@ -19,6 +20,12 @@ const serveopts = { }; const plugins = [ + styles({ + modules: false, + sass: { + includePaths: ["./node_modules/"] + } + }), nodeResolve({}), commonjs(), typescript(), @@ -32,7 +39,7 @@ const plugins = [ export default [ { - input: 'src/boilerplate-card.ts', + input: 'src/frigate-card.ts', output: { dir: 'dist', format: 'es', diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index 5c0bae8f..addf7b93 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -161,16 +161,15 @@ class ActionHandler extends HTMLElement implements ActionHandler { } } -// TODO You need to replace all instances of "action-handler-boilerplate" with "action-handler-" -customElements.define('action-handler-boilerplate', ActionHandler); +customElements.define('action-handler-frigate-card', ActionHandler); const getActionHandler = (): ActionHandler => { const body = document.body; - if (body.querySelector('action-handler-boilerplate')) { - return body.querySelector('action-handler-boilerplate') as ActionHandler; + if (body.querySelector('action-handler-frigate-card')) { + return body.querySelector('action-handler-frigate-card') as ActionHandler; } - const actionhandler = document.createElement('action-handler-boilerplate'); + const actionhandler = document.createElement('action-handler-frigate-card'); body.appendChild(actionhandler); return actionhandler as ActionHandler; diff --git a/src/boilerplate-card.ts b/src/boilerplate-card.ts deleted file mode 100644 index c399cc9e..00000000 --- a/src/boilerplate-card.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - LitElement, - html, - customElement, - property, - CSSResult, - TemplateResult, - css, - PropertyValues, - internalProperty, -} from 'lit-element'; -import { - HomeAssistant, - hasConfigOrEntityChanged, - hasAction, - ActionHandlerEvent, - handleAction, - LovelaceCardEditor, - getLovelace, -} from 'custom-card-helpers'; // This is a community maintained npm module with common helper functions/types - -import './editor'; - -import type { BoilerplateCardConfig } from './types'; -import { actionHandler } from './action-handler-directive'; -import { CARD_VERSION } from './const'; -import { localize } from './localize/localize'; - -/* eslint no-console: 0 */ -console.info( - `%c BOILERPLATE-CARD \n%c ${localize('common.version')} ${CARD_VERSION} `, - 'color: orange; font-weight: bold; background: black', - 'color: white; font-weight: bold; background: dimgray', -); - -// This puts your card into the UI card picker dialog -(window as any).customCards = (window as any).customCards || []; -(window as any).customCards.push({ - type: 'boilerplate-card', - name: 'Boilerplate Card', - description: 'A template custom card for you to create something awesome', -}); - -// TODO Name your custom element -@customElement('boilerplate-card') -export class BoilerplateCard extends LitElement { - public static async getConfigElement(): Promise { - return document.createElement('boilerplate-card-editor'); - } - - public static getStubConfig(): object { - return {}; - } - - // TODO Add any properities that should cause your element to re-render here - // https://lit-element.polymer-project.org/guide/properties - @property({ attribute: false }) public hass!: HomeAssistant; - @internalProperty() private config!: BoilerplateCardConfig; - - // https://lit-element.polymer-project.org/guide/properties#accessors-custom - public setConfig(config: BoilerplateCardConfig): void { - // TODO Check for required fields and that they are of the proper format - if (!config) { - throw new Error(localize('common.invalid_configuration')); - } - - if (config.test_gui) { - getLovelace().setEditMode(true); - } - - this.config = { - name: 'Boilerplate', - ...config, - }; - } - - // https://lit-element.polymer-project.org/guide/lifecycle#shouldupdate - protected shouldUpdate(changedProps: PropertyValues): boolean { - if (!this.config) { - return false; - } - - return hasConfigOrEntityChanged(this, changedProps, false); - } - - // https://lit-element.polymer-project.org/guide/templates - protected render(): TemplateResult | void { - // TODO Check for stateObj or other necessary things and render a warning if missing - if (this.config.show_warning) { - return this._showWarning(localize('common.show_warning')); - } - - if (this.config.show_error) { - return this._showError(localize('common.show_error')); - } - - return html` - - `; - } - - private _handleAction(ev: ActionHandlerEvent): void { - if (this.hass && this.config && ev.detail.action) { - handleAction(this, this.hass, this.config, ev.detail.action); - } - } - - private _showWarning(warning: string): TemplateResult { - return html` - ${warning} - `; - } - - private _showError(error: string): TemplateResult { - const errorCard = document.createElement('hui-error-card'); - errorCard.setConfig({ - type: 'error', - error, - origConfig: this.config, - }); - - return html` - ${errorCard} - `; - } - - // https://lit-element.polymer-project.org/guide/styles - static get styles(): CSSResult { - return css``; - } -} diff --git a/src/const.ts b/src/const.ts index 2e5cc802..59fb347d 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1 +1 @@ -export const CARD_VERSION = '1.3.2'; +export const CARD_VERSION = '0.0.1'; \ No newline at end of file diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 00000000..caaba6a2 --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1 @@ +declare module '*.scss'; \ No newline at end of file diff --git a/src/editor.ts b/src/editor.ts index 639ffd28..686db6d9 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/camelcase */ import { LitElement, html, @@ -8,11 +7,11 @@ import { TemplateResult, CSSResult, css, - internalProperty, + state, } from 'lit-element'; import { HomeAssistant, fireEvent, LovelaceCardEditor, ActionConfig } from 'custom-card-helpers'; -import { BoilerplateCardConfig } from './types'; +import { FrigateCardConfig } from './types'; const options = { required: { @@ -55,15 +54,15 @@ const options = { }, }; -@customElement('boilerplate-card-editor') -export class BoilerplateCardEditor extends LitElement implements LovelaceCardEditor { +@customElement('frigate-card-editor') +export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { @property({ attribute: false }) public hass?: HomeAssistant; - @internalProperty() private _config?: BoilerplateCardConfig; - @internalProperty() private _toggle?: boolean; - @internalProperty() private _helpers?: any; + @state() private _config?: FrigateCardConfig; + @state() private _toggle?: boolean; + @state() private _helpers?: any; private _initialized = false; - public setConfig(config: BoilerplateCardConfig): void { + public setConfig(config: FrigateCardConfig): void { this._config = config; this.loadCardHelpers(); diff --git a/src/frigate-card.scss b/src/frigate-card.scss new file mode 100644 index 00000000..a53b2e6f --- /dev/null +++ b/src/frigate-card.scss @@ -0,0 +1,99 @@ +@use "@material/image-list/mdc-image-list"; +@use "@material/image-list"; + +.frigate-card-viewer { + width: 100%; + height: 100%; + position: absolute; +} + +.frigate-card-gallery { + overflow: auto; + position: absolute; + + left: 55px; + right: 0px; + height: 100%; + + -ms-overflow-style: none; // Hide scrollbar: IE and Edge + scrollbar-width: none; // Hide scrollbar: Firefox +} + +.frigate-card-exception { + display: flex; + justify-content: center; + align-items: center; + height: 100%; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +.frigate-card-gallery::-webkit-scrollbar { + display: none; +} + +.frigate-card-image-list { + @include image-list.standard-columns(4); + @include image-list.shape-radius(5px); +} + +.frigate-card-image-list-icon-overlay { + position: absolute; + margin: auto; +} + +.frigate-card-image-list-highlight { + // Put border inside. + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + + opacity: 0.5; + + border-style: dashed; + border-width: 1px; + border-color: var(--primary-color); +} + +.frigate-card-container { + position: relative; + overflow: hidden; +} + +.frigate-card-navbar { + position: absolute; + width: 50px; + z-index: 1; +} + +.frigate-card-statusbar { + @extend .frigate-card-navbar; + right: 3px; +} + +ha-icon-button.button { + position: relative; + z-index: 2; + opacity: 50%; + background-color: rgba(0, 0, 0, 0.3); + border-radius: 50%; + padding: 0px; + margin: 3px; +} + +.invisible { + visibility: hidden; +} +.visibile { + visibility: visible; +} + +ha-card { + display: flex; + justify-content: center; + flex-direction: column; + margin: auto; + overflow: hidden; + width: 100%; + height: 100%; + position: relative; +} \ No newline at end of file diff --git a/src/frigate-card.ts b/src/frigate-card.ts new file mode 100644 index 00000000..8ff16032 --- /dev/null +++ b/src/frigate-card.ts @@ -0,0 +1,443 @@ +// TODO: Can I use ajv (https://ajv.js.org/guide/typescript.html) to verify +// event return matches the TS interface? + +// TODO Does each event contain thumbnail? + +// TODO comments per method. + +// TODO: Stop the live view video when I hide it. + +// TODO Check for HA state presence and validity before using it, otherwise warn. + +// TODO Add material tooltips + + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + LitElement, + html, + customElement, + property, + CSSResult, + TemplateResult, + PropertyValues, + state, + unsafeCSS, +} from 'lit-element'; + +import { until } from 'lit-html/directives/until.js'; +import { + HomeAssistant, + ActionHandlerEvent, + handleAction, + LovelaceCardEditor, + getLovelace, +} from 'custom-card-helpers'; + +import './editor'; + +import style from './frigate-card.scss' + +import type { FrigateCardConfig, FrigateEvent, GetEventsParameters } from './types'; +import { actionHandler } from './action-handler-directive'; +import { CARD_VERSION } from './const'; +import { localize } from './localize/localize'; + +/* eslint no-console: 0 */ +console.info( + `%c FRIGATE-HASS-CARD \n%c ${localize('common.version')} ${CARD_VERSION} `, + 'color: pink; font-weight: bold; background: black', + 'color: white; font-weight: bold; background: dimgray', +); + +// This puts your card into the UI card picker dialog +(window as any).customCards = (window as any).customCards || []; +(window as any).customCards.push({ + type: 'frigate-card', + name: 'Frigate Card', + description: 'A lovelace card for use with Frigate', +}); + +enum FrigateCardView { + LIVE, // Show the live camera. + CLIP, // Show a clip video. + CLIPS, // Show the clips gallery. + SNAPSHOT, // Show a snapshot. + SNAPSHOTS, // Show the snapshots gallery. +} + +@customElement('frigate-card') +export class FrigateCard extends LitElement { + constructor() { + super(); + this._viewMode = FrigateCardView.LIVE; + this._viewEvent = null; + this._interactionTimerID = null; + } + + public static async getConfigElement(): Promise { + return document.createElement('frigate-card-editor'); + } + + public static getStubConfig(): Record { + return {}; + } + + @property({ attribute: false }) + public hass!: HomeAssistant; + + @state() + public config!: FrigateCardConfig; + + @property({ attribute: false }) + protected _viewMode: FrigateCardView; + + @property({ attribute: false }) + protected _viewEvent: FrigateEvent | null; + + protected _interactionTimerID: number | null; + + public setConfig(inputConfig: FrigateCardConfig): void { + if (!inputConfig) { + throw new Error(localize('common.invalid_configuration:')); + } + // inputConfig is not extensible, need to make a copy to allow + // modifications. + const cardConfig = Object.assign({ + name: 'Frigate' + }, inputConfig); + + if (cardConfig.test_gui) { + getLovelace().setEditMode(true); + } + + if (!cardConfig.frigate_url) { + throw new Error(localize('common.invalid_configuration_missing') + ": frigate_url"); + } + + if (!cardConfig.frigate_camera_name) { + // No camera name specified, so just assume it's the same as the entity name. + if (cardConfig.camera_entity.includes(".")) { + cardConfig.frigate_camera_name = cardConfig.camera_entity.split('.', 2)[1] + } else { + throw new Error(localize('common.invalid_configuration_missing') + ": camera"); + } + } + + if (cardConfig.timeout_ms) { + if (isNaN(Number(cardConfig.timeout_ms))) { + throw new Error(localize('common.invalid_configuration') + ": timeout_ms"); + } + } + + if (cardConfig.default_view) { + if (!["live", "clips", "clip", "snapshots", "snapshot"].includes(cardConfig.default_view)) { + throw new Error(localize('common.invalid_configuration') + ": default_view"); + } + } + + this.config = cardConfig; + this._setViewModeToDefault(); + } + + protected _setViewModeToDefault(): void { + if (this.config.default_view == "live") { + this._viewMode = FrigateCardView.LIVE; + } else if (this.config.default_view == "clips") { + this._viewMode = FrigateCardView.CLIPS; + } else if (this.config.default_view == "clip") { + this._viewMode = FrigateCardView.CLIP; + } else if (this.config.default_view == "snapshots") { + this._viewMode = FrigateCardView.SNAPSHOTS; + } else if (this.config.default_view == "snapshot") { + this._viewMode = FrigateCardView.SNAPSHOT; + } + } + + // == RTC experimentation == + // const div = document.createElement("div"); + // const webrtcElement = customElements.get('webrtc-camera'); + // const webrtc = new webrtcElement(); + // webrtc.setConfig({ "entity": "camera.landing_rtsp" }); + // webrtc.hass = this.hass; + // div.appendChild(webrtc); + // this.renderRoot.appendChild(div); + // == + + protected shouldUpdate(changedProps: PropertyValues): boolean { + if (!this.config || !this.hass) { + return false; + } + + const cameraEntity = this.config.camera_entity; + const motionEntity = this.config.motion_entity; + + if (!cameraEntity) { + return false; + } + + if (changedProps.has('config')) { + return true; + } + + const oldHass = changedProps.get('hass') as HomeAssistant | undefined; + + if (oldHass) { + if (oldHass.states[cameraEntity] !== this.hass.states[cameraEntity]) { + return true; + } + if (motionEntity && oldHass.states[motionEntity] !== this.hass.states[motionEntity]) { + return true; + } + return false; + } + return true; + } + + protected async _getEvents({ + has_clip = false, + has_snapshot = false, + limit = 100, + }: GetEventsParameters): Promise { + let url = `${this.config.frigate_url}/api/events?camera=${this.config.frigate_camera_name}`; + if (has_clip) { + url += `&has_clip=1` + } + if (has_snapshot) { + url += `&has_snapshot=1` + } + if (limit > 0) { + url += `&limit=${limit}` + } + + if (this.config.label) { + url += `&label=${this.config.label}`; + } + + const response = await fetch(url); + if (response.ok) { + return await response.json(); + } else { + // TODO: Catch when json decoding fails. + throw new Error(`Frigate API request failed with status: ${response.status}`); + } + } + + protected async _renderEvents() : Promise { + const want_clips = this._viewMode == FrigateCardView.CLIPS; + + const events = await this._getEvents({ + has_clip: want_clips, + has_snapshot: !want_clips, + }); + + if (!events.length) { + return html` +
+ +
` + } + + return html` +
    + ${events.map(event => html` +
  • +
    + { + this._viewEvent = event; + this._viewMode = want_clips ? + FrigateCardView.CLIP : FrigateCardView.SNAPSHOT + }} + > +
    +
  • `)} +
`; + } + + protected _renderProgressIndicator(): TemplateResult { + return html` +
+ +
` + } + + protected _renderNavigationBar(): TemplateResult { + return html` +
+ this._viewMode = FrigateCardView.LIVE} + > + this._viewMode = FrigateCardView.CLIPS} + > + this._viewMode = FrigateCardView.SNAPSHOTS} + > +
` + } + + protected async _renderClipPlayer(): Promise { + let event: FrigateEvent; + if (!this._viewEvent) { + const events = await this._getEvents({ + has_clip: true, + limit: 1 + }); + if (!events.length) { + return html` +
+ +
` + } + event = events[0]; + } else { + event = this._viewEvent; + } + + const url = `${this.config.frigate_url}/clips/` + + `${event.camera}-${event.id}.mp4`; + return html` + ` + } + + protected _renderSnapshotViewer(): TemplateResult { + if (!this._viewEvent) { + return html`` + } + const url = `${this.config.frigate_url}/clips/` + + `${this._viewEvent.camera}-${this._viewEvent.id}.jpg`; + return html`` + } + + protected _renderStatusBar(): TemplateResult { + if (!this.config.motion_entity || !(this.config.motion_entity in this.hass.states)) { + return html``; + } + const icon = this.hass.states[this.config.motion_entity].state == "on" ? + "mdi:motion-sensor" : "mdi:walk" + return html` +
+ +
` + } + + protected _renderLiveViewer(): TemplateResult { + return html` + + `; + } + + protected _interactionHandler(): void { + if (!this.config.timeout_ms) { + return; + } + if (this._interactionTimerID) { + window.clearTimeout(this._interactionTimerID); + } + this._interactionTimerID = window.setTimeout(() => { + this._interactionTimerID = null; + this._setViewModeToDefault(); + }, this.config.timeout_ms); + } + + protected render(): TemplateResult | void { + if (this.config.show_warning) { + return this._showWarning(localize('common.show_warning')); + } + if (this.config.show_error) { + return this._showError(localize('common.show_error')); + } + + // TODO: Add latest snapshot fetch functionality. + return html` +
+ ${this._renderNavigationBar()} + ${this._viewMode == FrigateCardView.CLIPS ? + html`` : `` + } + ${this._viewMode == FrigateCardView.SNAPSHOTS ? + html`` : `` + } + ${this._viewMode == FrigateCardView.CLIP ? + html`
+ ${until(this._renderClipPlayer(), this._renderProgressIndicator())} +
` : `` + } + ${this._viewMode == FrigateCardView.SNAPSHOT ? + this._renderSnapshotViewer() : `` + } + ${this._renderStatusBar()} + ${this._renderLiveViewer()} +
`; + } + + // private _handleAction(ev: ActionHandlerEvent): void { + // if (this.hass && this.config && ev.detail.action) { + // handleAction(this, this.hass, this.config, ev.detail.action); + // } + // } + + private _showWarning(warning: string): TemplateResult { + return html` + ${warning} + `; + } + + private _showError(error: string): TemplateResult { + const errorCard = document.createElement('hui-error-card'); + errorCard.setConfig({ + type: 'error', + error, + origConfig: this.config, + }); + + return html` + ${errorCard} + `; + } + + static get styles(): CSSResult { + // CSS is compiled from frigate-card.scss, so this is safe. + return unsafeCSS(style); + } + + static getCardSize(): number { + return 5; + } +} \ No newline at end of file diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index e5a4b4d5..7cf83f7c 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -2,6 +2,7 @@ "common": { "version": "Version", "invalid_configuration": "Invalid configuration", + "invalid_configuration_missing": "Missing parameter in configuration", "show_warning": "Show Warning", "show_error": "Show Error" } diff --git a/src/localize/languages/nb.json b/src/localize/languages/nb.json deleted file mode 100644 index dfe46b06..00000000 --- a/src/localize/languages/nb.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "common": { - "version": "Versjon", - "invalid_configuration": "Ikke gyldig konfiguration", - "show_warning": "Vis advarsel" - } -} \ No newline at end of file diff --git a/src/localize/localize.ts b/src/localize/localize.ts index 4d17345f..d49a4ceb 100644 --- a/src/localize/localize.ts +++ b/src/localize/localize.ts @@ -1,10 +1,8 @@ import * as en from './languages/en.json'; -import * as nb from './languages/nb.json'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const languages: any = { en: en, - nb: nb, }; export function localize(string: string, search = '', replace = ''): string { diff --git a/src/types.ts b/src/types.ts index 9a5e8634..41d0856b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,21 +1,46 @@ import { ActionConfig, LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from 'custom-card-helpers'; - declare global { interface HTMLElementTagNameMap { - 'boilerplate-card-editor': LovelaceCardEditor; + 'frigate-card-editor': LovelaceCardEditor; 'hui-error-card': LovelaceCard; } } -// TODO Add your configuration elements here for type-checking -export interface BoilerplateCardConfig extends LovelaceCardConfig { +export interface FrigateCardConfig extends LovelaceCardConfig { type: string; name?: string; + + camera_entity: string; + motion_entity: string | null; + frigate_url: string; + frigate_camera_name?: string | null; + default_view: string | null; + timeout_ms?: number | null; + show_warning?: boolean; show_error?: boolean; test_gui?: boolean; - entity?: string; tap_action?: ActionConfig; hold_action?: ActionConfig; double_tap_action?: ActionConfig; } + +export interface FrigateEvent { + camera: string; + end_time: number; + false_positive: boolean; + has_clip: boolean; + has_snapshot: boolean; + id: string; + label: string; + start_time: number; + thumbnail: string; + top_score: number; + zones: string[]; +} + +export interface GetEventsParameters { + has_clip?: boolean; + has_snapshot?: boolean; + limit?: number; +} \ No newline at end of file