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``;
+ }
+
+ 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