From e92b53e44cc12434a1585dccfca2ac414046659e Mon Sep 17 00:00:00 2001 From: Andrea Ghensi Date: Sat, 25 Feb 2023 08:26:23 +0100 Subject: [PATCH 1/2] feat: graphical configuration - use fragment in url as cache invalidator instead of querystring - use lit library and some lovelace-mushroom code - rationalize config to take advantage of ha-form components BREAKING CHANGE: configuration fields changed --- README.md | 87 +++---- dist/refreshable-picture-card-editor.js | 122 ++++++++++ dist/refreshable-picture-card.js | 298 ++++++++++-------------- dist/utils.js | 95 ++++++++ 4 files changed, 388 insertions(+), 214 deletions(-) create mode 100644 dist/refreshable-picture-card-editor.js create mode 100644 dist/utils.js diff --git a/README.md b/README.md index 05b4a32..75c652a 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,101 @@ # Refreshable Picture card # - -``` +```yaml resources: - url: /hacsfiles/refreshable-picture-card/refreshable-picture-card.js type: module ``` +Configuration is very easy, and can be done graphically. -configuration is very easy. you can set a picture from a URL or a picture from an entity attribute +You can set a picture from a URL or a picture from an entity attribute. -attribute picture example: +| Name | Description | Required | +| ------------------ | --------------------------------------------------------- | -------------------------------- | +| `title` | Cart title | no | +| `refresh_interval` | Time in seconds between refreshes. Defaults to 30 seconds | yes | +| `url` | URL of the picture. Can be a local path. | mutually exclusive with `entity` | +| `entity` | Picture entity | mutually exclusive with `url` | +| `attribute` | Entity attribute | no | +| `tap_action` | Action on tap | no | -``` +Attribute picture example: + +```yaml type: 'custom:refreshable-picture-card' title: My Mibox -update_interval: 3 # default is 30 -entity_picture: media_player.livingroom_mibox +refresh_interval: 3 +entity: media_player.livingroom_mibox attribute: entity_picture - ``` url image example: -``` + +```yaml type: 'custom:refreshable-picture-card' title: My Mibox -update_interval: 3 # default is 30 -static_picture: /api/media_player_proxy/media_player.livingroom_mibox?token=11111111111111222222222233333333&cache=1589898123.724253 - +refresh_interval: 3 +url: /api/media_player_proxy/media_player.livingroom_mibox?token=11111111111111222222222233333333&cache=1589898123.724253 ``` -reolink camera snap url example: +Reolink camera snap url example: -``` +```yaml type: 'custom:refreshable-picture-card' title: Reolink Camera -update_interval: 1 -static_picture: http://192.168.1.174/cgi-bin/api.cgi?cmd=Snap&channel=0&rs=someString&user=username&password=password - +refresh_interval: 1 +url: http://192.168.1.174/cgi-bin/api.cgi?cmd=Snap&channel=0&rs=someString&user=username&password=password ``` -tap action example: +Tap action example: -``` +```yaml type: 'custom:refreshable-picture-card' title: Reolink Camera -update_interval: 1 -static_picture: http://192.168.1.174/cgi-bin/api.cgi?cmd=Snap&channel=0&rs=someString&user=username&password=password +refresh_interval: 1 +url: http://192.168.1.174/cgi-bin/api.cgi?cmd=Snap&channel=0&rs=someString&user=username&password=password tap_action: - call: remote.send_command + action: call-service + service: remote.send_command data: entity_id: remote.living_room_remote command: b64:JgCgAJSSEg8QEBIPERAPMhEyDxERDxAxEDESLxAyEREPEREQEBAQlBARDxIQEBAREi8PMhEvEhAQMRExDzIREBARDhISDhAyEBEQEQ8REi8RAAdclJMRDxAREREPEREwEi8SEBARDzIQMhAwDzESEBARERAQEBKSEg8QEBAREREPMREyDjESDhIwETESLxEQEBEREBAQETAPERERERAQMRAADQUAAAAAAAAAAA== - ``` no margin (full card picture) example: -``` +```yaml type: 'custom:refreshable-picture-card' -static_picture: http://192.168.1.174/weatherForecast/weather.jpg +url: http://192.168.1.174/weatherForecast/weather.jpg noMargin: true - ``` -navigate example (onclick, open url in new tab): +navigate example (onclick, open url in new tab): -``` +```yaml type: 'custom:refreshable-picture-card' title: Reolink Camera -update_interval: 1 -static_picture: http://192.168.1.174/cgi-bin/api.cgi?cmd=Snap&channel=0&rs=someString&user=username&password=password -navigate: https://github.com/dimagoltsman/refreshable-picture-card/ +refresh_interval: 1 +url: http://192.168.1.174/cgi-bin/api.cgi?cmd=Snap&channel=0&rs=someString&user=username&password=password +tap_action: + action: url + url_path: https://github.com/dimagoltsman/refreshable-picture-card/ ``` -navigate_local example (onclick, change local/lovelace tab): -``` +navigate_local example (onclick, change local/lovelace tab): + +```yaml type: 'custom:refreshable-picture-card' title: Reolink Camera -update_interval: 1 -static_picture: http://192.168.1.174/cgi-bin/api.cgi?cmd=Snap&channel=0&rs=someString&user=username&password=password -navigate_local: camera +refresh_interval: 1 +url: http://192.168.1.174/cgi-bin/api.cgi?cmd=Snap&channel=0&rs=someString&user=username&password=password +tap_action: + action: navigate + navigation_path: camera ``` - # you are also welcome to contribute # - - - diff --git a/dist/refreshable-picture-card-editor.js b/dist/refreshable-picture-card-editor.js new file mode 100644 index 0000000..a014af7 --- /dev/null +++ b/dist/refreshable-picture-card-editor.js @@ -0,0 +1,122 @@ +import { + LitElement, + html, + css, +} from "https://unpkg.com/lit-element@2.0.1/lit-element.js?module"; +import { fireEvent } from "./utils.js"; + +const SCHEMA = [ + { name: "title", selector: { text: {} } }, + { name: "url", selector: { text: {} } }, + { + name: "", + type: "grid", + schema: [ + { name: "entity", selector: { entity: {} } }, + { + name: "attribute", + selector: { attribute: { entity_id: "" } }, + context: { filter_entity: "entity" }, + }, + ], + }, + { + name: "", + type: "grid", + schema: [{ name: "tap_action", selector: { "ui-action": {} } }], + }, + { + name: "refresh_interval", + required: true, + selector: { number: { min: 1 } }, + }, + { name: "noMargin", selector: { boolean: {} } }, +]; + +class ResfeshablePictureCardEditor extends LitElement { + static properties = { + hass: {}, + _config: {}, + }; + + setConfig(config) { + this._config = config; + } + + render() { + if (!this.hass || !this._config) { + return html``; + } + + return html` + + `; + } + + _valueChanged = (ev) => + fireEvent(this, "config-changed", { config: ev.detail.value }); + + _computeLabelCallback = (schema) => { + const { name } = schema; + switch (name) { + case "noMargin": + return "Remove margin"; + // return this.hass.localize( + // `refreshable-picture-card.${name}` + // ); + case "refresh_interval": + return this.hass.localize( + `ui.panel.lovelace.editor.card.generic.${name}` + ); + default: + return `${this.hass.localize( + `ui.panel.lovelace.editor.card.generic.${name}` + )} (${this.hass.localize( + "ui.panel.lovelace.editor.card.config.optional" + )})`; + } + }; + + static styles = css` + .card-config { + /* Cancels overlapping Margins for HAForm + Card Config options */ + overflow: auto; + } + ha-switch { + padding: 16px 6px; + } + .side-by-side { + display: flex; + align-items: flex-end; + } + .side-by-side > * { + flex: 1; + padding-right: 8px; + } + .side-by-side > *:last-child { + flex: 1; + padding-right: 0; + } + .suffix { + margin: 0 8px; + } + hui-action-editor, + ha-select, + ha-textfield, + ha-icon-picker { + margin-top: 8px; + display: block; + } + `; +} + +customElements.define( + "refreshable-picture-card-editor", + ResfeshablePictureCardEditor +); diff --git a/dist/refreshable-picture-card.js b/dist/refreshable-picture-card.js index 1874d24..271dfad 100755 --- a/dist/refreshable-picture-card.js +++ b/dist/refreshable-picture-card.js @@ -1,195 +1,145 @@ +import { + LitElement, + html, + css, +} from "https://unpkg.com/lit-element@2.0.1/lit-element.js?module"; +import "./refreshable-picture-card-editor.js"; +import { handleAction } from "./utils.js"; -var hassObj = null; -class ResfeshablePictureCard extends HTMLElement { +class ResfeshablePictureCard extends LitElement { + static properties = { + hass: {}, + config: {}, + pictureUrl: {}, + }; + + _refreshInterval; constructor() { super(); - this.attachShadow({ mode: 'open' }); + this.pictureUrl = ""; } - setConfig(config) { - - const root = this.shadowRoot; - if (root.lastChild) root.removeChild(root.lastChild); - const cardConfig = Object.assign({}, config); - this._config = cardConfig + static getConfigElement() { + return document.createElement("refreshable-picture-card-editor"); } - - - set hass(hass) { - - hassObj = hass; - - - const config = this._config; - - // console.log(hassObj.states[config.entity_picture]["attributes"][config.attribute]) - - let picture = this._getPictureUrl(config.static_picture); - let title = config.title || "" + static getStubConfig() { + return { + type: "custom:refreshable-picture-card", + title: "Refreshable Picture", + refresh_interval: 30, + url: "", + entity: "", + attribute: "", + noMargin: true, + tap_action: { action: "none" }, + }; + } - let html = ""; - if(!title && !config.noMargin){ - html += `
`; - } else if(title) { - html += `

${title}


`; + setConfig(config) { + if (!config.url && !config.entity) { + throw new Error("You need to define either a url or an entity"); } - try{ - - html += ``; - if(!config.noMargin === true) { - html+= `
`; - } - let css = ` - .center{ - display: block; - margin-top: auto; - margin-bottom: auto; - margin-left: auto; - margin-right: auto; - background: var( --ha-card-background, var(--card-background-color, white) ); - box-shadow: var(--ha-card-box-shadow, none); - box-sizing: border-box; - border-width: var(--ha-card-border-width, 1px); - border-style: solid; - border-color: var( --ha-card-border-color, var(--divider-color, #e0e0e0) ); - color: var(--primary-text-color); - transition: all 0.3s ease-out 0s; - position: relative; - border-radius: var(--ha-card-border-radius, 12px); - `; - if(config.noMargin) { - css+=`width: 100%`; - }else { - css+=`width: 90%`; - } - css+= ` - } - .txt{ - color: var(--ha-card-header-color, --primary-text-color); - font-family: var(--ha-card-header-font-family, inherit); - font-size: var(--ha-card-header-font-size, 24px); - letter-spacing: -0.012em; - line-height: 32px; - } - - `; - - - const root = this.shadowRoot; - this._hass = hass; - // root.lastChild.hass = hass; - - const card = document.createElement('ha-card'); - if(!this.content){ - this.content = document.createElement('div'); - const style = document.createElement('style'); - - - style.textContent = css; - this.content.innerHTML = html; - card.appendChild(this.content); - card.appendChild(style); - - root.appendChild(card); - card.onclick = function(){ - if(config.tap_action){ - let domain = config.tap_action.call.split(".")[0] - let action = config.tap_action.call.split(".")[1] - console.log(config.tap_action.data); - hass.callService(domain, - action, - config.tap_action.data - ); - }else if(config.navigate){ - window.open(config.navigate); - }else if(config.navigate_local){ - window.location.assign(config.navigate_local); - } - - }; - this._bindrefresh(card, this._hass, this._config, this._getPictureUrl); - - window[`scriptLoaded`] = true - } - - } catch(err){ - console.log(err) - console.log('waiting for refreshable-picture-card to load'); + if (config.url && config.entity) { + throw new Error("You need to define only one of url or entity"); } - + this.config = config; + const refreshTime = (config.refresh_interval || 30) * 1000; + clearInterval(this._refreshInterval); + this._refreshInterval = setInterval( + () => (this.pictureUrl = this._getTimestampedUrl()), + refreshTime + ); + this.pictureUrl = this._getTimestampedUrl(); } - - _makeid(length) { - var result = ''; - var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - var charactersLength = characters.length; - for ( var i = 0; i < length; i++ ) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - } - return result; -} - - - _bindrefresh(card, hass, config, getPictureUrl){ - var picture = card.getElementsByClassName(`thePic`)[0]; - - - let refreshTime = config.update_interval || 30 - - var pictureUrl; - let refreshFunc = function(){ - pictureUrl = config.static_picture - - if(config.entity_picture){ - pictureUrl = hassObj.states[config.entity_picture].state - if(config.attribute){ - pictureUrl = hassObj.states[config.entity_picture]["attributes"][config.attribute] - } - - - } - - if(window.getComputedStyle(picture).display){ - - // console.log(pictureUrl) - picture.src = getPictureUrl(pictureUrl); - - } - - - setTimeout(refreshFunc, refreshTime * 1000) + + render() { + const { noMargin, title } = this.config; + return html` + +
+ +
+
+ `; + } + + _onClick() { + const { config, hass } = this; + const { tap_action } = config; + if (!tap_action) { + return; } - - - refreshFunc(); + handleAction(this, hass, config, tap_action); + } + static styles = css` + .center { + display: block; + margin-top: auto; + margin-bottom: auto; + margin-left: auto; + margin-right: auto; + background: var( + --ha-card-background, + var(--card-background-color, white) + ); + box-shadow: var(--ha-card-box-shadow, none); + box-sizing: border-box; + border-width: var(--ha-card-border-width, 1px); + border-style: solid; + border-color: var(--ha-card-border-color, var(--divider-color, #e0e0e0)); + color: var(--primary-text-color); + transition: all 0.3s ease-out 0s; + position: relative; + border-radius: var(--ha-card-border-radius, 12px); + width: 100%; + } + .withMargin { + margin: 5%; + } + .withoutMargin { + margin: 0; + } + .txt { + color: var(--ha-card-header-color, --primary-text-color); + font-family: var(--ha-card-header-font-family, inherit); + font-size: var(--ha-card-header-font-size, 24px); + letter-spacing: -0.012em; + line-height: 32px; + } + `; + + _getPictureUrl() { + const { url, entity, attribute } = this.config; + if (!entity) { + return url; + } + const pictStates = this.hass.states[entity]; + return attribute ? pictStates["attributes"][attribute] : pictStates.state; } - - _getPictureUrl(staticPictureUrl){ - var pictureUrl = staticPictureUrl || ""; - if(pictureUrl.indexOf("?") > -1){ - pictureUrl = pictureUrl + "¤tTimeCache=" + (new Date().getTime()) - }else{ - pictureUrl = pictureUrl + "?currentTimeCache=" + (new Date().getTime()) - } - return pictureUrl; - } - + + _getTimestampedUrl() { + const url = this._getPictureUrl(); + return url ? `${url}#${new Date().getTime()}` : ""; + } + getCardSize() { return 3; } - - } -window.customCards = window.customCards || [] -window.customCards.push({ - type: 'refreshable-picture-card', - name: 'Refreshable Picture Card', - description: 'A picture that can be loaded from url or entity attribute and refreshed every N seconds', +const cardDef = { + type: "refreshable-picture-card", + name: "Refreshable Picture Card", + description: + "A picture that can be loaded from url or entity attribute and refreshed every N seconds", preview: true, - documentationURL: 'https://github.com/dimagoltsman/refreshable-picture-card' -}) -customElements.define('refreshable-picture-card', ResfeshablePictureCard); + documentationURL: "https://github.com/dimagoltsman/refreshable-picture-card", + configurable: true, +}; +window.customCards = window.customCards || []; +window.customCards.push(cardDef); + +customElements.define("refreshable-picture-card", ResfeshablePictureCard); diff --git a/dist/utils.js b/dist/utils.js new file mode 100644 index 0000000..da96913 --- /dev/null +++ b/dist/utils.js @@ -0,0 +1,95 @@ +// Common utilities functions shamelessly taken from lovelace-mushroom +// Copyright Paul Bottein +// https://github.com/piitaya/lovelace-mushroom + +export function handleAction(node, hass, config, actionConfig) { + switch (actionConfig.action) { + case "more-info": { + if (config.entity) { + fireEvent(node, "hass-more-info", { entityId: config.entity }); + } else { + showToast(node, { + message: hass.localize( + "ui.panel.lovelace.cards.actions.no_entity_more_info" + ), + }); + forwardHaptic("failure"); + } + break; + } + case "navigate": + if (actionConfig.navigation_path) { + navigate(actionConfig.navigation_path); + } else { + showToast(node, { + message: hass.localize( + "ui.panel.lovelace.cards.actions.no_navigation_path" + ), + }); + forwardHaptic("failure"); + } + break; + case "url": { + if (actionConfig.url_path) { + window.open(actionConfig.url_path); + } else { + showToast(node, { + message: hass.localize("ui.panel.lovelace.cards.actions.no_url"), + }); + forwardHaptic("failure"); + } + break; + } + case "toggle": { + if (config.entity) { + toggleEntity(hass, config.entity); + forwardHaptic("light"); + } else { + showToast(this, { + message: hass.localize( + "ui.panel.lovelace.cards.actions.no_entity_toggle" + ), + }); + forwardHaptic("failure"); + } + break; + } + case "call-service": { + if (!actionConfig.service) { + showToast(node, { + message: hass.localize("ui.panel.lovelace.cards.actions.no_service"), + }); + forwardHaptic("failure"); + return; + } + const [domain, service] = actionConfig.service.split(".", 2); + hass.callService( + domain, + service, + actionConfig.data ?? actionConfig.service_data, + actionConfig.target + ); + forwardHaptic("light"); + break; + } + case "fire-dom-event": { + fireEvent(node, "ll-custom", actionConfig); + } + } +} + +const forwardHaptic = (hapticType) => fireEvent(window, "haptic", hapticType); + +const showToast = (el, params) => fireEvent(el, "hass-notification", params); + +export const fireEvent = (node, type, detail, options) => { + options = options || {}; + const event = new Event(type, { + bubbles: options.bubbles === undefined ? true : options.bubbles, + cancelable: Boolean(options.cancelable), + composed: options.composed === undefined ? true : options.composed, + }); + event.detail = detail; + node.dispatchEvent(event); + return event; +}; From 6c315c601830674ff57813b9bc014768a44fc177 Mon Sep 17 00:00:00 2001 From: Andrea Ghensi Date: Sun, 5 Mar 2023 23:32:37 +0100 Subject: [PATCH 2/2] docs: missing noMargin option from the table --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 75c652a..7735f4a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ You can set a picture from a URL or a picture from an entity attribute. | `entity` | Picture entity | mutually exclusive with `url` | | `attribute` | Entity attribute | no | | `tap_action` | Action on tap | no | +| `noMargin` | Whether to disable the margin around the picture. | no | Attribute picture example: