From 783f3566799507ee0a55aae36e155ae5ae831e43 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 19 Jan 2018 09:26:06 -0800 Subject: [PATCH] Add timer card and badge (#810) * Add timer card and badge * Disable interval on disconnect * Tests! * One more test case * Remove padStart * Remove state from timer state card --- gulp/.eslintrc | 5 +- js/common/util/duration_to_seconds.js | 4 + js/common/util/seconds_to_duration.js | 16 ++++ js/common/util/state_card_type.js | 1 + js/common/util/timer_time_remaining.js | 13 +++ js/util.js | 32 ++++--- package.json | 15 ++-- .../entity/ha-state-label-badge.html | 49 ++++++++++- src/components/ha-cards.html | 1 + src/state-summary/state-card-content.html | 1 + src/state-summary/state-card-timer.html | 88 +++++++++++++++++++ src/util/hass-util.html | 3 + test-mocha/.eslintrc | 3 + .../common/util/duration_to_seconds_test.js | 10 +++ .../common/util/seconds_to_duration_test.js | 12 +++ .../common/util/timer_time_remaining_test.js | 43 +++++++++ yarn.lock | 48 +++++++++- 17 files changed, 318 insertions(+), 26 deletions(-) create mode 100644 js/common/util/duration_to_seconds.js create mode 100644 js/common/util/seconds_to_duration.js create mode 100644 js/common/util/timer_time_remaining.js create mode 100644 src/state-summary/state-card-timer.html create mode 100644 test-mocha/common/util/duration_to_seconds_test.js create mode 100644 test-mocha/common/util/seconds_to_duration_test.js create mode 100644 test-mocha/common/util/timer_time_remaining_test.js diff --git a/gulp/.eslintrc b/gulp/.eslintrc index 15193b659c..42e889b0dd 100644 --- a/gulp/.eslintrc +++ b/gulp/.eslintrc @@ -1,6 +1,7 @@ { "rules": { - "no-restricted-syntax": 0, - "no-console": 0 + "import/no-extraneous-dependencies": 0, + "no-restricted-syntax": 0, + "no-console": 0 } } diff --git a/js/common/util/duration_to_seconds.js b/js/common/util/duration_to_seconds.js new file mode 100644 index 0000000000..3e9b72eb53 --- /dev/null +++ b/js/common/util/duration_to_seconds.js @@ -0,0 +1,4 @@ +export default function durationToSeconds(duration) { + const parts = duration.split(':').map(Number); + return (parts[0] * 3600) + (parts[1] * 60) + parts[2]; +} diff --git a/js/common/util/seconds_to_duration.js b/js/common/util/seconds_to_duration.js new file mode 100644 index 0000000000..85c8589383 --- /dev/null +++ b/js/common/util/seconds_to_duration.js @@ -0,0 +1,16 @@ +const leftPad = number => (number < 10 ? `0${number}` : number); + +export default function secondsToDuration(d) { + const h = Math.floor(d / 3600); + const m = Math.floor((d % 3600) / 60); + const s = Math.floor(d % 3600 % 60); + + if (h > 0) { + return `${h}:${leftPad(m)}:${leftPad(s)}`; + } else if (m > 0) { + return `${m}:${leftPad(s)}`; + } else if (s > 0) { + return '' + s; + } + return null; +} diff --git a/js/common/util/state_card_type.js b/js/common/util/state_card_type.js index 73d2378f2a..65526a1991 100644 --- a/js/common/util/state_card_type.js +++ b/js/common/util/state_card_type.js @@ -11,6 +11,7 @@ const DOMAINS_WITH_CARD = [ 'media_player', 'scene', 'script', + 'timer', 'weblink', ]; diff --git a/js/common/util/timer_time_remaining.js b/js/common/util/timer_time_remaining.js new file mode 100644 index 0000000000..2d527131ea --- /dev/null +++ b/js/common/util/timer_time_remaining.js @@ -0,0 +1,13 @@ +import durationToSeconds from './duration_to_seconds.js'; + +export default function timerTimeRemaining(stateObj) { + let timeRemaining = durationToSeconds(stateObj.attributes.remaining); + + if (stateObj.state === 'active') { + const now = new Date(); + const madeActive = new Date(stateObj.last_changed); + timeRemaining = Math.max(timeRemaining - ((now - madeActive) / 1000), 0); + } + + return timeRemaining; +} diff --git a/js/util.js b/js/util.js index 34af033a52..b970514fd4 100644 --- a/js/util.js +++ b/js/util.js @@ -9,14 +9,17 @@ import attributeClassNames from './common/util/attribute_class_names.js'; import canToggleDomain from './common/util/can_toggle_domain.js'; import canToggleState from './common/util/can_toggle_state.js'; -import computeStateDomain from './common/util/compute_state_domain.js'; import computeStateDisplay from './common/util/compute_state_display.js'; +import computeDomain from './common/util/compute_state_domain.js'; +import durationToSeconds from './common/util/duration_to_seconds.js'; import featureClassNames from './common/util/feature_class_names.js'; import formatDate from './common/util/format_date.js'; import formatDateTime from './common/util/format_date_time.js'; import formatTime from './common/util/format_time.js'; +import secondsToDuration from './common/util/seconds_to_duration.js'; import stateCardType from './common/util/state_card_type.js'; import stateMoreInfoType from './common/util/state_more_info_type.js'; +import timerTimeRemaining from './common/util/timer_time_remaining.js'; window.hassUtil = window.hassUtil || {}; @@ -25,14 +28,19 @@ const language = navigator.languages ? window.fecha.masks.haDateTime = window.fecha.masks.shortTime + ' ' + window.fecha.masks.mediumDate; -window.hassUtil.attributeClassNames = attributeClassNames; -window.hassUtil.canToggleDomain = canToggleDomain; -window.hassUtil.canToggleState = canToggleState; -window.hassUtil.computeDomain = computeStateDomain; -window.hassUtil.computeStateDisplay = computeStateDisplay; -window.hassUtil.featureClassNames = featureClassNames; -window.hassUtil.formatDate = dateObj => formatDate(dateObj, language); -window.hassUtil.formatDateTime = dateObj => formatDateTime(dateObj, language); -window.hassUtil.formatTime = dateObj => formatTime(dateObj, language); -window.hassUtil.stateCardType = stateCardType; -window.hassUtil.stateMoreInfoType = stateMoreInfoType; +Object.assign(window.hassUtil, { + attributeClassNames, + canToggleDomain, + canToggleState, + computeDomain, + computeStateDisplay, + durationToSeconds, + featureClassNames, + secondsToDuration, + stateCardType, + stateMoreInfoType, + timerTimeRemaining, + formatDate: dateObj => formatDate(dateObj, language), + formatDateTime: dateObj => formatDateTime(dateObj, language), + formatTime: dateObj => formatTime(dateObj, language), +}); diff --git a/package.json b/package.json index 30cbe748c5..d82023024b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,13 @@ "author": "Paulus Schoutsen (http://paulusschoutsen.nl)", "license": "Apache-2.0", "dependencies": { + "es6-object-assign": "^1.1.0", + "home-assistant-js-websocket": "^1.1.2", + "mdn-polyfills": "^5.5.0", + "preact": "^8.2.6", + "unfetch": "^3.0.0" + }, + "devDependencies": { "babel-core": "^6.26.0", "babel-plugin-external-helpers": "^6.22.0", "babel-plugin-transform-object-rest-spread": "^6.26.0", @@ -32,7 +39,6 @@ "chai": "^4.1.2", "css-slam": "^2.0.2", "del": "^3.0.0", - "es6-object-assign": "^1.1.0", "eslint": "^4.11.0", "eslint-config-airbnb-base": "^12.1.0", "eslint-plugin-html": "^3.2.2", @@ -58,9 +64,7 @@ "gulp-util": "^3.0.8", "gulp-vinyl-zip": "^2.1.0", "gulp-zopfli": "^1.0.0", - "home-assistant-js-websocket": "^1.1.2", "html-minifier": "^3.5.6", - "mdn-polyfills": "^5.5.0", "merge-stream": "^1.0.1", "mocha": "^4.0.1", "parse5": "^3.0.3", @@ -68,7 +72,6 @@ "polymer-build": "^2.1.0", "polymer-bundler": "^3.1.0", "polymer-cli": "^1.5.6", - "preact": "^8.2.6", "pump": "^1.0.2", "reify": "^0.12.3", "require-dir": "^0.3.2", @@ -79,12 +82,10 @@ "rollup-plugin-replace": "^2.0.0", "rollup-watch": "^4.3.1", "run-sequence": "^2.2.0", + "sinon": "^4.1.6", "sw-precache": "^5.2.0", "uglify-es": "^3.1.9", "uglify-js": "^3.1.9", - "unfetch": "^3.0.0" - }, - "devDependencies": { "web-component-tester": "^6.4.0" } } diff --git a/src/components/entity/ha-state-label-badge.html b/src/components/entity/ha-state-label-badge.html index 8b7f5b1a9c..3eef630da9 100644 --- a/src/components/entity/ha-state-label-badge.html +++ b/src/components/entity/ha-state-label-badge.html @@ -40,7 +40,7 @@ value='[[computeValue(state)]]' icon='[[computeIcon(state)]]' image='[[computeImage(state)]]' - label='[[computeLabel(localize, state)]]' + label='[[computeLabel(localize, state, timerTimeRemaining)]]' description='[[computeDescription(state)]]' > @@ -62,9 +62,23 @@ class HaStateLabelBadge extends type: Object, observer: 'stateChanged', }, + timerTimeRemaining: { + type: Number, + value: 0, + } }; } + connectedCallback() { + super.connectedCallback(); + this.startInterval(this.state); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.clearInterval(); + } + ready() { super.ready(); this.addEventListener('tap', ev => this.badgeTap(ev)); @@ -92,6 +106,7 @@ class HaStateLabelBadge extends case 'updater': case 'sun': case 'alarm_control_panel': + case 'timer': return null; case 'sensor': default: @@ -128,6 +143,8 @@ class HaStateLabelBadge extends case 'sun': return state.state === 'above_horizon' ? window.hassUtil.domainIcon(domain) : 'mdi:brightness-3'; + case 'timer': + return state.state === 'active' ? 'mdi:timer' : 'mdi:timer-off'; default: return null; } @@ -137,7 +154,7 @@ class HaStateLabelBadge extends return state.attributes.entity_picture || null; } - computeLabel(localize, state) { + computeLabel(localize, state, timerTimeRemaining) { const domain = window.hassUtil.computeDomain(state); if (state.state === 'unavailable' || ['device_tracker', 'alarm_control_panel'].includes(domain)) { @@ -146,6 +163,9 @@ class HaStateLabelBadge extends // are only added for device_tracker and alarm_control_panel. return localize(`state_badge.${domain}.${state.state}`) || localize(`state_badge.default.${state.state}`) || state.state; } + if (domain === 'timer') { + return window.hassUtil.secondsToDuration(timerTimeRemaining); + } return state.attributes.unit_of_measurement || null; } @@ -153,8 +173,31 @@ class HaStateLabelBadge extends return window.hassUtil.computeStateName(state); } - stateChanged() { + stateChanged(stateObj) { this.updateStyles(); + this.startInterval(stateObj); + } + + clearInterval() { + if (this._updateRemaining) { + clearInterval(this._updateRemaining); + this._updateRemaining = null; + } + } + + startInterval(stateObj) { + this.clearInterval(); + if (window.hassUtil.computeDomain(stateObj) === 'timer') { + this.calculateTimerRemaining(stateObj); + + if (stateObj.state === 'active') { + this._updateRemaining = setInterval(() => this.calculateTimerRemaining(this.state), 1000); + } + } + } + + calculateTimerRemaining(stateObj) { + this.timerTimeRemaining = window.hassUtil.timerTimeRemaining(stateObj); } } diff --git a/src/components/ha-cards.html b/src/components/ha-cards.html index f6e81a4ff5..f75cd47fcf 100644 --- a/src/components/ha-cards.html +++ b/src/components/ha-cards.html @@ -106,6 +106,7 @@ sun: 1, device_tracker: 2, alarm_control_panel: 3, + timer: 4, sensor: 5, binary_sensor: 6, mailbox: 7, diff --git a/src/state-summary/state-card-content.html b/src/state-summary/state-card-content.html index 73643e8390..8c95f16ce8 100644 --- a/src/state-summary/state-card-content.html +++ b/src/state-summary/state-card-content.html @@ -10,6 +10,7 @@ + diff --git a/src/state-summary/state-card-timer.html b/src/state-summary/state-card-timer.html new file mode 100644 index 0000000000..9b9adbaf79 --- /dev/null +++ b/src/state-summary/state-card-timer.html @@ -0,0 +1,88 @@ + + + + + + + + + + + + + diff --git a/src/util/hass-util.html b/src/util/hass-util.html index aac5f656e2..c34c769b70 100644 --- a/src/util/hass-util.html +++ b/src/util/hass-util.html @@ -187,6 +187,9 @@ window.hassUtil.domainIcon = function (domain, state) { case 'switch': return 'mdi:flash'; + case 'timer': + return 'mdi:timer'; + case 'updater': return 'mdi:cloud-upload'; diff --git a/test-mocha/.eslintrc b/test-mocha/.eslintrc index 7eeefc33b6..268209e0ba 100644 --- a/test-mocha/.eslintrc +++ b/test-mocha/.eslintrc @@ -1,5 +1,8 @@ { "env": { "mocha": true + }, + "rules": { + "import/no-extraneous-dependencies": 0 } } diff --git a/test-mocha/common/util/duration_to_seconds_test.js b/test-mocha/common/util/duration_to_seconds_test.js new file mode 100644 index 0000000000..1454574e70 --- /dev/null +++ b/test-mocha/common/util/duration_to_seconds_test.js @@ -0,0 +1,10 @@ +import { assert } from 'chai'; + +import durationToSeconds from '../../../js/common/util/duration_to_seconds.js'; + +describe('durationToSeconds', () => { + it('works', () => { + assert.strictEqual(durationToSeconds('0:01:05'), 65); + assert.strictEqual(durationToSeconds('11:01:05'), 39665); + }); +}); diff --git a/test-mocha/common/util/seconds_to_duration_test.js b/test-mocha/common/util/seconds_to_duration_test.js new file mode 100644 index 0000000000..ba7088c467 --- /dev/null +++ b/test-mocha/common/util/seconds_to_duration_test.js @@ -0,0 +1,12 @@ +import { assert } from 'chai'; + +import secondsToDuration from '../../../js/common/util/seconds_to_duration.js'; + +describe('secondsToDuration', () => { + it('works', () => { + assert.strictEqual(secondsToDuration(0), null); + assert.strictEqual(secondsToDuration(65), '1:05'); + assert.strictEqual(secondsToDuration(3665), '1:01:05'); + assert.strictEqual(secondsToDuration(39665), '11:01:05'); + }); +}); diff --git a/test-mocha/common/util/timer_time_remaining_test.js b/test-mocha/common/util/timer_time_remaining_test.js new file mode 100644 index 0000000000..8ab2678c0e --- /dev/null +++ b/test-mocha/common/util/timer_time_remaining_test.js @@ -0,0 +1,43 @@ +import { assert } from 'chai'; +import sinon from 'sinon'; + +import timerTimeRemaining from '../../../js/common/util/timer_time_remaining.js'; + +describe('timerTimeRemaining', () => { + it('works with idle timers', () => { + assert.strictEqual(timerTimeRemaining({ + state: 'idle', + attributes: { + remaining: '0:01:05' + } + }), 65); + }); + + it('works with paused timers', () => { + assert.strictEqual(timerTimeRemaining({ + state: 'paused', + attributes: { + remaining: '0:01:05' + } + }), 65); + }); + + describe('active timers', () => { + let clock; + beforeEach(() => { + clock = sinon.useFakeTimers(new Date('2018-01-17T16:15:30Z')); + }); + afterEach(() => { + clock.restore(); + }); + it('works', () => { + assert.strictEqual(timerTimeRemaining({ + state: 'active', + attributes: { + remaining: '0:01:05' + }, + last_changed: '2018-01-17T16:15:12Z', + }), 47); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 6d86e245b6..ebc07dbc92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3275,7 +3275,7 @@ formatio@1.1.1: dependencies: samsam "~1.1" -formatio@1.2.0: +formatio@1.2.0, formatio@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.2.0.tgz#f3b2167d9068c4698a8d51f4f760a39a54d818eb" dependencies: @@ -4679,6 +4679,10 @@ jsx-ast-utils@^2.0.0: dependencies: array-includes "^3.0.3" +just-extend@^1.1.26: + version "1.1.27" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-1.1.27.tgz#ec6e79410ff914e472652abfa0e603c03d60e905" + kind-of@^3.0.2: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -4968,6 +4972,10 @@ lodash.escape@~2.4.1: lodash._reunescapedhtml "~2.4.1" lodash.keys "~2.4.1" +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + lodash.identity@~2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/lodash.identity/-/lodash.identity-2.4.1.tgz#6694cffa65fef931f7c31ce86c74597cf560f4f1" @@ -5137,6 +5145,10 @@ lolex@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.6.0.tgz#3a9a0283452a47d7439e72731b9e07d7386e49f6" +lolex@^2.2.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.3.1.tgz#3d2319894471ea0950ef64692ead2a5318cff362" + longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" @@ -5544,6 +5556,16 @@ netrc@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/netrc/-/netrc-0.1.4.tgz#6be94fcaca8d77ade0a9670dc460914c94472444" +nise@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/nise/-/nise-1.2.0.tgz#079d6cadbbcb12ba30e38f1c999f36ad4d6baa53" + dependencies: + formatio "^1.2.0" + just-extend "^1.1.26" + lolex "^1.6.0" + path-to-regexp "^1.7.0" + text-encoding "^0.6.4" + no-case@^2.2.0: version "2.3.1" resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.1.tgz#7aeba1c73a52184265554b7dc03baf720df80081" @@ -7150,6 +7172,18 @@ sinon@^2.3.5: text-encoding "0.6.4" type-detect "^4.0.0" +sinon@^4.1.6: + version "4.1.6" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-4.1.6.tgz#9cb346bddb180d68a804429ffe14978d7fafd629" + dependencies: + diff "^3.1.0" + formatio "1.2.0" + lodash.get "^4.4.2" + lolex "^2.2.0" + nise "^1.2.0" + supports-color "^5.1.0" + type-detect "^4.0.5" + slash@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" @@ -7556,6 +7590,12 @@ supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" +supports-color@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.1.0.tgz#058a021d1b619f7ddf3980d712ea3590ce7de3d5" + dependencies: + has-flag "^2.0.0" + sw-precache@^5.1.1, sw-precache@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/sw-precache/-/sw-precache-5.2.0.tgz#eb6225ce580ceaae148194578a0ad01ab7ea199c" @@ -7684,7 +7724,7 @@ test-value@^2.1.0: array-back "^1.0.3" typical "^2.6.0" -text-encoding@0.6.4: +text-encoding@0.6.4, text-encoding@^0.6.4: version "0.6.4" resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19" @@ -7854,6 +7894,10 @@ type-detect@^4.0.0: version "4.0.5" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.5.tgz#d70e5bc81db6de2a381bcaca0c6e0cbdc7635de2" +type-detect@^4.0.5: + version "4.0.6" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.6.tgz#88cbce3d13bc675a63f840b3225c180f870786d7" + type-is@^1.6.4, type-is@~1.6.15: version "1.6.15" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.15.tgz#cab10fb4909e441c82842eafe1ad646c81804410"