diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a9a9867 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: 'Build' + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + name: Test build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Build + run: | + npm install + npm run build diff --git a/.github/workflows/hacs.yml b/.github/workflows/hacs.yml new file mode 100644 index 0000000..241a7bb --- /dev/null +++ b/.github/workflows/hacs.yml @@ -0,0 +1,18 @@ +name: HACS Action + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + +jobs: + hacs: + name: HACS Action + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v2" + - name: HACS Action + uses: "hacs/action@main" + with: + category: "plugin" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d00ee4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules/ +package-lock.json +/dist diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b62a9b5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,194 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..68854ad --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Lovelace OpenSprinkler Card + +Collect [OpenSprinkler][opensprinkler] status into a card for [Home Assistant][home-assistant]. + +You will need the [OpenSprinkler integration][opensprinkler-integration] installed. + +![Screenshots](https://raw.githubusercontent.com/rianadon/opensprinkler-card/main/images/readme.png) + +:warning: **BEWARE**: There are bugs :bug: + +## Install + +I haven't published to [HACS][hacs] yet, so there's an extra step here: +1. Add this repository to custom repositories by clicking the rotated-90-degrees-ellipses icon in the upper right of HACS and selecting custom repositories. Enter https://github.com/rianadon/opensprinkler-card as the url and Lovelace as the category. +2. Click add repositories and search for "opensprinkler card". Install the card. + +If you don't have [HACS][hacs] installed, see [manual installation](#manual-installation). + +## Options + +| Name | Type | Requirement | Description | +| ----------------- | ------- | ------------ | ------------------------------------------- | +| type | string | **Required** | `custom:opensprinkler-card` | +| device | string | **Required** | Device id of the OpenSprinkler in Home Assistant. | +| name | string | **Optional** | Card title. | + +Finding device ids is tricky, so I recommend using the dropdown in the visual card editor rather than YAML. + +## Entity id requirements + +This card locates your OpenSprinkler entities by using their entity ids. If you haven't changed these, you have nothing to worry about. + +Otherwise, make sure: +- The ids of Station status sensors end with `_status` +- The ids of Program running binary sensors end with `_program_running` +- The id of the Opensprinkler Enable switch ends with `opensprinkler_enabled` +- The ids of program & station enabled switches end with `_enabled` + +## Manual installation + +1. Download `opensprinkler-card.js` from the [latest release][release] and move this file to the `config/www` folder. +2. Ensure you have advanced mode enabled (accessible via your username in the bottom left corner) +3. Go to Configuration -> Lovelace Dashboards -> Resources. +4. Add `/local/opensprinkler-card.js` with type JS module. +5. Refresh the page? Or restart Home Assistant? The card should eventually be there. + +[home-assistant]: https://github.com/home-assistant/home-assistant +[opensprinkler]: https://opensprinkler.com +[opensprinkler-integration]: https://github.com/vinteo/hass-opensprinkler +[hacs]: https://hacs.xyz/ +[release]: https://github.com/rianadon/oepnsprinkler-card/releases diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..017ae72 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "OpenSprinkler Card", + "render_readme": true, + "filename": "opensprinkler-card.js" +} diff --git a/images/readme.png b/images/readme.png new file mode 100644 index 0000000..6a72893 Binary files /dev/null and b/images/readme.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..84122c0 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "opensprinkler-card", + "version": "1.0.0", + "description": "OpenSprinkler status for Home Assistant", + "keywords": [ + "home-assistant", + "homeassistant", + "hass", + "automation", + "lovelace", + "custom-cards" + ], + "module": "opensprinkler-card.js", + "repository": "git@github.com:rianadon/opensprinkler-card.git", + "license": "Apache-2.0", + "dependencies": { + "@mdi/js": "^5.9.55", + "custom-card-helpers": "^1.7.1", + "home-assistant-js-websocket": "^5.10.0", + "lit": "^2.0.0-rc.2", + "timer-bar-card": "github:rianadon/timer-bar-card" + }, + "devDependencies": { + "@babel/core": "^7.14.6", + "@rollup/plugin-json": "^4.1.0", + "prettier": "^2.3.1", + "rollup": "^2.51.2", + "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": "^7.0.2", + "rollup-plugin-typescript2": "^0.30.0", + "typescript": "^4.3.2" + }, + "scripts": { + "start": "rollup -c rollup.config.dev.js --watch", + "build": "rollup -c" + } +} diff --git a/rollup.config.dev.js b/rollup.config.dev.js new file mode 100644 index 0000000..907ccc4 --- /dev/null +++ b/rollup.config.dev.js @@ -0,0 +1,32 @@ +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/opensprinkler-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 new file mode 100644 index 0000000..3954589 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,42 @@ +import typescript from 'rollup-plugin-typescript2'; +import commonjs from 'rollup-plugin-commonjs'; +import nodeResolve from 'rollup-plugin-node-resolve'; +import babel from 'rollup-plugin-babel'; +import { terser } from 'rollup-plugin-terser'; +import serve from 'rollup-plugin-serve'; +import json from '@rollup/plugin-json'; + +const dev = process.env.ROLLUP_WATCH; + +const serveopts = { + contentBase: ['./dist'], + host: '0.0.0.0', + port: 5000, + allowCrossOrigin: true, + headers: { + 'Access-Control-Allow-Origin': '*', + }, +}; + +const plugins = [ + nodeResolve({}), + commonjs(), + typescript(), + json(), + babel({ + exclude: 'node_modules/**', + }), + dev && serve(serveopts), + !dev && terser(), +]; + +export default [ + { + input: 'src/opensprinkler-card.ts', + output: { + dir: 'dist', + format: 'es', + }, + plugins: [...plugins], + }, +]; diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..e09c78a --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,162 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/camelcase */ +import { + LitElement, + html, + customElement, + property, + TemplateResult, + CSSResult, + css, + internalProperty, +} from 'lit-element'; +import { HomeAssistant, fireEvent, LovelaceCardEditor, ActionConfig } from 'custom-card-helpers'; + +import { OpensprinklerCardConfig } from './types'; + +const options = { + required: { + icon: 'tune', + name: 'Required', + secondary: 'Required options for this card to function', + } +}; + +@customElement('opensprinkler-card-editor') +export class BoilerplateCardEditor extends LitElement implements LovelaceCardEditor { + @property({ attribute: false }) public hass?: HomeAssistant; + @internalProperty() private _config?: OpensprinklerCardConfig; + @internalProperty() private _toggle?: boolean; + @internalProperty() private _helpers?: any; + private _initialized = false; + + public setConfig(config: OpensprinklerCardConfig): void { + this._config = config; + + this.loadCardHelpers(); + } + + protected shouldUpdate(): boolean { + if (!this._initialized) { + this._initialize(); + } + + return true; + } + + get _name(): string { + return this._config?.name || ''; + } + + get _device(): string | undefined { + return this._config?.device; + } + + get _show_warning(): boolean { + return this._config?.show_warning || false; + } + + get _show_error(): boolean { + return this._config?.show_error || false; + } + + protected render(): TemplateResult | void { + if (!this.hass || !this._helpers) { + return html``; + } + + const selector = { device: { integration: 'opensprinkler' } }; + + return html` +
+
+
+ +
${options.required.name}
+
+
${options.required.secondary}
+
+
+ + +
+
+ `; + } + + private _initialize(): void { + if (this.hass === undefined) return; + if (this._config === undefined) return; + if (this._helpers === undefined) return; + this._initialized = true; + } + + private async loadCardHelpers(): Promise { + this._helpers = await (window as any).loadCardHelpers(); + } + + private _valueChanged(ev: CustomEvent): void { + if (!this._config || !this.hass) return; + + const target = ev.target as any; + const value = ev.detail?.value ?? target.value; + + if (this[`_${target.configValue}`] === value) return; + + if (target.configValue) { + if (value === '') { + delete this._config[target.configValue]; + } else { + this._config = { + ...this._config, + [target.configValue]: target.checked !== undefined ? target.checked : value, + }; + } + } + fireEvent(this, 'config-changed', { config: this._config }); + } + + static get styles(): CSSResult { + return css` + .option { + padding: 4px 0px; + cursor: pointer; + } + .row { + display: flex; + margin-bottom: -14px; + pointer-events: none; + } + .title { + padding-left: 16px; + margin-top: -6px; + pointer-events: none; + } + .secondary { + padding-left: 40px; + color: var(--secondary-text-color); + pointer-events: none; + } + .values { + padding-left: 16px; + background: var(--secondary-background-color); + display: grid; + } + ha-formfield { + padding-bottom: 8px; + } + `; + } +} diff --git a/src/ha_entity_registry.ts b/src/ha_entity_registry.ts new file mode 100644 index 0000000..afaac04 --- /dev/null +++ b/src/ha_entity_registry.ts @@ -0,0 +1,122 @@ +/** + * This file is adapted from the Home Assistant frontend. + * See src/data/entity_registry.ts in the frontend repository. + * + * It has been modified to use public libraries rather than + * the internal Home Assistant ones. Some stuff has also been removed. +*/ + +import { Connection, createCollection } from "home-assistant-js-websocket"; +import { debounce, HomeAssistant } from "custom-card-helpers"; + +export interface EntityRegistryEntry { + entity_id: string; + name: string | null; + icon: string | null; + platform: string; + config_entry_id: string | null; + device_id: string | null; + area_id: string | null; + disabled_by: string | null; +} + +export interface ExtEntityRegistryEntry extends EntityRegistryEntry { + unique_id: string; + capabilities: Record; + original_name?: string; + original_icon?: string; +} + +export interface UpdateEntityRegistryEntryResult { + entity_entry: ExtEntityRegistryEntry; + reload_delay?: number; + require_restart?: boolean; +} + +export interface EntityRegistryEntryUpdateParams { + name?: string | null; + icon?: string | null; + area_id?: string | null; + disabled_by?: string | null; + new_entity_id?: string; +} + +export const findBatteryEntity = ( + hass: HomeAssistant, + entities: EntityRegistryEntry[] +): EntityRegistryEntry | undefined => + entities.find( + (entity) => + hass.states[entity.entity_id] && + hass.states[entity.entity_id].attributes.device_class === "battery" + ); + +export const findBatteryChargingEntity = ( + hass: HomeAssistant, + entities: EntityRegistryEntry[] +): EntityRegistryEntry | undefined => + entities.find( + (entity) => + hass.states[entity.entity_id] && + hass.states[entity.entity_id].attributes.device_class === + "battery_charging" + ); + +export const getExtendedEntityRegistryEntry = ( + hass: HomeAssistant, + entityId: string +): Promise => + hass.callWS({ + type: "config/entity_registry/get", + entity_id: entityId, + }); + +export const updateEntityRegistryEntry = ( + hass: HomeAssistant, + entityId: string, + updates: Partial +): Promise => + hass.callWS({ + type: "config/entity_registry/update", + entity_id: entityId, + ...updates, + }); + +export const removeEntityRegistryEntry = ( + hass: HomeAssistant, + entityId: string +): Promise => + hass.callWS({ + type: "config/entity_registry/remove", + entity_id: entityId, + }); + +export const fetchEntityRegistry = (conn) => + conn.sendMessagePromise({ + type: "config/entity_registry/list", + }); + +const subscribeEntityRegistryUpdates = (conn, store) => + conn.subscribeEvents( + debounce( + () => + fetchEntityRegistry(conn).then((entities) => + store.setState(entities, true) + ), + 500, + true + ), + "entity_registry_updated" + ); + +export const subscribeEntityRegistry = ( + conn: Connection, + onChange: (entities: EntityRegistryEntry[]) => void +) => + createCollection( + "_entityRegistry", + fetchEntityRegistry, + subscribeEntityRegistryUpdates, + conn, + onChange + ); diff --git a/src/ha_style.ts b/src/ha_style.ts new file mode 100644 index 0000000..2e914e9 --- /dev/null +++ b/src/ha_style.ts @@ -0,0 +1,162 @@ +/** + * Dialog styles from the Home Assistant frontend. + * These are copied verbatim, but just put into their own file. + */ + +import { css } from "lit"; + +export const haStyleDialog = css` + /* prevent clipping of positioned elements */ + paper-dialog-scrollable { + --paper-dialog-scrollable: { + -webkit-overflow-scrolling: auto; + } + } + + /* force smooth scrolling for iOS 10 */ + paper-dialog-scrollable.can-scroll { + --paper-dialog-scrollable: { + -webkit-overflow-scrolling: touch; + } + } + + .paper-dialog-buttons { + align-items: flex-end; + padding: 8px; + padding-bottom: max(env(safe-area-inset-bottom), 8px); + } + + @media all and (min-width: 450px) and (min-height: 500px) { + ha-paper-dialog { + min-width: 400px; + } + } + + @media all and (max-width: 450px), all and (max-height: 500px) { + paper-dialog, + ha-paper-dialog { + margin: 0; + width: calc( + 100% - env(safe-area-inset-right) - env(safe-area-inset-left) + ) !important; + min-width: calc( + 100% - env(safe-area-inset-right) - env(safe-area-inset-left) + ) !important; + max-width: calc( + 100% - env(safe-area-inset-right) - env(safe-area-inset-left) + ) !important; + max-height: calc(100% - var(--header-height)); + + position: fixed !important; + bottom: 0px; + left: env(safe-area-inset-left); + right: env(safe-area-inset-right); + overflow: scroll; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + } + } + + /* mwc-dialog (ha-dialog) styles */ + ha-dialog { + --mdc-dialog-min-width: 400px; + --mdc-dialog-max-width: 600px; + --mdc-dialog-heading-ink-color: var(--primary-text-color); + --mdc-dialog-content-ink-color: var(--primary-text-color); + --justify-action-buttons: space-between; + } + + ha-dialog .form { + padding-bottom: 24px; + color: var(--primary-text-color); + } + + a { + color: var(--primary-color); + } + + /* make dialog fullscreen on small screens */ + @media all and (max-width: 450px), all and (max-height: 500px) { + ha-dialog { + --mdc-dialog-min-width: calc( + 100vw - env(safe-area-inset-right) - env(safe-area-inset-left) + ); + --mdc-dialog-max-width: calc( + 100vw - env(safe-area-inset-right) - env(safe-area-inset-left) + ); + --mdc-dialog-min-height: 100%; + --mdc-dialog-max-height: 100%; + --mdc-shape-medium: 0px; + --vertial-align-dialog: flex-end; + } + } + mwc-button.warning { + --mdc-theme-primary: var(--error-color); + } + .error { + color: var(--error-color); + } +`; + +export const haStyleMoreInfo = css` + ha-dialog { + --dialog-surface-position: static; + --dialog-content-position: static; + } + + ha-header-bar { + --mdc-theme-on-primary: var(--primary-text-color); + --mdc-theme-primary: var(--mdc-theme-surface); + flex-shrink: 0; + display: block; + } + + @media all and (max-width: 450px), all and (max-height: 500px) { + ha-header-bar { + --mdc-theme-primary: var(--app-header-background-color); + --mdc-theme-on-primary: var(--app-header-text-color, white); + border-bottom: none; + } + } + + .heading { + border-bottom: 1px solid + var(--mdc-dialog-scroll-divider-color, rgba(0, 0, 0, 0.12)); + } + + @media all and (min-width: 451px) and (min-height: 501px) { + ha-dialog { + --mdc-dialog-max-width: 90vw; + } + + .content { + width: 352px; + } + + ha-header-bar { + width: 400px; + } + + .main-title { + overflow: hidden; + text-overflow: ellipsis; + cursor: default; + } + + :host([large]) .content { + width: calc(90vw - 48px); + } + + :host([large]) ha-dialog[data-domain="camera"] .content, + :host([large]) ha-header-bar { + width: 90vw; + } + } + + state-card-content, + ha-more-info-history, + ha-more-info-logbook:not(:last-child) { + display: block; + margin-bottom: 16px; + } +`; diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..6d2fb15 --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,28 @@ +import { HassEntity } from "./types"; + +export type EntitiesFunc = (predicate: (id: string) => boolean) => HassEntity[]; + +const MANUAL_ID = 99; +const RUN_ONCE_ID = 254; + +const WAITING_STATES = ['waiting']; +const ACTIVE_STATES = ['program', 'once_program', 'manual', 'on']; +const STOPPABLE_STATES = [...ACTIVE_STATES, ...WAITING_STATES] + +export const isStation = (id: string) => id.startsWith('sensor.') && id.endsWith('_status'); +export const isProgram = (id: string) => id.startsWith('binary_sensor.') && id.endsWith('_program_running'); +export const isController = (id: string) => id.startsWith('switch.') && id.endsWith('opensprinkler_enabled'); +export const isStationProgEnable = (id: string) => id.startsWith('switch.') && id.endsWith('_enabled'); + +export function hasRunOnce(entities: EntitiesFunc) { + return entities(isStation).some(e => e.attributes.running_program_id === RUN_ONCE_ID); +} +export function hasManual(entities: EntitiesFunc) { + return entities(isStation).some(e => e.attributes.running_program_id === MANUAL_ID); +} + +export const stateWaiting = (entity: HassEntity) => WAITING_STATES.includes(entity.state); +export const stateStoppable = (entity: HassEntity) => STOPPABLE_STATES.includes(entity.state); +export const stateActivated = (entity: HassEntity) => ACTIVE_STATES.includes(entity.state) + +export const osName = (entity: HassEntity) => entity.attributes.name; diff --git a/src/opensprinkler-card.ts b/src/opensprinkler-card.ts new file mode 100644 index 0000000..987ab38 --- /dev/null +++ b/src/opensprinkler-card.ts @@ -0,0 +1,163 @@ +import { LitElement, html, TemplateResult } from 'lit'; +import { customElement, state, property } from "lit/decorators"; +import { HomeAssistant, hasConfigOrEntityChanged, LovelaceCardEditor } from 'custom-card-helpers'; +import { PropertyValues } from 'lit-element'; +import { UnsubscribeFunc } from 'home-assistant-js-websocket'; + +import { fillConfig, TimerBarEntityRow } from 'timer-bar-card/src/timer-bar-entity-row'; +import { EntityRegistryEntry, subscribeEntityRegistry } from './ha_entity_registry'; +import type { OpensprinklerCardConfig, HassEntity } from './types'; +import "./editor"; +import "./opensprinkler-generic-entity-row"; +import "./opensprinkler-more-info-dialog"; +import { MoreInfoDialog } from './opensprinkler-more-info-dialog'; +import { EntitiesFunc, hasManual, hasRunOnce, isProgram, isStation, osName, stateActivated, stateWaiting } from './helpers'; + +// This puts your card into the UI card picker dialog +(window as any).customCards = (window as any).customCards || []; +(window as any).customCards.push({ + type: 'opensprinkler-card', + name: 'Opensprinkler Card', + description: 'Collect OpenSprinkler status into a card', +}); + +window.customElements.define('opensprinkler-timer-bar-entity-row', TimerBarEntityRow); + +@customElement('opensprinkler-card') +export class OpensprinklerCard extends LitElement { + + @property({ attribute: false }) public hass?: HomeAssistant; + @state() private config!: OpensprinklerCardConfig; + @state() private entities?: EntityRegistryEntry[]; + @state() private unsub?: UnsubscribeFunc; + @state() private dialog!: MoreInfoDialog; + + public static async getConfigElement(): Promise { + return document.createElement('opensprinkler-card-editor') as LovelaceCardEditor; + } + + public static getStubConfig(): object { + return {}; + } + + setConfig(config: OpensprinklerCardConfig): void { + if (!config) { + throw new Error("Invalid configuration"); + } + this.config = { + name: "Sprinkler", + ...config, + }; + } + + protected render(): TemplateResult | void { + if (!this.config.device) return html`No device specified`; + if (!this.entities) return html``; + + const config = { name: this.config.name, icon: 'mdi:sprinkler-variant', title: true }; + const entities = this._statusEntities(); + + return html` +
+ +
+ ${entities.map(s => this._renderStatus(s))} +
+
+
+ `; + } + + private _moreInfo() { + this.dialog.showDialog({ entityId: 'idk' }); + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + if (!this.config) return false; + if (this.config.entity) { + return hasConfigOrEntityChanged(this, changedProps, false); + } + + const oldHass = changedProps.get('hass') as HomeAssistant | undefined; + if (!oldHass) return true; + + for (const entity of this._matchingEntities(() => true)) { + if (oldHass.states[entity.entity_id] !== entity) return true; + } + + return false; + } + + public connectedCallback() { + super.connectedCallback(); + if (this.hass) this._subscribe(); + + this.dialog = new MoreInfoDialog(); + this.dialog.hass = this.hass!; + this.dialog.entities = this._matchingEntities.bind(this); + this.dialog.parent = this; + document.body.appendChild(this.dialog); + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + if (!this.unsub && changedProps.has("hass")) { + this._subscribe(); + } + if (changedProps.has("hass")) this.dialog.hass = this.hass!; + if (changedProps.has("config")) this.dialog.config = this.config; + } + + public disconnectedCallback() { + super.disconnectedCallback(); + if (this.unsub) this.unsub(); + this.unsub = undefined; + document.body.removeChild(this.dialog); + } + + private _subscribe() { + this.unsub = subscribeEntityRegistry(this.hass!.connection, entries => { + this.entities = entries; + }); + } + + private _matchingEntities(predicate: (id: string) => boolean) { + if (!this.entities || !this.hass) return []; + const entities = this.entities.filter(e => + e.device_id === this.config.device && predicate(e.entity_id)); + return entities.map(e => this.hass!.states[e.entity_id]); + } + + private _statusEntities() { + const status = this._matchingEntities(isStation); + return status.filter(stateActivated).concat(status.filter(stateWaiting)); + } + + private _renderStatus(e: HassEntity) { + const config = fillConfig({ + type: 'timer-bar-entity-row', + entity: e.entity_id, + icon: 'mdi:water-outline', + active_icon: 'mdi:water', + name: e.attributes.name, + }); + return html` + `; + } + + private _secondaryText() { + const entities: EntitiesFunc = p => this._matchingEntities(p) + + const programs = entities(isProgram).filter(stateActivated).map(osName); + if (hasRunOnce(entities)) programs.splice(0, 0, 'Once Program'); + if (hasManual(entities)) programs.push('Stations Manually'); + + if (programs.length > 0) return 'Running ' + programs.join(', '); + return ''; + } +} diff --git a/src/opensprinkler-generic-entity-row.ts b/src/opensprinkler-generic-entity-row.ts new file mode 100644 index 0000000..5b41f45 --- /dev/null +++ b/src/opensprinkler-generic-entity-row.ts @@ -0,0 +1,117 @@ +/** + * This is a modified version of hui-generic-entity-row from Home Assistant. + * A more info button is added, and the code has been simplified for the limitted + * needs of the OpenSprinkler card. + */ + +import { mdiDotsVertical } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, + PropertyValues, TemplateResult } from "lit"; +import { property } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { fireEvent, HomeAssistant, computeRTL } from "custom-card-helpers"; + +class OpensprinklerGenericEntityRow extends LitElement { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property() public config?: any; + + @property() public secondaryText?: string; + + protected render(): TemplateResult { + if (!this.hass || !this.config) { + return html``; + } + const hasSecondary = this.secondaryText || this.config.secondary_info; + const spanStyle = this.config.title ? "font-size: 1.1em" : ""; + + return html` + +
+ ${this.config.name} + ${hasSecondary + ? html`
${this.secondaryText}
` + : ""} +
+ + + + + `; + } + + protected updated(changedProps: PropertyValues): void { + super.updated(changedProps); + if (changedProps.has("hass")) { + this.toggleAttribute("rtl", computeRTL(this.hass!)); + } + } + + private _handleClick() { + fireEvent(this, 'hass-more-info', { entityId: this.config.entity }); + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: flex; + align-items: center; + flex-direction: row; + --mdc-icon-button-size: 40px; + } + .info { + margin-left: 16px; + margin-right: 8px; + flex: 1 1 30%; + } + .info, + .info > * { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .flex ::slotted(*) { + margin-left: 8px; + min-width: 0; + } + .flex ::slotted([slot="secondary"]) { + margin-left: 0; + } + .secondary, + ha-relative-time { + color: var(--secondary-text-color); + } + state-badge { + flex: 0 0 40px; + } + :host([rtl]) .flex { + margin-left: 0; + margin-right: 16px; + } + :host([rtl]) .flex ::slotted(*) { + margin-left: 0; + margin-right: 8px; + } + .more-info { + color: var(--secondary-text-color); + } + `; + } +} +customElements.define("opensprinkler-generic-entity-row", OpensprinklerGenericEntityRow); diff --git a/src/opensprinkler-more-info-dialog.ts b/src/opensprinkler-more-info-dialog.ts new file mode 100644 index 0000000..db61cdb --- /dev/null +++ b/src/opensprinkler-more-info-dialog.ts @@ -0,0 +1,293 @@ +import { mdiClose, mdiPlay, mdiStop } from "@mdi/js"; +import { css, html, LitElement, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { computeDomain, fireEvent, HomeAssistant } from "custom-card-helpers"; +import { OpensprinklerCardConfig, HassEntity } from "./types"; +import "./opensprinkler-state"; +import { OpensprinklerCard } from "./opensprinkler-card"; +import { haStyleDialog, haStyleMoreInfo } from "./ha_style"; +import { EntitiesFunc, hasRunOnce, isController, isProgram, + isStation, isStationProgEnable, stateStoppable } from "./helpers"; + +export interface MoreInfoDialogParams { + entityId: string | null; +} + +@customElement("opensprinkler-more-info-dialog") +export class MoreInfoDialog extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + @property({ attribute: false }) public entities!: EntitiesFunc; + @property({ attribute: false }) public parent!: OpensprinklerCard; + + @property({ type: Boolean, reflect: true }) public large = false; + + @property() public config!: OpensprinklerCardConfig; + + @state() private _entityId?: string | null; + + @state() private _loading?: string; + @state() private _stopping?: string; + + public showDialog(params: MoreInfoDialogParams) { + this._entityId = params.entityId; + if (!this._entityId) { + this.closeDialog(); + return; + } + this.large = false; + } + + public closeDialog() { + this._entityId = undefined; + // fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + if (changedProps.has("hass")) { + this._loading = undefined; + // Only mark a stop operation as complete when all stations have turned off + if (this.entities(isStation).every(s => s.state === 'idle')) + this._stopping = undefined; + } + } + + protected render() { + if (!this._entityId) { + return html``; + } + const entityId = 'binary_sensor.updater'; + const stateObj = this.hass.states[entityId]; + const domain = computeDomain(entityId); + + if (!stateObj) { + return html``; + } + + return html` + +
+ + + + +
+ ${this.config.name} +
+
+
+
+ + ${this._renderStates()} + + ${stateObj.attributes.restored + ? html` +

+ ${this.hass.localize( + "ui.dialogs.more_info_control.restored.not_provided" + )} +

+

+ ${this.hass.localize( + "ui.dialogs.more_info_control.restored.remove_intro" + )} +

+ ` + : ""} +
+
+ `; + } + + private _renderHeading(title: string) { + return html`
${title}
`; + } + + private _renderState(domain: string, suffix: string) { + const entity = this.entities(id => id.startsWith(domain+'.') && id.endsWith(suffix))[0]; + if (!entity) return html`Entity not found`; + const config = { entity: entity.entity_id, name: entity.attributes.friendly_name.replace('OpenSprinkler ', '') }; + return html``; + } + + private _renderControl(entity: HassEntity, enabled: boolean | undefined, config: any) { + const loading = entity.entity_id === this._loading || entity.entity_id === this._stopping; + if (typeof enabled === 'undefined') return html`Enable switch for entity not found`; + + return html` + ${config.state} + ${loading ? html`` + : html` this._toggleEntity(entity)} .disabled=${!enabled}> + + `} + `; + } + + private _renderStation(entity: HassEntity) { + const enabled = this._enabled(entity); + return this._renderControl(entity, enabled, { + entity: entity.entity_id, name: entity.attributes.name, + icon: this._stationIcon(entity), + state: _stationStatus(entity.state, enabled!), + }); + } + + private _renderProgram(entity: HassEntity) { + const enabled = this._enabled(entity); + return this._renderControl(entity, enabled, { + entity: entity.entity_id, name: entity.attributes.name, + icon: this._programIcon(entity), + state: _programStatus(entity.state, enabled!), + }); + } + + private _renderRunOnce() { + const entity = { entity_id: 'run_once', state: 'on' } as HassEntity; + return this._renderControl(entity, true, { + name: 'Run Once Program', + icon: 'mdi:auto-fix', + state: 'Running' + }); + } + + private _renderStates() { + return [ + this._renderState('switch', 'opensprinkler_enabled'), + this._renderState('sensor', 'flow_rate'), + this._renderState('binary_sensor', 'rain_delay_active'), + this._renderState('sensor', 'rain_delay_stop_time'), + this._renderState('sensor', 'water_level'), + this._renderState('binary_sensor', 'sensor_1_active'), + this._renderState('binary_sensor', 'sensor_2_active'), + this._renderHeading('Stations'), + ] + .concat(this.entities(isStation).map(s => { + return this._renderStation(s); + })) + .concat([ + this._renderHeading('Programs'), + hasRunOnce(this.entities) ? this._renderRunOnce() : html``, + ]) + .concat(this.entities(isProgram).map(s => { + return this._renderProgram(s); + })); + } + + private _enlarge() { + this.large = !this.large; + } + + private _moreInfo(e: CustomEvent) { + this.closeDialog(); + fireEvent(this.parent, "hass-more-info", e.detail); + } + + private _toggleEntity(entity: HassEntity) { + const service = stateStoppable(entity) ? 'stop' : 'run'; + let entity_id = entity.entity_id; + + const isStoppingProgram = service === 'stop' && entity.entity_id.endsWith('_program_running'); + + if (entity_id === 'run_once' || isStoppingProgram) { + this._stopping = entity_id; + entity_id = this.entities(isController)[0].entity_id; + } else { + this._loading = entity_id; + } + + this.hass.callService('opensprinkler', service, { entity_id }); + } + + static get styles() { + return [ + haStyleDialog, + haStyleMoreInfo, + css` + opensprinkler-state, opensprinkler-generic-entity-row { + height: 32px; + } + + .button { + color: var(--secondary-text-color); + --mdc-icon-button-size: 40px; + margin-right: -8px; + margin-left: 4px; + } + + mwc-circular-progress { + margin-left: 8px; + margin-right: -4px; + } + + opensprinkler-state { + color: var(--primary-text-color); + display: flex; + justify-content: center; + flex-direction: column; + } + + .header { + margin-left: 56px; + margin-top: 16px; + color: var(--secondary-text-color); + } + `, + ]; + } + + private _enabled(entity: HassEntity): boolean | undefined { + const switches = this.entities(isStationProgEnable); + return switches.find(e => ( + e.attributes.index == entity.attributes.index && + e.attributes.opensprinkler_type == entity.attributes.opensprinkler_type + ))?.state === 'on'; + } + + private _stationIcon(entity: HassEntity) { + let base = this._enabled(entity) ? 'mdi:water' : 'mdi:water-off'; + if (entity.state === 'program' || entity.state === 'manual' || entity.state === 'once_program') + return base; + return base + '-outline'; + } + + private _programIcon(entity: HassEntity) { + let base = this._enabled(entity) ? 'mdi:timer' : 'mdi:timer-off'; + if (entity.state === 'on') + return base; + return base + '-outline'; + } +} + +function _toggleIcon(entity: HassEntity) { + return stateStoppable(entity) ? mdiStop : mdiPlay; +} + +function _stationStatus(status: string, enabled: boolean) { + if (status === 'idle' && !enabled) return 'Disabled'; + if (status === 'once_program') return 'Once Program'; + return _capitalize(status); +} + +function _programStatus(status: string, enabled: boolean) { + if (status === 'off' && !enabled) return 'Disabled'; + return status === 'on' ? 'Running' : 'Off'; +} + +function _capitalize(word: string) { + return word[0].toUpperCase() + word.substring(1); +} diff --git a/src/opensprinkler-state.ts b/src/opensprinkler-state.ts new file mode 100644 index 0000000..18b21d8 --- /dev/null +++ b/src/opensprinkler-state.ts @@ -0,0 +1,34 @@ +import { EntityConfig, HomeAssistant } from "custom-card-helpers"; + +const ELEMENTS = { + 'switch': 'hui-toggle-entity-row', + 'sensor': 'hui-sensor-entity-row', + 'binary_sensor': 'hui-text-entity-row', +} + +export class OpensprinklerState extends HTMLElement { + private element?: any; + + private _config?: EntityConfig; + private _hass?: HomeAssistant; + + connectedCallback() { + const domain = this.getAttribute('domain')!; + this.element = document.createElement(ELEMENTS[domain]); + this.element.hass = this._hass; + this.element.setConfig(this._config); + this.appendChild(this.element); + } + + set hass(hass: HomeAssistant) { + this._hass = hass; + if (this.element) this.element.hass = hass; + } + + set config(config: EntityConfig) { + this._config = config; + if (this.element) this.element.setConfig(config); + } +} + +window.customElements.define('opensprinkler-state', OpensprinklerState); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..db8688e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,23 @@ +import { LovelaceCardConfig } from 'custom-card-helpers'; + +export declare type HassEntity = { + entity_id: string; + state: string; + last_changed: string; + last_updated: string; + context: { + id: string; + user_id: string | null; + }; + attributes: { + [key: string]: any; + }; +}; + + +// TODO Add your configuration elements here for type-checking +export interface OpensprinklerCardConfig extends LovelaceCardConfig { + type: string; + name?: string; + device?: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cfd5d3a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "esnext", + "moduleResolution": "node", + "lib": [ + "es2017", + "dom", + "dom.iterable" + ], + "noEmit": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "strict": true, + "noImplicitAny": false, + "skipLibCheck": true, + "resolveJsonModule": true, + "experimentalDecorators": true + } +} \ No newline at end of file