diff --git a/package.json b/package.json index 8a1fa1ad09..906acb7236 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "chartjs-chart-timeline": "0.2.0", "es6-object-assign": "^1.1.0", "fecha": "^2.3.3", - "home-assistant-js-websocket": "^1.2.1", + "home-assistant-js-websocket": "2.0.0", "intl-messageformat": "^2.2.0", "leaflet": "^1.0.2", "marked": "^0.3.19", diff --git a/src/common/const.js b/src/common/const.js index 15247103e7..2b16d433c4 100644 --- a/src/common/const.js +++ b/src/common/const.js @@ -40,3 +40,6 @@ export const STATES_OFF = [ /** Temperature units. */ export const UNIT_C = '°C'; export const UNIT_F = '°F'; + +/** Entity ID of the default view. */ +export const DEFAULT_VIEW_ENTITY_ID = 'group.default_view'; diff --git a/src/common/entity/extract_views.js b/src/common/entity/extract_views.js new file mode 100644 index 0000000000..151b3dcd3d --- /dev/null +++ b/src/common/entity/extract_views.js @@ -0,0 +1,24 @@ +import { DEFAULT_VIEW_ENTITY_ID } from '../const.js'; + +// Return an ordered array of available views +export default function extractViews(entities) { + const views = []; + + Object.keys(entities).forEach((entityId) => { + const entity = entities[entityId]; + if (entity.attributes.view) { + views.push(entity); + } + }); + + views.sort((view1, view2) => { + if (view1.entity_id === DEFAULT_VIEW_ENTITY_ID) { + return -1; + } else if (view2.entity_id === DEFAULT_VIEW_ENTITY_ID) { + return 1; + } + return view1.attributes.order - view2.attributes.order; + }); + + return views; +} diff --git a/src/common/entity/get_group_entities.js b/src/common/entity/get_group_entities.js new file mode 100644 index 0000000000..5e034a9df8 --- /dev/null +++ b/src/common/entity/get_group_entities.js @@ -0,0 +1,13 @@ +export default function getGroupEntities(entities, group) { + const result = {}; + + group.attributes.entity_id.forEach((entityId) => { + const entity = entities[entityId]; + + if (entity) { + result[entity.entity_id] = entity; + } + }); + + return result; +} diff --git a/src/common/entity/get_view_entities.js b/src/common/entity/get_view_entities.js new file mode 100644 index 0000000000..7f10bdaf6c --- /dev/null +++ b/src/common/entity/get_view_entities.js @@ -0,0 +1,30 @@ +import computeDomain from './compute_domain.js'; +import getGroupEntities from './get_group_entities.js'; + +// Return an object containing all entities that the view will show +// including embedded groups. +export default function getViewEntities(entities, view) { + const viewEntities = {}; + + view.attributes.entity_id.forEach((entityId) => { + const entity = entities[entityId]; + + if (entity && !entity.attributes.hidden) { + viewEntities[entity.entity_id] = entity; + + if (computeDomain(entity.entity_id) === 'group') { + const groupEntities = getGroupEntities(entities, entity); + + Object.keys(groupEntities).forEach((grEntityId) => { + const grEntity = groupEntities[grEntityId]; + + if (!grEntity.attributes.hidden) { + viewEntities[grEntityId] = grEntity; + } + }); + } + } + }); + + return viewEntities; +} diff --git a/src/common/entity/split_by_groups.js b/src/common/entity/split_by_groups.js new file mode 100644 index 0000000000..62b70b380a --- /dev/null +++ b/src/common/entity/split_by_groups.js @@ -0,0 +1,24 @@ +import computeDomain from './compute_domain.js'; + +// Split a collection into a list of groups and a 'rest' list of ungrouped +// entities. +// Returns { groups: [], ungrouped: {} } +export default function splitByGroups(entities) { + const groups = []; + const ungrouped = {}; + + Object.keys(entities).forEach((entityId) => { + const entity = entities[entityId]; + + if (computeDomain(entityId) === 'group') { + groups.push(entity); + } else { + ungrouped[entityId] = entity; + } + }); + + groups.forEach(group => + group.attributes.entity_id.forEach((entityId) => { delete ungrouped[entityId]; })); + + return { groups, ungrouped }; +} diff --git a/src/components/ha-cards.js b/src/components/ha-cards.js index 2463032f27..0b467123dc 100644 --- a/src/components/ha-cards.js +++ b/src/components/ha-cards.js @@ -9,6 +9,8 @@ import '../cards/ha-card-chooser.js'; import './ha-demo-badge.js'; import computeStateDomain from '../common/entity/compute_state_domain.js'; +import splitByGroups from '../common/entity/split_by_groups.js'; +import getGroupEntities from '../common/entity/get_group_entities.js'; { // mapping domain to size of the card. @@ -281,7 +283,7 @@ import computeStateDomain from '../common/entity/compute_state_domain.js'; }); } - const splitted = window.HAWS.splitByGroups(states); + const splitted = splitByGroups(states); if (orderedGroupEntities) { splitted.groups.sort((gr1, gr2) => orderedGroupEntities[gr1.entity_id] - orderedGroupEntities[gr2.entity_id]); @@ -344,7 +346,7 @@ import computeStateDomain from '../common/entity/compute_state_domain.js'; }); splitted.groups.forEach((groupState) => { - const entities = window.HAWS.getGroupEntities(states, groupState); + const entities = getGroupEntities(states, groupState); addEntitiesCard( groupState.entity_id, Object.keys(entities).map(key => entities[key]), diff --git a/src/entrypoints/app.js b/src/entrypoints/app.js index b4cca72cb4..4b279ef5ac 100644 --- a/src/entrypoints/app.js +++ b/src/entrypoints/app.js @@ -9,6 +9,12 @@ import { html } from '@polymer/polymer/lib/utils/html-tag.js'; import { setPassiveTouchGestures } from '@polymer/polymer/lib/utils/settings.js'; import { PolymerElement } from '@polymer/polymer/polymer-element.js'; +import { + ERR_INVALID_AUTH, + subscribeEntities, + subscribeConfig, +} from 'home-assistant-js-websocket'; + import translationMetadata from '../../build-translations/translationMetadata.json'; import '../layouts/home-assistant-main.js'; import '../layouts/login-form.js'; @@ -221,7 +227,7 @@ class HomeAssistant extends PolymerElement { // If we reconnect after losing connection and access token is no longer // valid. conn.addEventListener('reconnect-error', (_conn, err) => { - if (err !== window.HAWS.ERR_INVALID_AUTH) return; + if (err !== ERR_INVALID_AUTH) return; disconnected(); this.unsubConnection(); window.refreshToken().then(accessToken => @@ -231,7 +237,7 @@ class HomeAssistant extends PolymerElement { var unsubEntities; - window.HAWS.subscribeEntities(conn, (states) => { + subscribeEntities(conn, (states) => { this._updateHass({ states: states }); }).then(function (unsub) { unsubEntities = unsub; @@ -239,7 +245,7 @@ class HomeAssistant extends PolymerElement { var unsubConfig; - window.HAWS.subscribeConfig(conn, (config) => { + subscribeConfig(conn, (config) => { this._updateHass({ config: config }); }).then(function (unsub) { unsubConfig = unsub; diff --git a/src/entrypoints/core.js b/src/entrypoints/core.js index 7dcd61cc8b..4c687ca9be 100644 --- a/src/entrypoints/core.js +++ b/src/entrypoints/core.js @@ -1,11 +1,14 @@ -import * as HAWS from 'home-assistant-js-websocket'; +import { + ERR_INVALID_AUTH, + createConnection, + subscribeConfig, + subscribeEntities, +} from 'home-assistant-js-websocket'; import fetchToken from '../common/auth/fetch_token.js'; import refreshToken_ from '../common/auth/refresh_token.js'; import parseQuery from '../common/util/parse_query.js'; -window.HAWS = HAWS; - const init = window.createHassConnection = function (password, accessToken) { const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; const url = `${proto}://${window.location.host}/api/websocket?${__BUILD__}`; @@ -17,10 +20,10 @@ const init = window.createHassConnection = function (password, accessToken) { } else if (accessToken) { options.accessToken = accessToken; } - return HAWS.createConnection(url, options) + return createConnection(url, options) .then(function (conn) { - HAWS.subscribeEntities(conn); - HAWS.subscribeConfig(conn); + subscribeEntities(conn); + subscribeConfig(conn); return conn; }); }; @@ -61,7 +64,7 @@ function main() { if (localStorage.tokens) { window.tokens = JSON.parse(localStorage.tokens); window.hassConnection = init(null, window.tokens.access_token).catch((err) => { - if (err !== HAWS.ERR_INVALID_AUTH) throw err; + if (err !== ERR_INVALID_AUTH) throw err; return window.refreshToken().then(accessToken => init(null, accessToken)); }); diff --git a/src/layouts/login-form.js b/src/layouts/login-form.js index 7898deff1e..48ccdff94f 100644 --- a/src/layouts/login-form.js +++ b/src/layouts/login-form.js @@ -5,6 +5,8 @@ import '@polymer/paper-input/paper-input.js'; import '@polymer/paper-spinner/paper-spinner.js'; import { html } from '@polymer/polymer/lib/utils/html-tag.js'; import { PolymerElement } from '@polymer/polymer/polymer-element.js'; +import { ERR_CANNOT_CONNECT, ERR_INVALID_AUTH } from 'home-assistant-js-websocket'; + import LocalizeMixin from '../mixins/localize-mixin.js'; @@ -172,9 +174,9 @@ class LoginForm extends LocalizeMixin(PolymerElement) { function (errCode) { el.isValidating = false; - if (errCode === window.HAWS.ERR_CANNOT_CONNECT) { + if (errCode === ERR_CANNOT_CONNECT) { el.errorMessage = 'Unable to connect'; - } else if (errCode === window.HAWS.ERR_INVALID_AUTH) { + } else if (errCode === ERR_INVALID_AUTH) { el.errorMessage = 'Invalid password'; } else { el.errorMessage = 'Unknown error: ' + errCode; diff --git a/src/layouts/partial-cards.js b/src/layouts/partial-cards.js index caa64a878a..b3e5e0bff8 100644 --- a/src/layouts/partial-cards.js +++ b/src/layouts/partial-cards.js @@ -16,6 +16,8 @@ import '../components/ha-start-voice-button.js'; import './ha-app-layout.js'; +import extractViews from '../common/entity/extract_views.js'; +import getViewEntities from '../common/entity/get_view_entities.js'; import computeStateName from '../common/entity/compute_state_name.js'; import computeStateDomain from '../common/entity/compute_state_domain.js'; import computeLocationName from '../common/config/location_name.js'; @@ -268,7 +270,7 @@ import EventsMixin from '../mixins/events-mixin.js'; hassChanged(hass) { if (!hass) return; - const views = window.HAWS.extractViews(hass.states); + const views = extractViews(hass.states); let defaultView = null; // If default view present, it's in first index. if (views.length > 0 && views[0].entity_id === DEFAULT_VIEW_ENTITY_ID) { @@ -311,9 +313,9 @@ import EventsMixin from '../mixins/events-mixin.js'; let states; if (currentView) { - states = window.HAWS.getViewEntities(hass.states, hass.states[currentView]); + states = getViewEntities(hass.states, hass.states[currentView]); } else { - states = window.HAWS.getViewEntities(hass.states, hass.states[DEFAULT_VIEW_ENTITY_ID]); + states = getViewEntities(hass.states, hass.states[DEFAULT_VIEW_ENTITY_ID]); } // Make sure certain domains are always shown. diff --git a/test-mocha/common/entity/extract_views.spec.js b/test-mocha/common/entity/extract_views.spec.js new file mode 100644 index 0000000000..10bf840c1c --- /dev/null +++ b/test-mocha/common/entity/extract_views.spec.js @@ -0,0 +1,32 @@ +import assert from 'assert'; + +import extractViews from '../../../src/common/entity/extract_views.js'; + +import { + createEntities, + createView, +} from './test_util'; + +describe('extractViews', () => { + it('should work', () => { + const entities = createEntities(10); + const view1 = createView({ attributes: { order: 10 } }); + entities[view1.entity_id] = view1; + + const view2 = createView({ attributes: { order: 2 } }); + entities[view2.entity_id] = view2; + + const view3 = createView({ + entity_id: 'group.default_view', + attributes: { order: 8 } + }); + entities[view3.entity_id] = view3; + + const view4 = createView({ attributes: { order: 4 } }); + entities[view4.entity_id] = view4; + + const expected = [view3, view2, view4, view1]; + + assert.deepEqual(expected, extractViews(entities)); + }); +}); diff --git a/test-mocha/common/entity/get_group_entities.spec.js b/test-mocha/common/entity/get_group_entities.spec.js new file mode 100644 index 0000000000..0a9802e7de --- /dev/null +++ b/test-mocha/common/entity/get_group_entities.spec.js @@ -0,0 +1,31 @@ +import assert from 'assert'; + +import getGroupEntities from '../../../src/common/entity/get_group_entities.js'; + +import { createEntities, createGroup, entityMap } from './test_util'; + +describe('getGroupEntities', () => { + it('works if all entities exist', () => { + const entities = createEntities(5); + const entityIds = Object.keys(entities); + + const group = createGroup({ attributes: { entity_id: entityIds.splice(0, 2) } }); + + const groupEntities = entityMap(group.attributes.entity_id.map(ent => entities[ent])); + assert.deepEqual(groupEntities, getGroupEntities(entities, group)); + }); + + it("works if one entity doesn't exist", () => { + const entities = createEntities(5); + const entityIds = Object.keys(entities); + + const groupEntities = entityMap([ + entities[entityIds[0]], + entities[entityIds[1]], + ]); + + const group = createGroup({ attributes: { entity_id: entityIds.splice(0, 2).concat('light.does_not_exist') } }); + + assert.deepEqual(groupEntities, getGroupEntities(entities, group)); + }); +}); diff --git a/test-mocha/common/entity/get_view_entities.spec.js b/test-mocha/common/entity/get_view_entities.spec.js new file mode 100644 index 0000000000..668fcede91 --- /dev/null +++ b/test-mocha/common/entity/get_view_entities.spec.js @@ -0,0 +1,68 @@ +import assert from 'assert'; + +import getViewEntities from '../../../src/common/entity/get_view_entities.js'; + +import { + createEntities, + createEntity, + createGroup, + createView, + entityMap +} from './test_util'; + +describe('getViewEntities', () => { + it('should work', () => { + const entities = createEntities(10); + const entityIds = Object.keys(entities); + + const group1 = createGroup({ attributes: { entity_id: entityIds.splice(0, 2) } }); + entities[group1.entity_id] = group1; + + const group2 = createGroup({ attributes: { entity_id: entityIds.splice(0, 3) } }); + entities[group2.entity_id] = group2; + + const view = createView({ + attributes: { + entity_id: [group1.entity_id, group2.entity_id].concat(entityIds.splice(0, 2)) + } + }); + + const expectedEntities = entityMap(view.attributes.entity_id.map(ent => entities[ent])); + Object.assign( + expectedEntities, + entityMap(group1.attributes.entity_id.map(ent => entities[ent])) + ); + Object.assign( + expectedEntities, + entityMap(group2.attributes.entity_id.map(ent => entities[ent])) + ); + + assert.deepEqual(expectedEntities, getViewEntities(entities, view)); + }); + + it('should not include hidden entities inside groups', () => { + const visibleEntity = createEntity({ attributes: { hidden: false } }); + const hiddenEntity = createEntity({ attributes: { hidden: true } }); + const group1 = createGroup({ attributes: { entity_id: [ + visibleEntity.entity_id, hiddenEntity.entity_id] } }); + + const entities = { + [visibleEntity.entity_id]: visibleEntity, + [hiddenEntity.entity_id]: hiddenEntity, + [group1.entity_id]: group1, + }; + + const view = createView({ + attributes: { + entity_id: [group1.entity_id], + }, + }); + + const expectedEntities = { + [visibleEntity.entity_id]: visibleEntity, + [group1.entity_id]: group1, + }; + + assert.deepEqual(expectedEntities, getViewEntities(entities, view)); + }); +}); diff --git a/test-mocha/common/entity/split_by_groups.spec.js b/test-mocha/common/entity/split_by_groups.spec.js new file mode 100644 index 0000000000..22f70dad11 --- /dev/null +++ b/test-mocha/common/entity/split_by_groups.spec.js @@ -0,0 +1,38 @@ +import assert from 'assert'; + +import splitByGroups from '../../../src/common/entity/split_by_groups.js'; + +import { createEntities, createGroup, entityMap } from './test_util'; + +describe('splitByGroups', () => { + it('splitByGroups splits correctly', () => { + const entities = createEntities(7); + const entityIds = Object.keys(entities); + + const group1 = createGroup({ + attributes: { + entity_id: entityIds.splice(0, 2), + order: 6, + }, + }); + entities[group1.entity_id] = group1; + + const group2 = createGroup({ + attributes: { + entity_id: entityIds.splice(0, 3), + order: 4, + }, + }); + entities[group2.entity_id] = group2; + + const result = splitByGroups(entities); + result.groups.sort((gr1, gr2) => gr1.attributes.order - gr2.attributes.order); + + const expected = { + groups: [group2, group1], + ungrouped: entityMap(entityIds.map(ent => entities[ent])), + }; + + assert.deepEqual(expected, result); + }); +}); diff --git a/test-mocha/common/entity/test_util.js b/test-mocha/common/entity/test_util.js new file mode 100644 index 0000000000..fce59b0855 --- /dev/null +++ b/test-mocha/common/entity/test_util.js @@ -0,0 +1,54 @@ +/* eslint-disable camelcase, no-param-reassign */ +let mockState = 1; + +export function createEntity(entity) { + mockState++; + entity.entity_id = entity.entity_id || `test.test_${mockState}`; + entity.last_changed = entity.last_changed || (new Date()).toISOString(); + entity.last_updated = entity.last_updated || entity.last_changed; + entity.attributes = entity.attributes || {}; + return entity; +} + +export function createGroup(entity) { + mockState++; + entity.entity_id = entity.entity_id || `group.test_${mockState}`; + entity.state = entity.state || 'on'; + entity.attributes = entity.attributes || {}; + if (!('order' in entity.attributes)) { + entity.attributes.order = 0; + } + return createEntity(entity); +} + +export function createView(entity) { + entity.attributes = entity.attributes || {}; + entity.attributes.view = true; + return createGroup(entity); +} + +export function createLightEntity(isOn) { + mockState++; + if (isOn === undefined) { + isOn = Math.random() > 0.5; + } + return createEntity({ + entity_id: `light.mock_${mockState}`, + state: isOn ? 'on' : 'off', + }); +} + +export function createEntities(count) { + const entities = {}; + for (let i = 0; i < count; i++) { + const entity = createLightEntity(); + entities[entity.entity_id] = entity; + } + return entities; +} + +export function entityMap(entityList) { + const entities = {}; + entityList.forEach((entity) => { entities[entity.entity_id] = entity; }); + return entities; +} diff --git a/test/state-card-display-test.html b/test/state-card-display-test.html index 353c654f9b..88d73e3ee2 100644 --- a/test/state-card-display-test.html +++ b/test/state-card-display-test.html @@ -4,11 +4,6 @@ - - diff --git a/yarn.lock b/yarn.lock index 2d9b28ee39..633022af4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6709,9 +6709,9 @@ hoek@4.x.x: version "4.2.1" resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.1.tgz#9634502aa12c445dd5a7c5734b572bb8738aacbb" -home-assistant-js-websocket@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/home-assistant-js-websocket/-/home-assistant-js-websocket-1.2.1.tgz#11229bed179da6b6e32e0c269793787a162748bf" +home-assistant-js-websocket@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/home-assistant-js-websocket/-/home-assistant-js-websocket-2.0.0.tgz#36dd29d6cf525efff7e463f0770c56c09536a829" home-or-tmp@^2.0.0: version "2.0.0"