Repository: reptilex/tesla-style-solar-power-card Branch: master Commit: 4881d8e2e1f1 Files: 40 Total size: 202.2 KB Directory structure: gitextract_1qkmhazr/ ├── .editorconfig ├── .github/ │ └── workflows/ │ └── validate.yaml ├── .gitignore ├── LICENSE-2.0.txt ├── README.md ├── hacs.json ├── package.json ├── rollup.config.js ├── src/ │ ├── TeslaStyleSolarPowerCard.ts │ ├── components/ │ │ └── editor.ts │ ├── localize/ │ │ ├── languages/ │ │ │ ├── de.json │ │ │ └── en.json │ │ └── localize.ts │ ├── models/ │ │ ├── BubbleData.ts │ │ ├── SensorElement.ts │ │ └── TeslaStyleSolarPowerCardConfig.ts │ ├── services/ │ │ ├── HtmlResizeForPowerCard.ts │ │ └── htmlWriterForPowerCard.ts │ ├── translations/ │ │ ├── languages/ │ │ │ ├── de.json │ │ │ └── en.json │ │ └── localize.ts │ └── types.ts ├── tesla-style-solar-power-card.js ├── tesla-style-solar-power-card.ts ├── test/ │ ├── SensorElement.test.ts │ ├── battery.test.ts │ ├── batteryCharging.test.ts │ ├── batteryWithoutExtra.test.ts │ ├── colouringOfBubblesDependingOnProduction.test.ts │ ├── defaultConfig.test.ts │ ├── extraAppliances.test.ts │ ├── extraAppliancesNotInHouse.test.ts │ ├── gridFeed.test.ts │ ├── setters.ts │ ├── solarProduction.test.ts │ ├── tesla-style-solar-power-card.test.ts │ └── threshold.test.ts ├── tsconfig.json ├── web-dev-server.config.mjs └── web-test-runner.config.mjs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # EditorConfig helps developers define and maintain consistent # coding styles between different editors and IDEs # editorconfig.org root = true [*] # Change these settings to your own preference indent_style = space indent_size = 2 # We recommend you to keep these unchanged end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false [*.json] indent_size = 2 [*.{html,js,md}] block_comment_start = /** block_comment = * block_comment_end = */ ================================================ FILE: .github/workflows/validate.yaml ================================================ name: Validate on: push: pull_request: schedule: - cron: "0 0 * * *" jobs: validate: runs-on: "ubuntu-latest" steps: - uses: "actions/checkout@v2" - name: HACS validation uses: "hacs/integration/action@main" with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CATEGORY: "plugin" ================================================ FILE: .gitignore ================================================ ## editors /.idea /.vscode ## system files .DS_Store ## npm /node_modules/ /npm-debug.log ## testing /coverage/ ## temp folders /.tmp/ # build /_site/ /dist/ /out-tsc/ /build storybook-static *.d.ts LICENSE ================================================ FILE: LICENSE-2.0.txt ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ 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. ================================================ FILE: README.md ================================================ # tesla-style-solar-power-card > **⚠ WARNING: BREAKING CONFIG CHANGE** > **You have to define the FLOWS AGAIN!!** > Without defining each flow no line will show, read the usage part carefully (the bubbles can be clickable but this is optional) This is a [home-assistant](home-assistant.io) card for solar installations. It provides a tesla style graphic to see the flows of energy ((k)W). ### Table of contents * [Concept](#concept) * [HACS-Installation](#hacs-installation) * [Usage](#usage) * [Tesla Powerwall Usage](#tesla-powerwall-usage) * [Contributing](#contributing) ![tesla-style-card-animated-gif](https://github.com/reptilex/tesla-style-solar-power-card/blob/master/tesla-style-card-animation.gif) ## Concept I have tried to make it as generic as possible, for now there are 6 bubbles with 4 main icons and 2 extra appliances. The Four main icon values are controlled by the sum of the flows from and to them: 1. Grid 2. Generation (usually solar) 3. House 4. Battery The two optional appliances can be any consumer in the house, they are attached to the house. These two are controlled directly by their consumption. Meaning no flow sum is done. * appliance1_consumption_entity (car/heater ...) * appliance2_consumption_entity (car2/oven ...) The bubbles/icons can be configured to have an entity when clicked, but the numbers are calculated from the flows. You can show an extra entity text/value on the top part of the bubble. There are 7 main flows and 2 appliance flows. The main flows are: * generation_to_grid_entity * generation_to_battery_entity * generation_to_house_entity * grid_to_battery_entity * grid_to_house_entity * battery_to_grid_entity * battery_to_house_entity You need at least one, the placement of the main bubbles is fixed for now. Some will substract the value from one bubble and will add value to another bubble. For example: battery_to_house will substract from the battery bubble/icon and add to house bubble/icon. ## Optional clickable entities The clickable entities can be configured through these entities but are optional: * grid_entity * generation_entity (solar/wind ...) * battery_entity * house_entity This card started based on the card from [bessarabov animated consumption card](https://github.com/bessarabov/animated-consumption-card), thanks again for that work. Then was rewritten completely taking [boilerplate card](https://github.com/custom-cards/boilerplate-card) as a starting point but with typescript. I also borrowed a few ideas from [power-wheel-card](https://github.com/gurbyz/power-wheel-card) sadly not yet as many as I would like ;) ## Optional extra entities On top of the flows and clickable entity every bubble can have an extra value on top. To define those you need to add a sensor to any of theses entities: * battery_extra_entity * house_extra_entity * generation_extra_entity * grid_extra_entity I always have the battery current charge as the battery_extra_entity. In this case the battery icon will also change with the charge. # HACS-Installation 1. [install HACS](https://hacs.xyz/docs/installation/installation) if you don't have it yet 2. When installed go to HACS->Frontend->Explore & add repositories 3. search for "tesla style" 4. click on the tesla-style-solar-power-card 5. Install repository 6. Restart HA ### Manual Installation (hacs will do all this for you) 1. Add the card js file from the repo under your home assistant config in the www folder (create one if you don't have it yet). 2. Add a resource under lovelace (you have to enable advanced Mode in your user profile to see the resource tab([see here for this card](https://github.com/reptilex/tesla-style-solar-power-card/blob/master/add-card-resource.png)). 3. restart home assistant. 4. add a manual card with the lovelace gui and configure as seen below. # Usage ### Just a grid a house and a line Currently I have no minimum configuration, but some combinations might not make sense. I would advice to use the bubbles you want and the flows linked to the one's you are using. The left part of these examples is fixed, change the right part with your own sensors. There are no required entities, though your configuration can show strange results if you leave some combinations out. The sensor can be called whatever you want, they are powermeter sensors in Watt or Kilowatt (choose the same for all, it will create kw from it). __ALL SENSORS NEED TO BE POSITIVE VALUES__ A simple combination example: ```yml type: 'custom:tesla-style-solar-power-card' grid_consumption_entity: sensor.grid_consumption house_consumption_entity: sensor.house_consumption grid_to_house_entity: sensor.grid_consumption ``` This will allow you to have two bubbles that are clickable and the flow from grid to house, which will determine the values beneath the icons. ### Complete example with all details ```yml type: 'custom:tesla-style-solar-power-card' name: My Flows # 7 flows between bubbles grid_to_house_entity: sensor.grid_consumption grid_to_battery_entity: sensor.grid_battery_charge generation_to_grid_entity: sensor.grid_feed_in generation_to_battery_entity: sensor.battery_charging generation_to_house_entity: sensor.solar_consumption battery_to_house_entity: sensor.battery_consumption battery_to_grid_entity: sensor.battery_to_grid # extra values to show as text above icons battery_extra_entity: sensor.battery_charge house_extra_entity: sensor.current_temperature generation_extra_entity: sensor.percent_cloud_coverage grid_extra_entity: sensor.monthly_feed_in # optional appliances with consumption and extra values appliance1_consumption_entity: sensor.car_consumption appliance1_extra_entity: sensor.car_battery_state_of_charge appliance2_consumption_entity: sensor.heating_consumption appliance2_extra_entity: sensor.heating_operation # optional 4 main bubble icons for clickable entities grid_entity: sensor.grid_consumption house_entity: sensor.house_consumption generation_entity: sensor.solar_yield battery_entity: sensor.battery_consumption ``` If you define an extra entity for the battery bubble with the state of charge then the icon will be dynamically replaced with the value of that entity and will override the icon definition above. There a few configuration variables that change the behaviour: Heading: ```yml name: 'My Tesla Power Card!' ``` One to force W (Watt) instead of kW, set it to 1 to use it: ```yml show_w_not_kw: 1 ``` One to set a different speed for the moving dots, normal speed factor is 0.04 so stay near that number at first, 0.2 is really fast: ```yml speed_factor: 0.03 ``` One for the threshold from which W is converted to kW (the example below will change W into kilowatt from 5000 W onwards): ```yml threshold_in_k: 5 ``` threshold_in_k is not compatible with show_w_not_kw, the latter will overrule the threshold_in_k One to hide the lines not active to use it, please make sure everything is working before you hide the lines: ```yml hide_inactive_lines: 1 ``` One to add gaps for the power lines the way the energy panel from ha does it: ```yml show_gap: true ``` One to colour the house bubble depending on the highest flow: ```yml change_house_bubble_color_with_flow: 1 ``` One to not show moving circles but an energy flow diagramm (thicker lines when flow is higher): ```yml energy_flow_diagramm: 1 ``` There is a factor to make the lines thicker depending on your flow normaly it's 2: ```yml energy_flow_diagramm_line_factor: 2 ``` You can subtract the appliance values from the house value without affecting the line flow: ```yml house_without_appliances_values: 1 ``` Then there are 6 icon configuration variables: ```yml grid_icon: 'mdi:transmission-tower' generation_icon: 'mdi:solar-panel-large' house_icon: 'mdi:home' battery_icon: 'mdi:battery' appliance1_icon: 'mdi:car-sports' appliance2_icon: 'mdi:car-sports' ``` ### templates for missing sensors or for negative sensors Remember you can create template sensors if you are missing one like solar yield out of solar_consumption and grid_feed_in or if you are missing another one like home_consumption. Some inverters have positive and negative values, here all sensors need to be positive values, so create template sensors like: ```yml battery_consumption: value_template: '{% set batter_cons = sensor.powerwall_battery_now | int %} {% if batter_cons > 0 %} {{ batter_cons | int }} {% else %} 0 {% endif %}' device_class: power unit_of_measurement: W ``` # Tesla-Powerwall-Usage In order to use this card with the [Tesla Powerwall integration](https://www.home-assistant.io/integrations/powerwall/) you will need to create some additional sensors first. This card expects an entity with a positive numeric value per line shown on the screen. However the Tesla Powerwall integration creates sensors which go negative or positive depending on whether energy is being consumed from or feed into that particular meter. Fortunately this can be easily fixed with the addition of a few template sensors, the ones you would need to add are shown below. Note that these sensors assume the default names for each entity created by the Tesla Powerwall integration, if you've changed the names of your entities then you'll need to adjust the config accordingly: ```yaml # Templates for Actual Powerflow transfer charts (APF - Actual PowerFlow) # # For the math to add up a new Real House Load must be calculated and used, witch includes # the inverter consumption and excludes rounding errors and corrects inaccurate power readings. # # It never made sense that inbound power sometimes does not equal outbound power. This fixes it! # # Developed by AviadorLP modified for powerwall by purcell-lab # Correctly sets battery2grid & grid2battery flows # template: - sensor: # grid sensor must be negative when importing and positive when exporting - name: APF Grid Entity device_class: power state_class: measurement unit_of_measurement: W state: "{{ (0 - states('sensor.powerwall_site_now')|float(0)*1000)|int(0) }}" # sensor must always be 0 or positive (i think they always are) - name: APF House Entity device_class: power state_class: measurement unit_of_measurement: W state: "{{ (states('sensor.powerwall_load_now')|float(0)*1000)|int(0) }}" # sensor must always be 0 or positive (i think they always are) - name: APF Generation Entity device_class: power state_class: measurement unit_of_measurement: W state: "{{ (states('sensor.powerwall_solar_now')|float(0)*1000)|int(0) }}" # battery sensor must be positive when charging and negative when discharging - name: APF Battery Entity device_class: power state_class: measurement unit_of_measurement: W state: "{{ (0 - states('sensor.powerwall_battery_now')|float(0)*1000)|int(0) }}" # Required to reduce code later on - name: APF Grid Import device_class: power state_class: measurement unit_of_measurement: W state: > {% if states('sensor.apf_grid_entity')|int(default=0) < 0 %} {{ states('sensor.apf_grid_entity')|int(default=0)|abs }} {% else %} 0 {% endif %} # Inverter consumption and power losses due to Inverter transfers and power conversions (AC/DC) # excludes rounding errors made worst by the fact that some inverters round all sensors readings to INT # Occasionally this might be negative probably due to cumulative errors in not so accurate power readings. - name: APF Inverter Power Consumption device_class: power state_class: measurement unit_of_measurement: W state: "{{ states('sensor.apf_generation_entity')|int(default=0) - states('sensor.apf_battery_entity')|int(default=0) - states('sensor.apf_house_entity')|int(default=0) - states('sensor.apf_grid_entity')|int(default=0) }}" # Real House Load Includes Inverter consumption and transfer conversions and losses and rounding errors. # It never made sense that inbound power sometimes does not equal outbound power. This fixes it! - name: APF Real House Load device_class: power state_class: measurement unit_of_measurement: W state: "{{ states('sensor.apf_house_entity')|int(default=0) + states('sensor.apf_inverter_power_consumption')|int(default=0) }}" icon: mdi:home-lightning-bolt - name: APF Grid2House device_class: power state_class: measurement unit_of_measurement: W state: > {% if states('sensor.apf_grid_import')|int(default=0) > states('sensor.apf_real_house_load')|int(default=0) %} {{ states('sensor.apf_real_house_load')|int(default=0) }} {% else %} {{ states('sensor.apf_grid_import')|int(default=0) }} {% endif %} - name: APF Grid2Batt device_class: power state_class: measurement unit_of_measurement: W state: > {% if states('sensor.apf_grid_import')|int(default=0) > states('sensor.apf_real_house_load')|int(default=0) %} {{ states('sensor.apf_grid_import')|int(default=0) - states('sensor.apf_real_house_load')|int(default=0) }} {% else %} 0 {% endif %} - name: APF Batt2House device_class: power state_class: measurement unit_of_measurement: W state: > {% if states('sensor.apf_battery_entity')|int(default=0) < 0 %} {% if states('sensor.apf_battery_entity')|int(default=0)|abs > states('sensor.apf_real_house_load')|int(default=0) %} {{ states('sensor.apf_real_house_load')|int(default=0) }} {% else %} {{ states('sensor.apf_battery_entity')|int(default=0)|abs }} {% endif %} {% else %} 0 {% endif %} # This might be called house to grid, and can happen in rare circumstances, # like when the inverter is not able to do a precise adjustment of power fast enough # or when you want to force a discharge of the battery or something... # But it only happens with battery or other power generator users. - name: APF Batt2Grid device_class: power state_class: measurement unit_of_measurement: W state: > {% if states('sensor.apf_battery_entity')|int(default=0) < 0 %} {% if states('sensor.apf_battery_entity')|int(default=0)|abs > states('sensor.apf_real_house_load')|int(default=0) %} {{ states('sensor.apf_battery_entity')|int(default=0)|abs - states('sensor.apf_real_house_load')|int(default=0) }} {% else %} 0 {% endif %} {% else %} 0 {% endif %} - name: APF Solar2Grid device_class: power state_class: measurement unit_of_measurement: W state: > {% if states('sensor.apf_grid_entity')|int(default=0) > states('sensor.apf_batt2grid')|int(default=0) %} {{ states('sensor.apf_grid_entity')|int(default=0) - states('sensor.apf_batt2grid')|int(default=0) }} {% else %} 0 {% endif %} - name: APF Solar2House device_class: power state_class: measurement unit_of_measurement: W state: > {% if states('sensor.apf_generation_entity')|int(default=0) > 0 and states('sensor.apf_real_house_load')|int(default=0) > states('sensor.apf_batt2house')|int(default=0) + states('sensor.apf_grid_import')|int(default=0) %} {% if states('sensor.apf_generation_entity')|int(default=0) > states('sensor.apf_real_house_load')|int(default=0) - states('sensor.apf_batt2house')|int(default=0) - states('sensor.apf_grid2house')|int(default=0) %} {{ states('sensor.apf_real_house_load')|int(default=0) - states('sensor.apf_batt2house')|int(default=0) - states('sensor.apf_grid2house')|int(default=0) }} {% else %} {{ states('sensor.apf_generation_entity')|int(default=0) }} {% endif %} {% else %} 0 {% endif %} - name: APF Solar2Batt device_class: power state_class: measurement unit_of_measurement: W state: > {% if states('sensor.apf_generation_entity')|int(default=0) > 0 and states('sensor.apf_battery_entity')|int(default=0) > 0 %} {% if states('sensor.apf_battery_entity')|int(default=0) > states('sensor.apf_grid2batt')|int(default=0) %} {% if states('sensor.apf_generation_entity')|int(default=0) - states('sensor.apf_solar2house')|int(default=0) > states('sensor.apf_battery_entity')|int(default=0) - states('sensor.apf_grid2batt')|int(default=0) %} {{ states('sensor.apf_battery_entity')|int(default=0) - states('sensor.apf_grid2batt')|int(default=0) }} {% else %} {{ states('sensor.apf_generation_entity')|int(default=0) - states('sensor.apf_solar2house')|int(default=0) - states('sensor.apf_solar2grid')|int(default=0) }} {% endif %} {% else %} 0 {% endif %} {% else %} 0 {% endif %} ``` After you've included these sensors then you can configure the card like this: ```yaml type: 'custom:tesla-style-solar-power-card' grid_entity: sensor.apf_grid_entity house_entity: sensor.apf_real_house_load generation_entity: sensor.apf_generation_entity battery_entity: sensor.apf_battery_entity grid_to_house_entity: sensor.apf_grid2house grid_to_battery_entity: sensor.apf_grid2batt generation_to_grid_entity: sensor.apf_solar2grid generation_to_battery_entity: sensor.apf_solar2batt generation_to_house_entity: sensor.apf_solar2house battery_to_house_entity: sensor.apf_batt2house battery_to_grid_entity: sensor.apf_batt2grid battery_extra_entity: sensor.powerwall_charge ``` ## Releases v0.9 v0.92 vbeta1.1. ## Contributing Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. ## License Apache License V 2.0 ================================================ FILE: hacs.json ================================================ { "name": "Tesla style solar power card", "content_in_root": true, "filename": "tesla-style-solar-power-card.js", "render_readme": true } ================================================ FILE: package.json ================================================ { "name": "tesla-style-solar-power-card", "version": "0.0.0", "description": "Webcomponent tesla-style-solar-power-card following open-wc recommendations", "author": "tesla-style-solar-power-card", "license": "Apache-2.0", "main": "dist/index.js", "module": "dist/index.js", "scripts": { "start": "tsc && concurrently -k -r \"tsc --watch --preserveWatchOutput\" \"wds\"", "build": "rimraf dist && tsc && rollup -c rollup.config.js", "prepublish": "tsc", "lint": "eslint --ext .ts,.html . --ignore-path .gitignore && prettier \"**/*.ts\" --check --ignore-path .gitignore", "format": "eslint --ext .ts,.html . --fix --ignore-path .gitignore && prettier \"**/*.ts\" --write --ignore-path .gitignore", "test": "tsc && wtr --coverage", "test:watch": "tsc && concurrently -k -r \"tsc --watch --preserveWatchOutput\" \"wtr --watch\"" }, "dependencies": { "custom-card-helpers": "^1.7.0", "home-assistant-js-websocket": "^5.7.0", "lit": "^2.2.1" }, "devDependencies": { "@babel/eslint-plugin": "^7.12.13", "@open-wc/building-rollup": "^2.0.1", "@open-wc/eslint-config": "^4.2.0", "@open-wc/testing": "^3.1.2", "@rollup/plugin-node-resolve": "^13.1.3", "@types/convert-source-map": "^1.5.1", "@types/istanbul-reports": "^3.0.0", "@types/mocha": "^9.1.0", "@typescript-eslint/eslint-plugin": "^4.14.1", "@typescript-eslint/parser": "^4.14.1", "@web/dev-server": "^0.1.5", "@web/dev-server-legacy": "^0.1.7", "@web/test-runner": "^0.13.27", "@web/test-runner-commands": "^0.6.1", "concurrently": "^7.1.0", "deepmerge": "^4.2.2", "eslint": "^7.32.0", "eslint-config-prettier": "^7.2.0", "husky": "^7.0.4", "lint-staged": "^12.3.7", "prettier": "^2.6.2", "rimraf": "^3.0.2", "rollup": "^2.45.1", "rollup-plugin-terser": "^7.0.2", "rollup-plugin-typescript2": "^0.31.2", "sinon": "^13.0.1", "tslib": "^2.1.0", "typescript": "^4.1.3" }, "eslintConfig": { "parser": "@typescript-eslint/parser", "extends": [ "@open-wc/eslint-config", "eslint-config-prettier" ], "plugins": [ "@typescript-eslint" ], "rules": { "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": [ "error" ], "import/no-unresolved": "off", "import/extensions": [ "error", "always", { "ignorePackages": true } ] } }, "prettier": { "singleQuote": true, "arrowParens": "avoid" }, "husky": { "hooks": { "pre-commit": "lint-staged" } }, "lint-staged": { "*.ts": [ "eslint --fix", "prettier --write --print-width 140" ] } } ================================================ FILE: rollup.config.js ================================================ import typescript from 'rollup-plugin-typescript2' import resolve from '@rollup/plugin-node-resolve' import {terser} from 'rollup-plugin-terser'; import pkg from './package.json'; export default { input: 'tesla-style-solar-power-card.ts', output: [ { file: pkg.main, format: 'cjs', }, { file: pkg.module, format: 'es', }, { file: 'tesla-style-solar-power-card.js', format: 'iife', name: 'version', plugins: [terser()] } ], plugins: [ resolve(), typescript(), ], } ================================================ FILE: src/TeslaStyleSolarPowerCard.ts ================================================ /* eslint-disable no-restricted-globals, prefer-template, no-param-reassign, class-methods-use-this, lit-a11y/click-events-have-key-events, no-bitwise, import/extensions */ import { LitElement, html, TemplateResult, CSSResult, css } from 'lit'; import { property } from 'lit/decorators.js'; import { HomeAssistant, LovelaceCardConfig /* , LovelaceCardEditor */ } from 'custom-card-helpers'; import { TeslaStyleSolarPowerCardConfig } from './models/TeslaStyleSolarPowerCardConfig'; /* import './components/editor'; */ import { SensorElement } from './models/SensorElement'; import { BubbleData } from './models/BubbleData'; import { HtmlWriterForPowerCard } from './services/HtmlWriterForPowerCard'; import { HtmlResizeForPowerCard } from './services/HtmlResizeForPowerCard'; // import { localize } from './localize/localize'; // This puts your card into the UI card picker dialog (window as any).customCards = (window as any).customCards || []; (window as any).customCards.push({ type: 'tesla-style-solar-power-card', name: 'Tesla Style Solar Power Card', description: 'A Solar Power Visualization with svg paths that mimmicks the powerwall app of tesla 2', }); export class TeslaStyleSolarPowerCard extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property() private config!: TeslaStyleSolarPowerCardConfig; @property({ attribute: false }) public solarCardElements: Map = new Map(); @property() private oldWidth = 100; public pxRate = 4; private teslaCardElement?: HTMLElement; private htmlWriter: HtmlWriterForPowerCard = new HtmlWriterForPowerCard(this, this.hass); @property({ type: String }) title = 'Hey there'; @property({ type: Number }) counter = 5; __increment() { this.counter += 1; } private error: string = ''; public setConfig(config: LovelaceCardConfig): void { if (!config) { // throw new Error(localize('common.invalid_configuration')); } if (config.test_gui) { // getLovelace().setEditMode(true); } this.config = { ...config, }; if (this.config.grid_icon == null) this.config.grid_icon = 'mdi:transmission-tower'; if (this.config.generation_icon == null) this.config.generation_icon = 'mdi:solar-panel-large'; if (this.config.house_icon == null) this.config.house_icon = 'mdi:home'; if (this.config.battery_icon == null) this.config.battery_icon = 'mdi:battery-medium'; if (this.config.appliance1_icon == null) this.config.appliance1_icon = 'mdi:car-sports'; if (this.config.appliance2_icon == null) this.config.appliance2_icon = 'mdi:air-filter'; if (this.config.speed_factor == null) this.config.speed_factor = 0.04; this.createSolarCardElements(); if (!this.config.energy_flow_diagramm) { const obj = this; setInterval(this.animateCircles, 15, obj); } } private createSolarCardElements(): void { Object.keys(this.config).forEach(key => { if (this.config[key] != null && key.indexOf('_entity') > 5) { // only filled entity config elements const sensorName = this.config[key].toString(); this.solarCardElements.set(key, new SensorElement(sensorName, key)); } }); } public getCardSize() { return 5; } /* public static async getConfigElement(): Promise { return document.createElement('tesla-style-solar-power-card-editor'); } */ public static getStubConfig(): Record { return {}; } /* ** LitElement process functions ** */ async firstUpdated(): Promise { // Give the browser a chance to paint await new Promise(r => setTimeout(r, 0)); const realWidth = this.getBoundingClientRect().width this.oldWidth = HtmlResizeForPowerCard.changeStylesDependingOnWidth(this, this.solarCardElements, realWidth, this.oldWidth); } public connectedCallback(): void { super.connectedCallback(); this.redraw = this.redraw.bind(this); window.addEventListener('resize', this.redraw); } public shouldUpdate(changedProperties: any): boolean { let obj: any; obj = this; if (!this.config.energy_flow_diagramm) { requestAnimationFrame(timestamp => { obj.updateAllCircles(timestamp); }); } obj = this; // Update only when our values in hass changed let update = true; Array.from(changedProperties.keys()).some((propName: any) => { const oldValue = changedProperties.get(propName); if (propName === 'hass' && oldValue) { update = update && this.sensorChangeDetected(oldValue); } return !update; }); return update; } private sensorChangeDetected(oldValue: any): boolean { let change = false; this.solarCardElements.forEach((_solarSensor, key) => { if ( this.hass.states[this.config[key]] !== undefined && this.hass.states[this.config[key]].state !== oldValue.states[this.config[key]].state ) { change = true; } }); return change; } public async performUpdate(): Promise { this.error = ''; this.solarCardElements.forEach(solarSensor => { try { solarSensor.setValueAndUnitOfMeasurement( this.hass.states[solarSensor.entity].state, this.hass.states[solarSensor.entity].attributes.unit_of_measurement ); solarSensor.setSpeed(this.config.speed_factor); } catch (err) { this.error += " Configured '" + solarSensor.entity + "' entity was not found. "; } }); if (this.config.energy_flow_diagramm) { this.setEnergyFlowDiagramm(); } if (this.config.change_house_bubble_color_with_flow) { this.colourHouseBubbleDependingOnHighestInput(); } super.performUpdate(); } /* **** render functions ****** */ protected render(): TemplateResult | void { if (this.error !== '') return this._showError(); let newWidth = this.getBoundingClientRect().width; if(newWidth < 200) newWidth = 250; this.pxRate = newWidth / 100; let gap: number; if (this.config.show_gap !== undefined && this.config.show_gap) { gap = 2 * this.pxRate; } else { gap = 0; } const half = 22 * this.pxRate; // return html`
${this.writeGenerationIconBubble()}
${this.writeGridIconBubble()}
${this.htmlWriter.writeCircleAndLine( 'generation_to_house_entity', 'M' + (half - this.pxRate + gap) + ',0' + 'C' + (half - this.pxRate + gap) + ',' + (half - gap) + ' ' + (half - this.pxRate + gap) + ',' + (half - gap) + ' ' + half * 2 + ',' + (half - gap) )} ${this.htmlWriter.writeCircleAndLine( 'grid_to_house_entity', 'M0,' + half + ' ' + 'C' + (half - this.pxRate) + ',' + half + ' ' + (half - this.pxRate) + ',' + half + ' ' + (half - this.pxRate) * 2 + ',' + half )} ${this.htmlWriter.writeCircleAndLine( 'generation_to_grid_entity', 'M' + (half - this.pxRate - gap) + ',0 ' + 'C' + (half - this.pxRate - gap) + ',' + (half - gap) + ' ' + (half - this.pxRate - gap) + ',' + (half - gap) + ' 0,' + (half - gap) )} ${this.htmlWriter.writeCircleAndLine( 'grid_to_battery_entity', 'M0,' + (half + gap) + ' ' + 'C' + (half - this.pxRate - gap) + ',' + (half + gap) + ' ' + (half - this.pxRate - gap) + ',' + (half + gap) + ' ' + (half - this.pxRate - gap) + ',' + half * 2 )} ${this.htmlWriter.writeCircleAndLine( 'battery_to_grid_entity', 'M' + (half - this.pxRate - gap) + ',' + half * 2 + ' ' + 'C' + (half - this.pxRate - gap) + ',' + (half + gap) + ' ' + (half - this.pxRate - gap) + ',' + (half + gap) + ' ' + '0,' + (half + gap) )} ${this.htmlWriter.writeCircleAndLine( 'generation_to_battery_entity', 'M' + (half - this.pxRate) + ',0 ' + 'C' + (half - this.pxRate) + ',0 ' + (half - this.pxRate) + ',' + half * 2 + ' ' + (half - this.pxRate) + ',' + half * 2 )} ${this.htmlWriter.writeCircleAndLine( 'battery_to_house_entity', 'M' + (half - this.pxRate + gap) + ',' + half * 2 + ' ' + 'C' + (half - this.pxRate + gap) + ',' + (half + gap) + ' ' + (half - this.pxRate + gap) + ',' + (half + gap) + ' ' + half * 2 + ',' + (half + gap) )}
${this.writeHouseIconBubble()} ${this.writeApplianceIconBubble(1)} ${this.htmlWriter.writeAppliancePowerLineAndCircle(1, 'M5,' + 12 * this.pxRate + ' C5,' + 12 * this.pxRate + ' 5,0 5,0')} ${this.writeApplianceIconBubble(2)} ${this.htmlWriter.writeAppliancePowerLineAndCircle(2, 'M5,0 C5,0 5,' + 11 * this.pxRate + ' 5,' + 11 * this.pxRate)}
${this.writeBatteryIconBubble()}
`; } private writeGenerationIconBubble(): TemplateResult { const generationEntities = ['generation_to_grid_entity', 'generation_to_house_entity', 'generation_to_battery_entity']; const bubbleData:BubbleData = this.calculateIconBubbleData( generationEntities, 'generation_entity', 'generation_extra_entity'); bubbleData.cssSelector = 'acc_top'; bubbleData.icon = this.config.generation_icon; return this.htmlWriter.writeBatteryBubbleDiv(bubbleData); } private writeGridIconBubble(): TemplateResult { const gridEntities = ['-generation_to_grid_entity', 'grid_to_house_entity', '-battery_to_grid_entity', 'grid_to_battery_entity']; const bubbleData:BubbleData = this.calculateIconBubbleData( gridEntities, 'grid_entity', 'grid_extra_entity'); bubbleData.cssSelector = 'acc_left'; bubbleData.icon = this.config.grid_icon; return this.htmlWriter.writeBatteryBubbleDiv(bubbleData); } private writeHouseIconBubble(): TemplateResult { let houseEntities:Array; if(this.config.house_without_appliances_values){ houseEntities = ['generation_to_house_entity', 'grid_to_house_entity', 'battery_to_house_entity', '-appliance1_consumption_entity','-appliance2_consumption_entity']; } else { houseEntities = ['generation_to_house_entity', 'grid_to_house_entity', 'battery_to_house_entity']; } const bubbleData:BubbleData = this.calculateIconBubbleData( houseEntities, 'house_entity', 'house_extra_entity'); bubbleData.cssSelector = 'acc_right'; bubbleData.icon = this.config.house_icon; return this.htmlWriter.writeBatteryBubbleDiv(bubbleData); } private writeBatteryIconBubble(): TemplateResult { const batteryEntities = [ 'generation_to_battery_entity', 'grid_to_battery_entity', '-battery_to_house_entity', '-battery_to_grid_entity', ]; const bubbleData:BubbleData = this.calculateIconBubbleData( batteryEntities, 'battery_entity', 'battery_extra_entity'); bubbleData.cssSelector = 'acc_bottom'; bubbleData.icon = this.config.battery_icon; return this.htmlWriter.writeBatteryBubbleDiv(bubbleData); } private writeApplianceIconBubble(applianceNumber: number): TemplateResult { const applianceEntities = ['appliance' + applianceNumber + '_consumption_entity']; const bubbleData:BubbleData = this.calculateIconBubbleData( applianceEntities, 'appliance' + applianceNumber + '_consumption_entity', 'appliance' + applianceNumber + '_extra_entity'); bubbleData.cssSelector = 'acc_appliance' + applianceNumber; bubbleData.icon = this.config['appliance' + applianceNumber + '_icon']; return this.htmlWriter.writeBatteryBubbleDiv(bubbleData); } private calculateIconBubbleData( entitiesForMainValue: Array, bubbleClickEntitySlot: string | null = null, extraEntitySlot: string | null = null, ): BubbleData { let isSubstractionEntity = false; const bubbleData = new BubbleData; bubbleData.clickEntitySlot = bubbleClickEntitySlot; entitiesForMainValue.forEach((entityHolder: string) => { if (entityHolder.substring(0, 1) === '-') { entityHolder = entityHolder.substring(1); isSubstractionEntity = true; } const divSolarElement = this.solarCardElements.get(entityHolder); if (divSolarElement !== null && divSolarElement?.value !== undefined) { bubbleData.noEntitiesWithValueFound = false; bubbleData.mainValue = isSubstractionEntity ? (bubbleData.mainValue - divSolarElement?.value) : (bubbleData.mainValue + divSolarElement?.value); bubbleData.mainValue = ((bubbleData.mainValue * 100) | 0) / 100; bubbleData.mainUnitOfMeasurement = divSolarElement?.unitOfMeasurement; } isSubstractionEntity = false; }); if (extraEntitySlot !== null) { const extraEntity = this.solarCardElements.get(extraEntitySlot); bubbleData.extraValue = extraEntity?.value; bubbleData.extraUnitOfMeasurement = extraEntity?.unitOfMeasurement; } if (bubbleClickEntitySlot !== null) { bubbleData.clickEntityHassState = this.hass.states[this.config[bubbleClickEntitySlot]]; } if (this.showKW(bubbleData.mainValue)) { bubbleData.mainValue = this.roundValue(bubbleData.mainValue / 1000); bubbleData.mainUnitOfMeasurement = 'kW'; } return bubbleData; } private showKW(value: number) { if (this.config.show_w_not_kw) { return false; } if (this.config.threshold_in_k !== undefined && Math.abs(value) < this.config.threshold_in_k * 1000) { return false; } return true; } private roundValue(value: number): number { let roundedValue: number; if (value > 0.1) { roundedValue = (Math.round((value + Number.EPSILON) * 10) | 0) / 10; } else { roundedValue = (Math.round((value + Number.EPSILON) * 100) | 0) / 100; } return roundedValue; } private animateCircles(obj: any) { requestAnimationFrame(timestamp => { obj.updateAllCircles(timestamp); }); } public updateAllCircles(timestamp: number): void { // console.log('updating all circles') this.solarCardElements.forEach((_solarSensor, key) => { const element = this.solarCardElements.get(key); if (element !== undefined) this.updateOneCircle(timestamp, element); }); } private updateOneCircle(timestamp: number, entity: SensorElement) { if (this.shadowRoot == null) return; const teslaCardElement = this.shadowRoot.querySelector('#tesla-style-solar-power-card'); if (teslaCardElement == null) return; entity.line = teslaCardElement.querySelector('#' + entity.entitySlot + '_line'); if (entity.line === null) return; const lineLength = entity.line.getTotalLength(); if (isNaN(lineLength)) return; entity.circle = teslaCardElement.querySelector('#' + entity.entitySlot + '_circle'); if (entity.speed === 0) { entity.circle.setAttribute('visibility', 'hidden'); if (this.config.hide_inactive_lines) entity.line.setAttribute('visibility', 'hidden'); return; } entity.circle.setAttribute('visibility', 'visible'); if (this.config.hide_inactive_lines) { entity.line.setAttribute('visibility', 'visible'); } if (entity.prevTimestamp === 0) { entity.prevTimestamp = timestamp; entity.currentDelta = 0; } entity.currentDelta += Math.abs(entity.speed) * (timestamp - entity.prevTimestamp); let percentageDelta = entity.currentDelta / lineLength; if (entity.speed > 0) { if (percentageDelta >= 1 || isNaN(percentageDelta)) { entity.currentDelta = 0; percentageDelta = 0.01; } } else { percentageDelta = 1 - percentageDelta; if (percentageDelta <= 0 || isNaN(percentageDelta)) { entity.currentDelta = 0; percentageDelta = 1; } } const point = entity.line.getPointAtLength(lineLength * percentageDelta); entity.circle.setAttributeNS(null, 'cx', point.x.toString()); entity.circle.setAttributeNS(null, 'cy', point.y.toString()); entity.prevTimestamp = timestamp; } private colourHouseBubbleDependingOnHighestInput() { if (this.shadowRoot == null) return; const teslaCardElement = this.shadowRoot.querySelector('#tesla-style-solar-power-card'); if (teslaCardElement == null) return; const houseEntities = ['generation_to_house_entity', 'grid_to_house_entity', 'battery_to_house_entity']; let highestEntity: SensorElement | null = null; let highestEntityHolder = ''; houseEntities.forEach(entityHolder => { const divSolarElement = this.solarCardElements.get(entityHolder); if (divSolarElement !== null && divSolarElement?.value !== undefined) { if (highestEntity == null || divSolarElement?.value > highestEntity.value) { highestEntityHolder = entityHolder; highestEntity = divSolarElement; } } }); switch (highestEntityHolder) { case 'generation_to_house_entity': this.colourBubble('.house_entity', teslaCardElement, 'warning'); this.colourBubble('.appliance1_consumption_entity', teslaCardElement, 'warning'); this.colourBubble('.appliance2_consumption_entity', teslaCardElement, 'warning'); this.colourLineAndCircle('#appliance1_consumption_entity', teslaCardElement, 'warning'); this.colourLineAndCircle('#appliance2_consumption_entity', teslaCardElement, 'warning'); break; case 'battery_to_house_entity': this.colourBubble('.house_entity', teslaCardElement, 'success'); this.colourBubble('.appliance1_consumption_entity', teslaCardElement, 'success'); this.colourBubble('.appliance2_consumption_entity', teslaCardElement, 'success'); this.colourLineAndCircle('#appliance1_consumption_entity', teslaCardElement, 'success'); this.colourLineAndCircle('#appliance2_consumption_entity', teslaCardElement, 'success'); break; case 'grid_to_house_entity': this.colourBubble('.house_entity', teslaCardElement, 'info'); this.colourBubble('.appliance1_consumption_entity', teslaCardElement, 'info'); this.colourBubble('.appliance2_consumption_entity', teslaCardElement, 'info'); this.colourLineAndCircle('#appliance1_consumption_entity', teslaCardElement, 'info'); this.colourLineAndCircle('#appliance2_consumption_entity', teslaCardElement, 'info'); break; default: } } private colourBubble(elementName: string, teslaCardElement: HTMLElement, colour: string) { const element = teslaCardElement.querySelector(elementName); if (element === null) return; element.style.color = 'var(--' + colour + '-color)'; element.style.border = '1px solid var(--' + colour + '-color)'; } private colourLineAndCircle(elementName: string, teslaCardElement: HTMLElement, colour: string) { const elementLine = teslaCardElement.querySelector(elementName + '_line'); const elementCircle = teslaCardElement.querySelector(elementName + '_circle'); if (elementLine === null) return; elementLine.style.stroke = 'var(--' + colour + '-color)'; elementCircle.style.fill = 'var(--' + colour + '-color)'; } private setEnergyFlowDiagramm() { if (this.shadowRoot == null) return; const teslaCardElement = this.shadowRoot.querySelector('#tesla-style-solar-power-card'); if (teslaCardElement == null) return; this.solarCardElements.forEach((_solarSensor, key) => { const element = this.solarCardElements.get(key); let width = 1; if (teslaCardElement == null) return; const entityLine = teslaCardElement.querySelector('#' + key + '_line'); if (entityLine != null && element !== undefined) { const entityCircle = teslaCardElement.querySelector('#' + key + '_circle'); entityCircle.style.visibility = 'hidden'; if (this.config.energy_flow_diagramm_lines_factor === undefined) this.config.energy_flow_diagramm_lines_factor = 2; if (element?.unitOfMeasurement.toUpperCase() === 'W') { width = (Math.floor(element?.value / 100) / 10) * this.config.energy_flow_diagramm_lines_factor; } else { width = (Math.floor(element?.value * 10) / 10) * this.config.energy_flow_diagramm_lines_factor; } if (width <= 0.1 && width !== 0) width = 0.1; entityLine.style.strokeWidth = width + 'px'; } }); } private redraw(ev: UIEvent) { if (this.hass && this.config && ev.type === 'resize') { const realWidth = this.getBoundingClientRect().width this.oldWidth = HtmlResizeForPowerCard.changeStylesDependingOnWidth(this, this.solarCardElements, realWidth, this.oldWidth); } } /* ******* actions ******** */ private _showWarning(warning: string): TemplateResult { return html` ${warning} `; } private _showError(): TemplateResult { // const errorCard = document.createElement('hui-error-card'); // eslint-disable-next-line no-console console.log(this.error); return html`
ERROR:
${this.error}
`; } /* ******* style ******** */ static get styles(): CSSResult { return css` #tesla-style-solar-power-card{ margin:auto; display:table; padding: 10px; position: relative; } .acc_container { height: 40px; width: 40px; border: 1px solid black; border-radius: 100px; padding: 22px; color: var(--primary-text-color); border-color: var(--primary-text-color); position:relative; cursor:pointer; } .acc_icon { --mdc-icon-size: 40px; } .acc_text, .acc_text_extra { text-align: center; white-space: nowrap; } .acc_text_extra { overflow: hidden; position: absolute; } .acc_td { vertical-align: top; } .acc_center .acc_td{ position:relative; } .acc_top .acc_container, .acc_bottom .acc_container{ margin:auto; } .acc_center{ display:flex; } .acc_center_container{ display:inline-block; margin: 0px auto; margin-bottom:-5px; } .acc_right , .acc_left , .acc_line{ display:inline-block; margin-right:-4px } .acc_left { vertical-align: top; } .acc_right { margin-right:0px; } #battery_to_house_entity_line, #generation_to_house_entity_line, #grid_to_house_entity_line, #generation_to_battery_entity_line, #grid_feed_in_entity_line, #generation_to_grid_entity_line, #battery_to_grid_entity_line, #grid_to_battery_entity_line, #appliance1_consumption_entity_line, #appliance2_consumption_entity_line{ stroke:var(--info-color); fill:none; stroke-width:1; } .generation_entity { border: 1px solid var(--warning-color); } .generation_entity .acc_icon, .generation_entity{ color: var(--warning-color); } .house_entity{ border: 1px solid var(--info-color); } .appliance1_consumption_entity, .appliance2_consumption_entity { border: 1px solid var(--info-color); } .house_entity, .appliance1_consumption_entity, .appliance2_consumption_entity{ color: var(--info-color); } #generation_to_house_entity_line, #generation_to_grid_entity_line, #generation_to_battery_entity_line{ stroke:var(--warning-color); } #grid_to_battery_entity_circle, #grid_to_house_entity_circle, #appliance1_consumption_entity_circle, #appliance2_consumption_entity_circle{ fill:var(--info-color); } #generation_to_house_entity_circle, #generation_to_grid_entity_circle, #generation_to_battery_entity_circle{ fill:var(--warning-color); } #battery_to_house_entity_line, #battery_to_grid_entity_line{ stroke:var(--success-color); } #battery_to_house_entity_circle, #battery_to_grid_entity_circle{ fill:var(--success-color); } .battery_extra_entity, .battery_entity{ border: 1px solid var(--success-color); color: var(--success-color); } .battery_extra_text{ position:absolute; top:8px; } br.clear { clear:both; } .power_lines svg{ transform: translateZ(0); display:inline-block; } .acc_center .acc_td.acc_appliance1, .acc_center .acc_td.acc_appliance2 { position: absolute; right: 10px; `; } } ================================================ FILE: src/components/editor.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any, no-param-reassign, camelcase, lit/no-useless-template-literals, lit-a11y/click-events-have-key-events */ import { LitElement, html, TemplateResult, CSSResult, css } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { HomeAssistant, LovelaceCardEditor, ActionConfig, LovelaceCardConfig } from 'custom-card-helpers'; import '../types.js'; import { TeslaStyleSolarPowerCardConfig } from '../models/TeslaStyleSolarPowerCardConfig.js'; const options = { entities: { icon: 'tune', name: 'Entities', secondary: 'Entities for card to make sense, none are required but you should have a few.', show: false, }, actions: { icon: 'gesture-tap-hold', name: 'Actions', secondary: 'Perform actions based on tapping/clicking', show: false, options: { tap: { icon: 'gesture-tap', name: 'Tap', secondary: 'Set the action to perform on tap', show: false, }, hold: { icon: 'gesture-tap-hold', name: 'Hold', secondary: 'Set the action to perform on hold', show: false, }, double_tap: { icon: 'gesture-double-tap', name: 'Double Tap', secondary: 'Set the action to perform on double tap', show: false, }, }, }, appearance: { icon: 'palette', name: 'Appearance', secondary: 'Customize the name, icon, etc', show: false, }, }; @customElement('tesla-style-solar-power-card-editor') export class TeslaStyleSolarPowerCardEditor extends LitElement implements LovelaceCardEditor { @property({ attribute: false }) public hass?: HomeAssistant; @property() private _config?: LovelaceCardConfig; @property() private _toggle?: boolean; @property() private _helpers?: any; private _initialized = false; private _entityMap = new Map(); public setConfig(config: LovelaceCardConfig): void { this._config = config; // this._fillLineEntityMap(); this.loadCardHelpers(); } protected shouldUpdate(): boolean { if (!this._initialized) { this._initialize(); } return true; } get name(): string { return this._config?.name || ''; } // entities get home_entity(): string { return this._config?.home_entity || ''; } get battery_entity(): string { return this._config?.battery_consumption_entity || ''; } get grid_entity(): string { return this._config?.grid_entity || ''; } get generation_entity(): string { return this._config?.generation_entity || ''; } get home_extra_entity(): string { return this._config?.home_entity || ''; } get battery_extra_entity(): string { return this._config?.battery_consumption_entity || ''; } get grid_extra_entity(): string { return this._config?.grid_entity || ''; } get generation_extra_entity(): string { return this._config?.generation_entity || ''; } get grid_to_house_entity(): string { return this._config?.grid_to_house_entity || ''; } get grid_to_battery_entity(): string { return this._config?.grid_to_battery_entity || ''; } get battery_to_grid_in_entity(): string { return this._config?.battery_to_grid_entity || ''; } get generation_to_grid_entity(): string { return this._config?.grid_to_battery_entity || ''; } get generation_to_house_entity(): string { return this._config?.generation_entity || ''; } get generation_to_battery_entity(): string { return this._config?.generation_yield_entity || ''; } get appliance1_consumption_entity(): string { return this._config?.appliance1_consumption_entity || ''; } get appliance1_extra_entity(): string { return this._config?.appliance1_state_entity || ''; } get appliance2_consumption_entity(): string { return this._config?.appliance2_consumption_entity || ''; } get appliance2_extra_entity(): string { return this._config?.appliance2_state_entity || ''; } get show_w_not_kw(): boolean { return this._config?.show_w_not_kw || false; } get show_warning(): boolean { return this._config?.show_warning || false; } get show_error(): boolean { return this._config?.show_error || false; } get hide_inactive_lines(): boolean { return this._config?.hide_inactive_lines || false; } get tap_action(): ActionConfig { return this._config?.tap_action || { action: 'more-info' }; } get hold_action(): ActionConfig { return this._config?.hold_action || { action: 'none' }; } get double_tap_action(): ActionConfig { return this._config?.double_tap_action || { action: 'none' }; } protected render(): TemplateResult | void { if (!this.hass || !this._helpers) { return html``; } // The climate more-info has ha-switch and paper-dropdown-menu elements that are lazy loaded unless explicitly done here this._helpers.importMoreInfoControl('climate'); return html`
${options.entities.name}
${options.entities.secondary}
${options.entities.show ? html`
${Array.from(this._entityMap).map(entityArr => { const entityName: keyof TeslaStyleSolarPowerCardConfig = entityArr[0]; const entityFunction = this[`_${entityName}`]; return html` `; })}
` : ''}
${options.actions.name}
${options.actions.secondary}
${options.actions.show ? html`
${options.actions.options.tap.name}
${options.actions.options.tap.secondary}
${options.actions.options.tap.show ? html`
Action Editors Coming Soon
` : ''}
${options.actions.options.hold.name}
${options.actions.options.hold.secondary}
${options.actions.options.hold.show ? html`
Action Editors Coming Soon
` : ''}
${options.actions.options.double_tap.name}
${options.actions.options.double_tap.secondary}
${options.actions.options.double_tap.show ? html`
Action Editors Coming Soon
` : ''}
` : ''}
${options.appearance.name}
${options.appearance.secondary}
${options.appearance.show ? html`

` : ''}
`; } 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 _toggleAction(ev: any): void { this._toggleThing(ev, options.actions.options); } private _toggleOption(ev: any): void { this._toggleThing(ev, options); } private _toggleThing(ev: any, optionList: any): void { const show = !optionList[ev.target.option].show; for (const [key] of Object.entries(optionList)) { optionList[key].show = false; } optionList[ev.target.option].show = show; this._toggle = !this._toggle; } private _valueChanged(ev: any): void { if (!this._config || !this.hass) { return; } const { target } = ev; if (this[`_${target.configValue}`] === target.value) { return; } if (target.configValue) { if (target.value === '') { delete this._config[target.configValue]; } else { this._config = { ...this._config, [target.configValue]: target.checked !== undefined ? target.checked : target.value, }; } } // fireEvent(this, 'config-changed', { config: this._config }); // this is breaking the card built when terser is activated } private _fillLineEntityMap() { if (this._config !== undefined) { this._entityMap.set('home_entity', this._config.home_entity); this._entityMap.set('grid_entity', this._config.grid_entity); this._entityMap.set('generation_entity', this._config.generation_entity); this._entityMap.set('battery_entity', this._config.battery_entity); this._entityMap.set('grid_to_house_entity', this._config.grid_to_battery_entity); this._entityMap.set('grid_to_battery_entity', this._config.grid_to_battery_entity); this._entityMap.set('generation_to_grid_entity', this._config.grid_feed_in_entity); this._entityMap.set('generation_to_house_entity', this._config.generation_consumption_entity); this._entityMap.set('generation_to_battery_entity', this._config.generation_to_battery_entity); this._entityMap.set('battery_to_grid_entity', this._config.generation_consumption_entity); this._entityMap.set('battery_to_house_entity', this._config.generation_to_battery_entity); this._entityMap.set('battery_extra_entity', this._config.battery_extra_entity); this._entityMap.set('appliance1_consumption_entity', this._config.appliance1_entity); this._entityMap.set('appliance1_state_entity', this._config.appliance1_state_entity); this._entityMap.set('appliance2_consumption_entity', this._config.appliance2_entity); this._entityMap.set('appliance2_state_entity', this._config.appliance2_state_entity); } } private _fillIconEntityMap() { if (this._config !== undefined) { this._entityMap.set('grid_entity', this._config.grid_entity); this._entityMap.set('home_entity', this._config.home_entity); this._entityMap.set('generation_entity', this._config.generation_entity); this._entityMap.set('battery_entity', this._config.battery_entity); this._entityMap.set('appliance1_entity', this._config.appliance1_entity); this._entityMap.set('appliance2_entity', this._config.appliance2_entity); } } 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; } `; } } ================================================ FILE: src/localize/languages/de.json ================================================ { "common": { "version": "Version", "invalid_configuration": "Invalid configuration", "show_warning": "Show Warning", "show_error": "Show Error" } } ================================================ FILE: src/localize/languages/en.json ================================================ ================================================ FILE: src/localize/localize.ts ================================================ import * as en from './languages/en.json'; import * as nb from './languages/de.json'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const languages: any = { en, nb, }; export function localize(string: string, search = '', replace = ''): string { const lang = (localStorage.getItem('selectedLanguage') || 'en') .replace(/['"]+/g, '') .replace('-', '_'); let translated: string; try { translated = string.split('.').reduce((o, i) => o[i], languages[lang]); } catch (e) { translated = string.split('.').reduce((o, i) => o[i], languages.en); } if (translated === undefined) translated = string.split('.').reduce((o, i) => o[i], languages.en); if (search !== '' && replace !== '') { translated = translated.replace(search, replace); } return translated; } ================================================ FILE: src/models/BubbleData.ts ================================================ import { HassEntity } from 'home-assistant-js-websocket'; export class BubbleData { public mainValue: number = 0; public mainUnitOfMeasurement: string | undefined; public clickEntitySlot: string | null = null; public clickEntityHassState:HassEntity | null = null; public icon:string | undefined; public cssSelector: string | undefined; public extraValue: string | undefined; public extraUnitOfMeasurement: string | undefined; public noEntitiesWithValueFound = true; } ================================================ FILE: src/models/SensorElement.ts ================================================ /* eslint-disable class-methods-use-this, no-bitwise */ export class SensorElement { public speed = 0; public startPosition = 0; public currentPosition = 0; public currentDelta = 0; public maxPosition = 30; public value: any; public unitOfMeasurement = ''; public accText = ''; public accTextclassName = 'accText'; public entity = ''; public circle?: SVGPathElement; public line?: SVGPathElement; public color = 'stroke:var(--info-color)'; public circleColor = 'var(--primary-color)'; public prevTimestamp = 0; public accTextElement = null; public entitySlot: string; private static readonly SPEEDFACTOR = 0.04; constructor(entity: string, enitySlot: string) { this.entity = entity; this.entitySlot = enitySlot; this.value = 0; } public setValueAndUnitOfMeasurement(entityState: string | undefined, unitOfMeasurement: string | undefined): void { if (entityState === undefined) { this.value = 0; return; } if (unitOfMeasurement === undefined) { this.value = entityState; return; } const valueFromState = parseFloat(entityState); switch (unitOfMeasurement) { case 'W': case 'w': case 'kW': this.value = valueFromState; if (unitOfMeasurement === 'kW') { this.value *= 1000; } this.unitOfMeasurement = 'W'; this.value = Math.round(this.value); break; case '%': this.value = valueFromState; this.unitOfMeasurement = unitOfMeasurement; break; default: this.value = entityState; this.unitOfMeasurement = unitOfMeasurement; } } public setSpeed(factor: number | undefined): void { this.speed = 0; if (Math.abs(this.value) === 0) return; let speedFactor: number; if (factor === undefined || factor > 1 || factor <= 0) { speedFactor = SensorElement.SPEEDFACTOR; } else { speedFactor = factor; } this.speed = (speedFactor * this.value) / 1000; } } ================================================ FILE: src/models/TeslaStyleSolarPowerCardConfig.ts ================================================ /* eslint-disable camelcase */ import { ActionConfig, LovelaceCardConfig } from 'custom-card-helpers'; export interface TeslaStyleSolarPowerCardConfig extends LovelaceCardConfig { type: string; name?: string; show_header_toggle?: boolean; show_warning?: boolean; show_error?: boolean; show_gap?: boolean; test_gui?: boolean; show_w_not_kw?: any; hide_inactive_lines?: boolean; threshold_in_k?: number; speed_factor?: number; energy_flow_diagramm?: boolean; energy_flow_diagramm_lines_factor?: number; change_house_bubble_color_with_flow?: boolean; house_without_appliances_values?: boolean; grid_icon?: string; generation_icon?: string; house_icon?: string; battery_icon?: string; appliance1_icon?: string; appliance2_icon?: string; icon_entities?: Map; line_entities?: Map; house_entity?: string; battery_entity?: string; generation_entity?: string; grid_entity?: string; grid_to_house_entity?: string; grid_to_battery_entity?: string; generation_to_grid_entity?: string; generation_to_battery_entity?: string; generation_to_house_entity?: string; battery_to_house_entity?: string; battery_to_grid_entity?: string; grid_extra_entity?: string; generation_extra_entity?: string; house_extra_entity?: string; battery_extra_entity?: string; appliance1_consumption_entity?: string; appliance1_extra_entity?: string; appliance2_consumption_entity?: string; appliance2_extra_entity?: string; tap_action?: ActionConfig; hold_action?: ActionConfig; double_tap_action?: ActionConfig; } ================================================ FILE: src/services/HtmlResizeForPowerCard.ts ================================================ /* eslint-disable func-names, prefer-template, import/extensions, no-param-reassign, class-methods-use-this, lit-a11y/click-events-have-key-events */ import { SensorElement } from '../models/SensorElement'; import { TeslaStyleSolarPowerCard } from '../TeslaStyleSolarPowerCard'; export class HtmlResizeForPowerCard { public static changeStylesDependingOnWidth( teslaCard: TeslaStyleSolarPowerCard, solarCardElements: Map, newWidth: number, oldWidth: number ): number { if (document.readyState !== 'complete' || oldWidth === newWidth) return oldWidth; if (teslaCard.shadowRoot == null) return oldWidth; const teslaCardElement = ( teslaCard.shadowRoot.querySelector('#tesla-style-solar-power-card') ); if (teslaCardElement == null) return oldWidth; if(newWidth < 200) newWidth = 250; const pxRate = newWidth / 100; const changeSelectorStyle = function ( selector: string, styleAttribute: any, attributeValue: string ) { const selectorElement = ( teslaCardElement.querySelector(selector) ); if (selectorElement !== null) selectorElement.style[styleAttribute] = attributeValue; }; changeSelectorStyle('.acc_left', 'top', 12 * pxRate + 'px'); changeSelectorStyle('.acc_right', 'top', 12 * pxRate + 'px'); // icons teslaCardElement .querySelectorAll('.acc_container') .forEach((_currentValue, currentIndex, iconContainerItem) => { const iconContainer = iconContainerItem[currentIndex]; iconContainer.style.height = 9 * pxRate + 'px'; iconContainer.style.width = 9 * pxRate + 'px'; iconContainer.style.padding = 5 * pxRate + 'px'; }); teslaCardElement .querySelectorAll('ha-icon') .forEach((_currentValue, currentIndex, icons) => { const icon = ( icons[currentIndex].shadowRoot?.querySelector('ha-svg-icon') ); if (icon != null) { icon.style.height = 9 * pxRate + 'px'; icon.style.width = 9 * pxRate + 'px'; } }); teslaCardElement .querySelectorAll('.acc_text') .forEach(icontext => { icontext.style['font-size'] = 3 * pxRate + 'px'; icontext.style['margin-top'] = -0.5 * pxRate + 'px'; icontext.style.width = 10 * pxRate + 'px'; }); teslaCardElement .querySelectorAll('.acc_text_extra') .forEach(icontextExtra => { icontextExtra.style['font-size'] = 3 * pxRate + 'px'; icontextExtra.style.top = 1 * pxRate + 'px'; icontextExtra.style.width = 10 * pxRate + 'px'; }); // power lines changeSelectorStyle('.power_lines', 'height', 42 * pxRate + 'px'); changeSelectorStyle('.power_lines', 'width', 42 * pxRate + 'px'); changeSelectorStyle('.power_lines', 'top', 0 * pxRate + 'px'); changeSelectorStyle('.power_lines', 'left', 28 * pxRate + 'px'); changeSelectorStyle('.power_lines svg', 'width', 42 * pxRate + 'px'); changeSelectorStyle('.power_lines svg', 'height', 42 * pxRate + 'px'); changeSelectorStyle( '.power_lines svg', 'viewBox', '0 0 ' + 42 * pxRate + ' ' + 42 * pxRate ); let selectorElement = ( teslaCardElement.querySelector('.power_lines svg') ); if (selectorElement !== null) selectorElement.setAttribute( 'viewBox', '0 0 ' + 42 * pxRate + ' ' + 42 * pxRate ); const half = 22 * pxRate; changeSelectorStyle( '#generation_to_house_entity_line', 'd', 'M' + (half-pxRate) + ',0 C' + (half-pxRate) + ',' + half + ' ' + (half-pxRate) + ',' + half + ' ' + half * 2 + ',' + half ); changeSelectorStyle( '#grid_feed_in_entity_line', 'd', 'M' + (half-pxRate) + ',0 C' + (half-pxRate) + ',' + half + ' ' + (half-pxRate) + ',' + half + ' 0,' + half ); changeSelectorStyle( '#grid_to_house_entity_line', 'd', 'M0,' + half + ' C' + half + ',' + half + ' ' + half + ',' + half + ' ' + half * 2 + ',' + half ); changeSelectorStyle( '#grid_to_battery_entity_line', 'd', 'M0,' + half + ' C' + half + ',' + half + ' ' + half + ',' + half + ' ' + half + ',' + half * 2 ); changeSelectorStyle( '#battery_to_house_entity_line', 'd', 'M' + (half-pxRate) + ',' + half * 2 + ' C' + (half-pxRate) + ',' + half + ' ' + (half-pxRate) + ',' + half + ' ' + half * 2 + ',' + half ); changeSelectorStyle( '#generation_to_battery_entity_line', 'd', 'M' + (half-pxRate) + ',0 C' + (half-pxRate) + ',0 ' + (half-pxRate) + ',' + half * 2 + ' ' + (half-pxRate) + ',' + half * 2 ); // appliances [1, 2].forEach(value => { changeSelectorStyle( '.acc_appliance' + value + '_line svg', 'viewBox', '0 0 ' + ((12*pxRate)-((value-1)*5)) + ' ' + ((12*pxRate)-((value-1)*5)) ); changeSelectorStyle( '.acc_appliance' + value + '_line', 'right', (9.5 * pxRate) + 10 + 'px' ); changeSelectorStyle( '.acc_appliance' + value + '_line', 'width', '10px' ); changeSelectorStyle( '.acc_appliance' + value + '_line', 'height', (12 * pxRate ) - (( value - 1) * 5) + 'px' ); changeSelectorStyle( '.acc_appliance' + value + '_line svg', 'width', '10px' ); changeSelectorStyle( '.acc_appliance' + value + '_line svg', 'height', (12 * pxRate ) - (( value - 1) * 5) + 'px' ); selectorElement = ( teslaCardElement.querySelector('.acc_appliance' + value + '_line_svg') ); if (selectorElement !== null) selectorElement.setAttribute( 'viewBox', '0 0 ' + ((12*pxRate)-((value-1)*5)) + ' ' + ((12*pxRate)-((value-1)*5)) ); const topElement = ( teslaCardElement.querySelector('.generation_entity') ); if (topElement === null && value === 1 && selectorElement !== null) { changeSelectorStyle( '.acc_center_container', 'margin-top', 19 * pxRate + 'px' ); } const bottomElement = ( teslaCardElement.querySelector('.battery_entity') ); if (bottomElement === null && value === 2 && selectorElement !== null) { changeSelectorStyle( '.acc_center_container', 'margin-bottom', 19 * pxRate + 'px' ); } }); const gridElement = ( teslaCardElement.querySelector('.grid_entity') ); if (gridElement === null) { changeSelectorStyle('.generation_entity', 'margin', '0px'); changeSelectorStyle('.battery_entity', 'margin', '0px'); changeSelectorStyle('.power_lines', 'width', 30 * pxRate + 'px'); selectorElement = ( teslaCardElement.querySelector('.power_lines svg') ); if (selectorElement !== null) selectorElement.setAttribute( 'viewBox', 12 * pxRate + ' 0 ' + 42 * pxRate + ' ' + 42 * pxRate ); } changeSelectorStyle('.acc_appliance1', 'top', 10 + 'px'); changeSelectorStyle('.acc_appliance1_line', 'top', 19 * pxRate + 12 + 'px'); changeSelectorStyle('.acc_appliance2', 'bottom', 10 + 'px'); changeSelectorStyle('.acc_appliance2_line', 'bottom', 19 * pxRate + 12 +'px'); return newWidth; } } ================================================ FILE: src/services/htmlWriterForPowerCard.ts ================================================ /* eslint-disable no-param-reassign, import/extensions, prefer-template, class-methods-use-this, lit-a11y/click-events-have-key-events, lines-between-class-members */ import { html, TemplateResult } from 'lit'; import { HomeAssistant } from 'custom-card-helpers'; import { HassEntity } from 'home-assistant-js-websocket'; import { SensorElement } from '../models/SensorElement'; import { TeslaStyleSolarPowerCard } from '../TeslaStyleSolarPowerCard'; import { BubbleData } from '../models/BubbleData'; export class HtmlWriterForPowerCard { private teslaCard: TeslaStyleSolarPowerCard; private solarCardElements: Map; private pxRate: number; private hass: HomeAssistant; public constructor(teslaCard: TeslaStyleSolarPowerCard, hass: HomeAssistant) { this.teslaCard = teslaCard; this.solarCardElements = teslaCard.solarCardElements; this.pxRate = teslaCard.pxRate; this.hass = hass; } public writeBubbleDiv(bubbleData: BubbleData ): TemplateResult { if(bubbleData.noEntitiesWithValueFound) return html``; return html`
${bubbleData.extraValue !== null ? html`
${bubbleData.extraValue} ${bubbleData.extraUnitOfMeasurement}
` : html``}
${bubbleData.mainValue} ${bubbleData.mainUnitOfMeasurement}
`; } public writeBatteryBubbleDiv(bubbleData:BubbleData): TemplateResult { if (bubbleData.extraValue !== undefined) { if (bubbleData.icon === 'mdi:battery-medium' || bubbleData.icon === 'mdi:battery'){ bubbleData.icon = this.getBatteryIcon(parseFloat(bubbleData.extraValue), bubbleData.mainValue); } } return this.writeBubbleDiv(bubbleData); } private getBatteryIcon(batteryValue: number, batteryChargeDischargeValue: number) { let TempSocValue = batteryValue; if (batteryValue <= 5) TempSocValue = 0; const batteryStateRoundedValue = Math.ceil(TempSocValue / 10) * 10; let batteryStateIconString = '-' + batteryStateRoundedValue.toString(); // show charging icon beside battery state let batteryCharging: string = '-charging'; if (batteryChargeDischargeValue <= 0) { batteryCharging = ''; } if (batteryStateRoundedValue === 100) batteryStateIconString = ''; // full if (batteryStateRoundedValue <= 5) batteryStateIconString = '-outline'; // empty return 'mdi:battery' + batteryCharging + batteryStateIconString; } public writeAppliancePowerLineAndCircle(applianceNumber: number, pathDAttribute: string) { const divEntity = this.solarCardElements.get('appliance' + applianceNumber + '_consumption_entity'); if (divEntity == null) return html``; const height = 12; let verticalPosition: string; if (applianceNumber === 1) { verticalPosition = 'top:' + 22.5 * this.pxRate + 'px;'; } else { verticalPosition = 'bottom:' + 15 * this.pxRate + 'px;'; } return html`
${this.writeCircleAndLine('appliance' + applianceNumber + '_consumption_entity', pathDAttribute)}
`; } public writeCircleAndLine(sensorName: string, pathDAttribute: string) { const entity = this.solarCardElements.get(sensorName); if (entity == null) return html``; return html` `; } private _handleClick(stateObj: HassEntity | null) { if (stateObj == null) return; const event = new Event('hass-more-info', { bubbles: true, cancelable: true, composed: true, }); event.detail = { entityId: stateObj.entity_id }; if (this.teslaCard.shadowRoot == null) return; this.teslaCard.shadowRoot.dispatchEvent(event); } } ================================================ FILE: src/translations/languages/de.json ================================================ { "common": { "version": "Version", "invalid_configuration": "Invalid configuration", "show_warning": "Show Warning", "show_error": "Show Error" } } ================================================ FILE: src/translations/languages/en.json ================================================ ================================================ FILE: src/translations/localize.ts ================================================ import * as en from './languages/en.json'; import * as nb from './languages/de.json'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const languages: any = { en, nb, }; export function localize(string: string, search = '', replace = ''): string { const lang = (localStorage.getItem('selectedLanguage') || 'en') .replace(/['"]+/g, '') .replace('-', '_'); let translated: string; try { translated = string.split('.').reduce((o, i) => o[i], languages[lang]); } catch (e) { translated = string.split('.').reduce((o, i) => o[i], languages.en); } if (translated === undefined) translated = string.split('.').reduce((o, i) => o[i], languages.en); if (search !== '' && replace !== '') { translated = translated.replace(search, replace); } return translated; } ================================================ FILE: src/types.ts ================================================ import { LovelaceCard, LovelaceCardEditor } from 'custom-card-helpers'; declare global { interface HTMLElementTagNameMap { 'tesla-style-solar-power-card-editor': LovelaceCardEditor; 'hui-error-card': LovelaceCard; } } ================================================ FILE: tesla-style-solar-power-card.js ================================================ !function(){"use strict"; /*! ***************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */function t(t,e,i,n){var s,o=arguments.length,r=o<3?e:null===n?n=Object.getOwnPropertyDescriptor(e,i):n;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(t,e,i,n);else for(var l=t.length-1;l>=0;l--)(s=t[l])&&(r=(o<3?s(r):o>3?s(e,i,r):s(e,i))||r);return o>3&&r&&Object.defineProperty(e,i,r),r /** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */}const e=window.ShadowRoot&&(void 0===window.ShadyCSS||window.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),n=new Map;class s{constructor(t,e){if(this._$cssResult$=!0,e!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t}get styleSheet(){let t=n.get(this.cssText);return e&&void 0===t&&(n.set(this.cssText,t=new CSSStyleSheet),t.replaceSync(this.cssText)),t}toString(){return this.cssText}}const o=(t,...e)=>{const n=1===t.length?t[0]:e.reduce(((e,i,n)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[n+1]),t[0]);return new s(n,i)},r=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new s("string"==typeof t?t:t+"",i))(e)})(t):t /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */;var l;const a=window.trustedTypes,c=a?a.emptyScript:"",h=window.reactiveElementPolyfillSupport,u={toAttribute(t,e){switch(e){case Boolean:t=t?c:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},d=(t,e)=>e!==t&&(e==e||t==t),p={attribute:!0,type:String,converter:u,reflect:!1,hasChanged:d};class _ extends HTMLElement{constructor(){super(),this._$Et=new Map,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Ei=null,this.o()}static addInitializer(t){var e;null!==(e=this.l)&&void 0!==e||(this.l=[]),this.l.push(t)}static get observedAttributes(){this.finalize();const t=[];return this.elementProperties.forEach(((e,i)=>{const n=this._$Eh(i,e);void 0!==n&&(this._$Eu.set(n,i),t.push(n))})),t}static createProperty(t,e=p){if(e.state&&(e.attribute=!1),this.finalize(),this.elementProperties.set(t,e),!e.noAccessor&&!this.prototype.hasOwnProperty(t)){const i="symbol"==typeof t?Symbol():"__"+t,n=this.getPropertyDescriptor(t,i,e);void 0!==n&&Object.defineProperty(this.prototype,t,n)}}static getPropertyDescriptor(t,e,i){return{get(){return this[e]},set(n){const s=this[t];this[e]=n,this.requestUpdate(t,s,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)||p}static finalize(){if(this.hasOwnProperty("finalized"))return!1;this.finalized=!0;const t=Object.getPrototypeOf(this);if(t.finalize(),this.elementProperties=new Map(t.elementProperties),this._$Eu=new Map,this.hasOwnProperty("properties")){const t=this.properties,e=[...Object.getOwnPropertyNames(t),...Object.getOwnPropertySymbols(t)];for(const i of e)this.createProperty(i,t[i])}return this.elementStyles=this.finalizeStyles(this.styles),!0}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(r(t))}else void 0!==t&&e.push(r(t));return e}static _$Eh(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}o(){var t;this._$Ep=new Promise((t=>this.enableUpdating=t)),this._$AL=new Map,this._$Em(),this.requestUpdate(),null===(t=this.constructor.l)||void 0===t||t.forEach((t=>t(this)))}addController(t){var e,i;(null!==(e=this._$Eg)&&void 0!==e?e:this._$Eg=[]).push(t),void 0!==this.renderRoot&&this.isConnected&&(null===(i=t.hostConnected)||void 0===i||i.call(t))}removeController(t){var e;null===(e=this._$Eg)||void 0===e||e.splice(this._$Eg.indexOf(t)>>>0,1)}_$Em(){this.constructor.elementProperties.forEach(((t,e)=>{this.hasOwnProperty(e)&&(this._$Et.set(e,this[e]),delete this[e])}))}createRenderRoot(){var t;const i=null!==(t=this.shadowRoot)&&void 0!==t?t:this.attachShadow(this.constructor.shadowRootOptions);return((t,i)=>{e?t.adoptedStyleSheets=i.map((t=>t instanceof CSSStyleSheet?t:t.styleSheet)):i.forEach((e=>{const i=document.createElement("style"),n=window.litNonce;void 0!==n&&i.setAttribute("nonce",n),i.textContent=e.cssText,t.appendChild(i)}))})(i,this.constructor.elementStyles),i}connectedCallback(){var t;void 0===this.renderRoot&&(this.renderRoot=this.createRenderRoot()),this.enableUpdating(!0),null===(t=this._$Eg)||void 0===t||t.forEach((t=>{var e;return null===(e=t.hostConnected)||void 0===e?void 0:e.call(t)}))}enableUpdating(t){}disconnectedCallback(){var t;null===(t=this._$Eg)||void 0===t||t.forEach((t=>{var e;return null===(e=t.hostDisconnected)||void 0===e?void 0:e.call(t)}))}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ES(t,e,i=p){var n,s;const o=this.constructor._$Eh(t,i);if(void 0!==o&&!0===i.reflect){const r=(null!==(s=null===(n=i.converter)||void 0===n?void 0:n.toAttribute)&&void 0!==s?s:u.toAttribute)(e,i.type);this._$Ei=t,null==r?this.removeAttribute(o):this.setAttribute(o,r),this._$Ei=null}}_$AK(t,e){var i,n,s;const o=this.constructor,r=o._$Eu.get(t);if(void 0!==r&&this._$Ei!==r){const t=o.getPropertyOptions(r),l=t.converter,a=null!==(s=null!==(n=null===(i=l)||void 0===i?void 0:i.fromAttribute)&&void 0!==n?n:"function"==typeof l?l:null)&&void 0!==s?s:u.fromAttribute;this._$Ei=r,this[r]=a(e,t.type),this._$Ei=null}}requestUpdate(t,e,i){let n=!0;void 0!==t&&(((i=i||this.constructor.getPropertyOptions(t)).hasChanged||d)(this[t],e)?(this._$AL.has(t)||this._$AL.set(t,e),!0===i.reflect&&this._$Ei!==t&&(void 0===this._$EC&&(this._$EC=new Map),this._$EC.set(t,i))):n=!1),!this.isUpdatePending&&n&&(this._$Ep=this._$E_())}async _$E_(){this.isUpdatePending=!0;try{await this._$Ep}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){var t;if(!this.isUpdatePending)return;this.hasUpdated,this._$Et&&(this._$Et.forEach(((t,e)=>this[e]=t)),this._$Et=void 0);let e=!1;const i=this._$AL;try{e=this.shouldUpdate(i),e?(this.willUpdate(i),null===(t=this._$Eg)||void 0===t||t.forEach((t=>{var e;return null===(e=t.hostUpdate)||void 0===e?void 0:e.call(t)})),this.update(i)):this._$EU()}catch(t){throw e=!1,this._$EU(),t}e&&this._$AE(i)}willUpdate(t){}_$AE(t){var e;null===(e=this._$Eg)||void 0===e||e.forEach((t=>{var e;return null===(e=t.hostUpdated)||void 0===e?void 0:e.call(t)})),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EU(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$Ep}shouldUpdate(t){return!0}update(t){void 0!==this._$EC&&(this._$EC.forEach(((t,e)=>this._$ES(e,this[e],t))),this._$EC=void 0),this._$EU()}updated(t){}firstUpdated(t){}} /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ var y;_.finalized=!0,_.elementProperties=new Map,_.elementStyles=[],_.shadowRootOptions={mode:"open"},null==h||h({ReactiveElement:_}),(null!==(l=globalThis.reactiveElementVersions)&&void 0!==l?l:globalThis.reactiveElementVersions=[]).push("1.3.1");const g=globalThis.trustedTypes,v=g?g.createPolicy("lit-html",{createHTML:t=>t}):void 0,m=`lit$${(Math.random()+"").slice(9)}$`,f="?"+m,b=`<${f}>`,$=document,w=(t="")=>$.createComment(t),x=t=>null===t||"object"!=typeof t&&"function"!=typeof t,A=Array.isArray,C=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,E=/-->/g,S=/>/g,R=/>|[ \n \r](?:([^\s"'>=/]+)([ \n \r]*=[ \n \r]*(?:[^ \n \r"'`<>=]|("|')|))|$)/g,B=/'/g,M=/"/g,k=/^(?:script|style|textarea|title)$/i,O=(t=>(e,...i)=>({_$litType$:t,strings:e,values:i}))(1),P=Symbol.for("lit-noChange"),U=Symbol.for("lit-nothing"),T=new WeakMap,H=$.createTreeWalker($,129,null,!1),N=(t,e)=>{const i=t.length-1,n=[];let s,o=2===e?"":"",r=C;for(let e=0;e"===a[0]?(r=null!=s?s:C,c=-1):void 0===a[1]?c=-2:(c=r.lastIndex-a[2].length,l=a[1],r=void 0===a[3]?R:'"'===a[3]?M:B):r===M||r===B?r=R:r===E||r===S?r=C:(r=R,s=void 0);const u=r===R&&t[e+1].startsWith("/>")?" ":"";o+=r===C?i+b:c>=0?(n.push(l),i.slice(0,c)+"$lit$"+i.slice(c)+m+u):i+m+(-2===c?(n.push(void 0),e):u)}const l=o+(t[i]||"")+(2===e?"":"");if(!Array.isArray(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return[void 0!==v?v.createHTML(l):l,n]};class D{constructor({strings:t,_$litType$:e},i){let n;this.parts=[];let s=0,o=0;const r=t.length-1,l=this.parts,[a,c]=N(t,e);if(this.el=D.createElement(a,i),H.currentNode=this.el.content,2===e){const t=this.el.content,e=t.firstChild;e.remove(),t.append(...e.childNodes)}for(;null!==(n=H.nextNode())&&l.length0){n.textContent=g?g.emptyScript:"";for(let i=0;i{var e;return A(t)||"function"==typeof(null===(e=t)||void 0===e?void 0:e[Symbol.iterator])})(t)?this.S(t):this.$(t)}A(t,e=this._$AB){return this._$AA.parentNode.insertBefore(t,e)}k(t){this._$AH!==t&&(this._$AR(),this._$AH=this.A(t))}$(t){this._$AH!==U&&x(this._$AH)?this._$AA.nextSibling.data=t:this.k($.createTextNode(t)),this._$AH=t}T(t){var e;const{values:i,_$litType$:n}=t,s="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=D.createElement(n.h,this.options)),n);if((null===(e=this._$AH)||void 0===e?void 0:e._$AD)===s)this._$AH.m(i);else{const t=new L(s,this),e=t.p(this.options);t.m(i),this.k(e),this._$AH=t}}_$AC(t){let e=T.get(t.strings);return void 0===e&&T.set(t.strings,e=new D(t)),e}S(t){A(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,n=0;for(const s of t)n===e.length?e.push(i=new I(this.A(w()),this.A(w()),this,this.options)):i=e[n],i._$AI(s),n++;n2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=U}get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}_$AI(t,e=this,i,n){const s=this.strings;let o=!1;if(void 0===s)t=W(this,t,e,0),o=!x(t)||t!==this._$AH&&t!==P,o&&(this._$AH=t);else{const n=t;let r,l;for(t=s[0],r=0;r{var n,s;const o=null!==(n=null==i?void 0:i.renderBefore)&&void 0!==n?n:e;let r=o._$litPart$;if(void 0===r){const t=null!==(s=null==i?void 0:i.renderBefore)&&void 0!==s?s:null;o._$litPart$=r=new I(e.insertBefore(w(),t),t,void 0,null!=i?i:{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){var t;super.connectedCallback(),null===(t=this._$Dt)||void 0===t||t.setConnected(!0)}disconnectedCallback(){var t;super.disconnectedCallback(),null===(t=this._$Dt)||void 0===t||t.setConnected(!1)}render(){return P}}Y.finalized=!0,Y._$litElement$=!0,null===(Z=globalThis.litElementHydrateSupport)||void 0===Z||Z.call(globalThis,{LitElement:Y});const Q=globalThis.litElementPolyfillSupport;null==Q||Q({LitElement:Y}),(null!==(J=globalThis.litElementVersions)&&void 0!==J?J:globalThis.litElementVersions=[]).push("3.2.0"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ const X=(t,e)=>"method"===e.kind&&e.descriptor&&!("value"in e.descriptor)?{...e,finisher(i){i.createProperty(e.key,t)}}:{kind:"field",key:Symbol(),placement:"own",descriptor:{},originalKey:e.key,initializer(){"function"==typeof e.initializer&&(this[e.key]=e.initializer.call(this))},finisher(i){i.createProperty(e.key,t)}};function tt(t){return(e,i)=>void 0!==i?((t,e,i)=>{e.constructor.createProperty(i,t)})(t,e,i):X(t,e) /** * @license * Copyright 2021 Google LLC * SPDX-License-Identifier: BSD-3-Clause */}var et;null===(et=window.HTMLSlotElement)||void 0===et||et.prototype.assignedElements;class it{constructor(t,e){this.speed=0,this.startPosition=0,this.currentPosition=0,this.currentDelta=0,this.maxPosition=30,this.unitOfMeasurement="",this.accText="",this.accTextclassName="accText",this.entity="",this.color="stroke:var(--info-color)",this.circleColor="var(--primary-color)",this.prevTimestamp=0,this.accTextElement=null,this.entity=t,this.entitySlot=e,this.value=0}setValueAndUnitOfMeasurement(t,e){if(void 0===t)return void(this.value=0);if(void 0===e)return void(this.value=t);const i=parseFloat(t);switch(e){case"W":case"w":case"kW":this.value=i,"kW"===e&&(this.value*=1e3),this.unitOfMeasurement="W",this.value=Math.round(this.value);break;case"%":this.value=i,this.unitOfMeasurement=e;break;default:this.value=t,this.unitOfMeasurement=e}}setSpeed(t){if(this.speed=0,0===Math.abs(this.value))return;let e;e=void 0===t||t>1||t<=0?it.SPEEDFACTOR:t,this.speed=e*this.value/1e3}}it.SPEEDFACTOR=.04;class nt{constructor(){this.mainValue=0,this.clickEntitySlot=null,this.clickEntityHassState=null,this.noEntitiesWithValueFound=!0}}class st{constructor(t,e){this.teslaCard=t,this.solarCardElements=t.solarCardElements,this.pxRate=t.pxRate,this.hass=e}writeBubbleDiv(t){return t.noEntitiesWithValueFound?O``:O`
${null!==t.extraValue?O`
${t.extraValue} ${t.extraUnitOfMeasurement}
`:O``}
${t.mainValue} ${t.mainUnitOfMeasurement}
`}writeBatteryBubbleDiv(t){return void 0!==t.extraValue&&("mdi:battery-medium"!==t.icon&&"mdi:battery"!==t.icon||(t.icon=this.getBatteryIcon(parseFloat(t.extraValue),t.mainValue))),this.writeBubbleDiv(t)}getBatteryIcon(t,e){let i=t;t<=5&&(i=0);const n=10*Math.ceil(i/10);let s="-"+n.toString(),o="-charging";return e<=0&&(o=""),100===n&&(s=""),n<=5&&(s="-outline"),"mdi:battery"+o+s}writeAppliancePowerLineAndCircle(t,e){if(null==this.solarCardElements.get("appliance"+t+"_consumption_entity"))return O``;let i;return i=1===t?"top:"+22.5*this.pxRate+"px;":"bottom:"+15*this.pxRate+"px;",O`
${this.writeCircleAndLine("appliance"+t+"_consumption_entity",e)}
`}writeCircleAndLine(t,e){const i=this.solarCardElements.get(t);return null==i?O``:O` `}_handleClick(t){if(null==t)return;const e=new Event("hass-more-info",{bubbles:!0,cancelable:!0,composed:!0});e.detail={entityId:t.entity_id},null!=this.teslaCard.shadowRoot&&this.teslaCard.shadowRoot.dispatchEvent(e)}}class ot{static changeStylesDependingOnWidth(t,e,i,n){if("complete"!==document.readyState||n===i)return n;if(null==t.shadowRoot)return n;const s=t.shadowRoot.querySelector("#tesla-style-solar-power-card");if(null==s)return n;i<200&&(i=250);const o=i/100,r=function(t,e,i){const n=s.querySelector(t);null!==n&&(n.style[e]=i)};r(".acc_left","top",12*o+"px"),r(".acc_right","top",12*o+"px"),s.querySelectorAll(".acc_container").forEach(((t,e,i)=>{const n=i[e];n.style.height=9*o+"px",n.style.width=9*o+"px",n.style.padding=5*o+"px"})),s.querySelectorAll("ha-icon").forEach(((t,e,i)=>{var n;const s=null===(n=i[e].shadowRoot)||void 0===n?void 0:n.querySelector("ha-svg-icon");null!=s&&(s.style.height=9*o+"px",s.style.width=9*o+"px")})),s.querySelectorAll(".acc_text").forEach((t=>{t.style["font-size"]=3*o+"px",t.style["margin-top"]=-.5*o+"px",t.style.width=10*o+"px"})),s.querySelectorAll(".acc_text_extra").forEach((t=>{t.style["font-size"]=3*o+"px",t.style.top=1*o+"px",t.style.width=10*o+"px"})),r(".power_lines","height",42*o+"px"),r(".power_lines","width",42*o+"px"),r(".power_lines","top",0*o+"px"),r(".power_lines","left",28*o+"px"),r(".power_lines svg","width",42*o+"px"),r(".power_lines svg","height",42*o+"px"),r(".power_lines svg","viewBox","0 0 "+42*o+" "+42*o);let l=s.querySelector(".power_lines svg");null!==l&&l.setAttribute("viewBox","0 0 "+42*o+" "+42*o);const a=22*o;r("#generation_to_house_entity_line","d","M"+(a-o)+",0 C"+(a-o)+","+a+" "+(a-o)+","+a+" "+2*a+","+a),r("#grid_feed_in_entity_line","d","M"+(a-o)+",0 C"+(a-o)+","+a+" "+(a-o)+","+a+" 0,"+a),r("#grid_to_house_entity_line","d","M0,"+a+" C"+a+","+a+" "+a+","+a+" "+2*a+","+a),r("#grid_to_battery_entity_line","d","M0,"+a+" C"+a+","+a+" "+a+","+a+" "+a+","+2*a),r("#battery_to_house_entity_line","d","M"+(a-o)+","+2*a+" C"+(a-o)+","+a+" "+(a-o)+","+a+" "+2*a+","+a),r("#generation_to_battery_entity_line","d","M"+(a-o)+",0 C"+(a-o)+",0 "+(a-o)+","+2*a+" "+(a-o)+","+2*a),[1,2].forEach((t=>{r(".acc_appliance"+t+"_line svg","viewBox","0 0 "+(12*o-5*(t-1))+" "+(12*o-5*(t-1))),r(".acc_appliance"+t+"_line","right",9.5*o+10+"px"),r(".acc_appliance"+t+"_line","width","10px"),r(".acc_appliance"+t+"_line","height",12*o-5*(t-1)+"px"),r(".acc_appliance"+t+"_line svg","width","10px"),r(".acc_appliance"+t+"_line svg","height",12*o-5*(t-1)+"px"),l=s.querySelector(".acc_appliance"+t+"_line_svg"),null!==l&&l.setAttribute("viewBox","0 0 "+(12*o-5*(t-1))+" "+(12*o-5*(t-1)));null===s.querySelector(".generation_entity")&&1===t&&null!==l&&r(".acc_center_container","margin-top",19*o+"px");null===s.querySelector(".battery_entity")&&2===t&&null!==l&&r(".acc_center_container","margin-bottom",19*o+"px")}));return null===s.querySelector(".grid_entity")&&(r(".generation_entity","margin","0px"),r(".battery_entity","margin","0px"),r(".power_lines","width",30*o+"px"),l=s.querySelector(".power_lines svg"),null!==l&&l.setAttribute("viewBox",12*o+" 0 "+42*o+" "+42*o)),r(".acc_appliance1","top","10px"),r(".acc_appliance1_line","top",19*o+12+"px"),r(".acc_appliance2","bottom","10px"),r(".acc_appliance2_line","bottom",19*o+12+"px"),i}}window.customCards=window.customCards||[],window.customCards.push({type:"tesla-style-solar-power-card",name:"Tesla Style Solar Power Card",description:"A Solar Power Visualization with svg paths that mimmicks the powerwall app of tesla 2"});class rt extends Y{constructor(){super(...arguments),this.solarCardElements=new Map,this.oldWidth=100,this.pxRate=4,this.htmlWriter=new st(this,this.hass),this.title="Hey there",this.counter=5,this.error=""}__increment(){this.counter+=1}setConfig(t){if(t.test_gui,this.config={...t},null==this.config.grid_icon&&(this.config.grid_icon="mdi:transmission-tower"),null==this.config.generation_icon&&(this.config.generation_icon="mdi:solar-panel-large"),null==this.config.house_icon&&(this.config.house_icon="mdi:home"),null==this.config.battery_icon&&(this.config.battery_icon="mdi:battery-medium"),null==this.config.appliance1_icon&&(this.config.appliance1_icon="mdi:car-sports"),null==this.config.appliance2_icon&&(this.config.appliance2_icon="mdi:air-filter"),null==this.config.speed_factor&&(this.config.speed_factor=.04),this.createSolarCardElements(),!this.config.energy_flow_diagramm){const t=this;setInterval(this.animateCircles,15,t)}}createSolarCardElements(){Object.keys(this.config).forEach((t=>{if(null!=this.config[t]&&t.indexOf("_entity")>5){const e=this.config[t].toString();this.solarCardElements.set(t,new it(e,t))}}))}getCardSize(){return 5}static getStubConfig(){return{}}async firstUpdated(){await new Promise((t=>setTimeout(t,0)));const t=this.getBoundingClientRect().width;this.oldWidth=ot.changeStylesDependingOnWidth(this,this.solarCardElements,t,this.oldWidth)}connectedCallback(){super.connectedCallback(),this.redraw=this.redraw.bind(this),window.addEventListener("resize",this.redraw)}shouldUpdate(t){let e;e=this,this.config.energy_flow_diagramm||requestAnimationFrame((t=>{e.updateAllCircles(t)})),e=this;let i=!0;return Array.from(t.keys()).some((e=>{const n=t.get(e);return"hass"===e&&n&&(i=i&&this.sensorChangeDetected(n)),!i})),i}sensorChangeDetected(t){let e=!1;return this.solarCardElements.forEach(((i,n)=>{void 0!==this.hass.states[this.config[n]]&&this.hass.states[this.config[n]].state!==t.states[this.config[n]].state&&(e=!0)})),e}async performUpdate(){this.error="",this.solarCardElements.forEach((t=>{try{t.setValueAndUnitOfMeasurement(this.hass.states[t.entity].state,this.hass.states[t.entity].attributes.unit_of_measurement),t.setSpeed(this.config.speed_factor)}catch(e){this.error+=" Configured '"+t.entity+"' entity was not found. "}})),this.config.energy_flow_diagramm&&this.setEnergyFlowDiagramm(),this.config.change_house_bubble_color_with_flow&&this.colourHouseBubbleDependingOnHighestInput(),super.performUpdate()}render(){if(""!==this.error)return this._showError();let t,e=this.getBoundingClientRect().width;e<200&&(e=250),this.pxRate=e/100,t=void 0!==this.config.show_gap&&this.config.show_gap?2*this.pxRate:0;const i=22*this.pxRate;return O`
${this.writeGenerationIconBubble()}
${this.writeGridIconBubble()}
${this.htmlWriter.writeCircleAndLine("generation_to_house_entity","M"+(i-this.pxRate+t)+",0C"+(i-this.pxRate+t)+","+(i-t)+" "+(i-this.pxRate+t)+","+(i-t)+" "+2*i+","+(i-t))} ${this.htmlWriter.writeCircleAndLine("grid_to_house_entity","M0,"+i+" C"+(i-this.pxRate)+","+i+" "+(i-this.pxRate)+","+i+" "+2*(i-this.pxRate)+","+i)} ${this.htmlWriter.writeCircleAndLine("generation_to_grid_entity","M"+(i-this.pxRate-t)+",0 C"+(i-this.pxRate-t)+","+(i-t)+" "+(i-this.pxRate-t)+","+(i-t)+" 0,"+(i-t))} ${this.htmlWriter.writeCircleAndLine("grid_to_battery_entity","M0,"+(i+t)+" C"+(i-this.pxRate-t)+","+(i+t)+" "+(i-this.pxRate-t)+","+(i+t)+" "+(i-this.pxRate-t)+","+2*i)} ${this.htmlWriter.writeCircleAndLine("battery_to_grid_entity","M"+(i-this.pxRate-t)+","+2*i+" C"+(i-this.pxRate-t)+","+(i+t)+" "+(i-this.pxRate-t)+","+(i+t)+" 0,"+(i+t))} ${this.htmlWriter.writeCircleAndLine("generation_to_battery_entity","M"+(i-this.pxRate)+",0 C"+(i-this.pxRate)+",0 "+(i-this.pxRate)+","+2*i+" "+(i-this.pxRate)+","+2*i)} ${this.htmlWriter.writeCircleAndLine("battery_to_house_entity","M"+(i-this.pxRate+t)+","+2*i+" C"+(i-this.pxRate+t)+","+(i+t)+" "+(i-this.pxRate+t)+","+(i+t)+" "+2*i+","+(i+t))}
${this.writeHouseIconBubble()} ${this.writeApplianceIconBubble(1)} ${this.htmlWriter.writeAppliancePowerLineAndCircle(1,"M5,"+12*this.pxRate+" C5,"+12*this.pxRate+" 5,0 5,0")} ${this.writeApplianceIconBubble(2)} ${this.htmlWriter.writeAppliancePowerLineAndCircle(2,"M5,0 C5,0 5,"+11*this.pxRate+" 5,"+11*this.pxRate)}
${this.writeBatteryIconBubble()}
`}writeGenerationIconBubble(){const t=this.calculateIconBubbleData(["generation_to_grid_entity","generation_to_house_entity","generation_to_battery_entity"],"generation_entity","generation_extra_entity");return t.cssSelector="acc_top",t.icon=this.config.generation_icon,this.htmlWriter.writeBatteryBubbleDiv(t)}writeGridIconBubble(){const t=this.calculateIconBubbleData(["-generation_to_grid_entity","grid_to_house_entity","-battery_to_grid_entity","grid_to_battery_entity"],"grid_entity","grid_extra_entity");return t.cssSelector="acc_left",t.icon=this.config.grid_icon,this.htmlWriter.writeBatteryBubbleDiv(t)}writeHouseIconBubble(){let t;t=this.config.house_without_appliances_values?["generation_to_house_entity","grid_to_house_entity","battery_to_house_entity","-appliance1_consumption_entity","-appliance2_consumption_entity"]:["generation_to_house_entity","grid_to_house_entity","battery_to_house_entity"];const e=this.calculateIconBubbleData(t,"house_entity","house_extra_entity");return e.cssSelector="acc_right",e.icon=this.config.house_icon,this.htmlWriter.writeBatteryBubbleDiv(e)}writeBatteryIconBubble(){const t=this.calculateIconBubbleData(["generation_to_battery_entity","grid_to_battery_entity","-battery_to_house_entity","-battery_to_grid_entity"],"battery_entity","battery_extra_entity");return t.cssSelector="acc_bottom",t.icon=this.config.battery_icon,this.htmlWriter.writeBatteryBubbleDiv(t)}writeApplianceIconBubble(t){const e=["appliance"+t+"_consumption_entity"],i=this.calculateIconBubbleData(e,"appliance"+t+"_consumption_entity","appliance"+t+"_extra_entity");return i.cssSelector="acc_appliance"+t,i.icon=this.config["appliance"+t+"_icon"],this.htmlWriter.writeBatteryBubbleDiv(i)}calculateIconBubbleData(t,e=null,i=null){let n=!1;const s=new nt;if(s.clickEntitySlot=e,t.forEach((t=>{"-"===t.substring(0,1)&&(t=t.substring(1),n=!0);const e=this.solarCardElements.get(t);null!==e&&void 0!==(null==e?void 0:e.value)&&(s.noEntitiesWithValueFound=!1,s.mainValue=n?s.mainValue-(null==e?void 0:e.value):s.mainValue+(null==e?void 0:e.value),s.mainValue=(100*s.mainValue|0)/100,s.mainUnitOfMeasurement=null==e?void 0:e.unitOfMeasurement),n=!1})),null!==i){const t=this.solarCardElements.get(i);s.extraValue=null==t?void 0:t.value,s.extraUnitOfMeasurement=null==t?void 0:t.unitOfMeasurement}return null!==e&&(s.clickEntityHassState=this.hass.states[this.config[e]]),this.showKW(s.mainValue)&&(s.mainValue=this.roundValue(s.mainValue/1e3),s.mainUnitOfMeasurement="kW"),s}showKW(t){return!this.config.show_w_not_kw&&!(void 0!==this.config.threshold_in_k&&Math.abs(t)<1e3*this.config.threshold_in_k)}roundValue(t){let e;return e=t>.1?(0|Math.round(10*(t+Number.EPSILON)))/10:(0|Math.round(100*(t+Number.EPSILON)))/100,e}animateCircles(t){requestAnimationFrame((e=>{t.updateAllCircles(e)}))}updateAllCircles(t){this.solarCardElements.forEach(((e,i)=>{const n=this.solarCardElements.get(i);void 0!==n&&this.updateOneCircle(t,n)}))}updateOneCircle(t,e){if(null==this.shadowRoot)return;const i=this.shadowRoot.querySelector("#tesla-style-solar-power-card");if(null==i)return;if(e.line=i.querySelector("#"+e.entitySlot+"_line"),null===e.line)return;const n=e.line.getTotalLength();if(isNaN(n))return;if(e.circle=i.querySelector("#"+e.entitySlot+"_circle"),0===e.speed)return e.circle.setAttribute("visibility","hidden"),void(this.config.hide_inactive_lines&&e.line.setAttribute("visibility","hidden"));e.circle.setAttribute("visibility","visible"),this.config.hide_inactive_lines&&e.line.setAttribute("visibility","visible"),0===e.prevTimestamp&&(e.prevTimestamp=t,e.currentDelta=0),e.currentDelta+=Math.abs(e.speed)*(t-e.prevTimestamp);let s=e.currentDelta/n;e.speed>0?(s>=1||isNaN(s))&&(e.currentDelta=0,s=.01):(s=1-s,(s<=0||isNaN(s))&&(e.currentDelta=0,s=1));const o=e.line.getPointAtLength(n*s);e.circle.setAttributeNS(null,"cx",o.x.toString()),e.circle.setAttributeNS(null,"cy",o.y.toString()),e.prevTimestamp=t}colourHouseBubbleDependingOnHighestInput(){if(null==this.shadowRoot)return;const t=this.shadowRoot.querySelector("#tesla-style-solar-power-card");if(null==t)return;let e=null,i="";switch(["generation_to_house_entity","grid_to_house_entity","battery_to_house_entity"].forEach((t=>{const n=this.solarCardElements.get(t);null!==n&&void 0!==(null==n?void 0:n.value)&&(null==e||(null==n?void 0:n.value)>e.value)&&(i=t,e=n)})),i){case"generation_to_house_entity":this.colourBubble(".house_entity",t,"warning"),this.colourBubble(".appliance1_consumption_entity",t,"warning"),this.colourBubble(".appliance2_consumption_entity",t,"warning"),this.colourLineAndCircle("#appliance1_consumption_entity",t,"warning"),this.colourLineAndCircle("#appliance2_consumption_entity",t,"warning");break;case"battery_to_house_entity":this.colourBubble(".house_entity",t,"success"),this.colourBubble(".appliance1_consumption_entity",t,"success"),this.colourBubble(".appliance2_consumption_entity",t,"success"),this.colourLineAndCircle("#appliance1_consumption_entity",t,"success"),this.colourLineAndCircle("#appliance2_consumption_entity",t,"success");break;case"grid_to_house_entity":this.colourBubble(".house_entity",t,"info"),this.colourBubble(".appliance1_consumption_entity",t,"info"),this.colourBubble(".appliance2_consumption_entity",t,"info"),this.colourLineAndCircle("#appliance1_consumption_entity",t,"info"),this.colourLineAndCircle("#appliance2_consumption_entity",t,"info")}}colourBubble(t,e,i){const n=e.querySelector(t);null!==n&&(n.style.color="var(--"+i+"-color)",n.style.border="1px solid var(--"+i+"-color)")}colourLineAndCircle(t,e,i){const n=e.querySelector(t+"_line"),s=e.querySelector(t+"_circle");null!==n&&(n.style.stroke="var(--"+i+"-color)",s.style.fill="var(--"+i+"-color)")}setEnergyFlowDiagramm(){if(null==this.shadowRoot)return;const t=this.shadowRoot.querySelector("#tesla-style-solar-power-card");null!=t&&this.solarCardElements.forEach(((e,i)=>{const n=this.solarCardElements.get(i);let s=1;if(null==t)return;const o=t.querySelector("#"+i+"_line");if(null!=o&&void 0!==n){t.querySelector("#"+i+"_circle").style.visibility="hidden",void 0===this.config.energy_flow_diagramm_lines_factor&&(this.config.energy_flow_diagramm_lines_factor=2),s="W"===(null==n?void 0:n.unitOfMeasurement.toUpperCase())?Math.floor((null==n?void 0:n.value)/100)/10*this.config.energy_flow_diagramm_lines_factor:Math.floor(10*(null==n?void 0:n.value))/10*this.config.energy_flow_diagramm_lines_factor,s<=.1&&0!==s&&(s=.1),o.style.strokeWidth=s+"px"}}))}redraw(t){if(this.hass&&this.config&&"resize"===t.type){const t=this.getBoundingClientRect().width;this.oldWidth=ot.changeStylesDependingOnWidth(this,this.solarCardElements,t,this.oldWidth)}}_showWarning(t){return O` ${t} `}_showError(){return console.log(this.error),O`
ERROR:
${this.error}
`}static get styles(){return o` #tesla-style-solar-power-card{ margin:auto; display:table; padding: 10px; position: relative; } .acc_container { height: 40px; width: 40px; border: 1px solid black; border-radius: 100px; padding: 22px; color: var(--primary-text-color); border-color: var(--primary-text-color); position:relative; cursor:pointer; } .acc_icon { --mdc-icon-size: 40px; } .acc_text, .acc_text_extra { text-align: center; white-space: nowrap; } .acc_text_extra { overflow: hidden; position: absolute; } .acc_td { vertical-align: top; } .acc_center .acc_td{ position:relative; } .acc_top .acc_container, .acc_bottom .acc_container{ margin:auto; } .acc_center{ display:flex; } .acc_center_container{ display:inline-block; margin: 0px auto; margin-bottom:-5px; } .acc_right , .acc_left , .acc_line{ display:inline-block; margin-right:-4px } .acc_left { vertical-align: top; } .acc_right { margin-right:0px; } #battery_to_house_entity_line, #generation_to_house_entity_line, #grid_to_house_entity_line, #generation_to_battery_entity_line, #grid_feed_in_entity_line, #generation_to_grid_entity_line, #battery_to_grid_entity_line, #grid_to_battery_entity_line, #appliance1_consumption_entity_line, #appliance2_consumption_entity_line{ stroke:var(--info-color); fill:none; stroke-width:1; } .generation_entity { border: 1px solid var(--warning-color); } .generation_entity .acc_icon, .generation_entity{ color: var(--warning-color); } .house_entity{ border: 1px solid var(--info-color); } .appliance1_consumption_entity, .appliance2_consumption_entity { border: 1px solid var(--info-color); } .house_entity, .appliance1_consumption_entity, .appliance2_consumption_entity{ color: var(--info-color); } #generation_to_house_entity_line, #generation_to_grid_entity_line, #generation_to_battery_entity_line{ stroke:var(--warning-color); } #grid_to_battery_entity_circle, #grid_to_house_entity_circle, #appliance1_consumption_entity_circle, #appliance2_consumption_entity_circle{ fill:var(--info-color); } #generation_to_house_entity_circle, #generation_to_grid_entity_circle, #generation_to_battery_entity_circle{ fill:var(--warning-color); } #battery_to_house_entity_line, #battery_to_grid_entity_line{ stroke:var(--success-color); } #battery_to_house_entity_circle, #battery_to_grid_entity_circle{ fill:var(--success-color); } .battery_extra_entity, .battery_entity{ border: 1px solid var(--success-color); color: var(--success-color); } .battery_extra_text{ position:absolute; top:8px; } br.clear { clear:both; } .power_lines svg{ transform: translateZ(0); display:inline-block; } .acc_center .acc_td.acc_appliance1, .acc_center .acc_td.acc_appliance2 { position: absolute; right: 10px; `}}t([tt({attribute:!1})],rt.prototype,"hass",void 0),t([tt()],rt.prototype,"config",void 0),t([tt({attribute:!1})],rt.prototype,"solarCardElements",void 0),t([tt()],rt.prototype,"oldWidth",void 0),t([tt({type:String})],rt.prototype,"title",void 0),t([tt({type:Number})],rt.prototype,"counter",void 0),window.customElements.define("tesla-style-solar-power-card",rt)}(); ================================================ FILE: tesla-style-solar-power-card.ts ================================================ import { TeslaStyleSolarPowerCard } from './src/TeslaStyleSolarPowerCard.js'; window.customElements.define('tesla-style-solar-power-card', TeslaStyleSolarPowerCard); ================================================ FILE: test/SensorElement.test.ts ================================================ import { expect } from '@open-wc/testing'; import { SensorElement } from '../src/models/SensorElement.js'; // import { TeslaStyleSolarPowerCard } from '../src/TeslaStyleSolarPowerCard.js'; import '../tesla-style-solar-power-card.js'; describe('SensorElements test', () => { it('should setSpeed with W', () => { const selement = new SensorElement('test_entity', 'solar_consumption'); selement.value = 1; selement.unitOfMeasurement = 'W'; selement.setSpeed(0.04); expect(selement.speed).to.equal(0.00004); }); it('should setSpeed with kW', () => { const selement = new SensorElement('test_entity', 'solar_consumption'); selement.value = 1; selement.unitOfMeasurement = 'KW'; selement.setSpeed(0.04); expect(selement.speed).to.equal(0.00004); }); it('should setSpeed with kW with factor 0.05', () => { const selement = new SensorElement('test_entity', 'solar_consumption'); selement.value = 1; selement.unitOfMeasurement = 'KW'; selement.setSpeed(0.05); expect(selement.speed).to.equal(0.00005); }); it('should setValueAndUnitOfMeasurement from kW rounded to 1 decimals', () => { const selement = new SensorElement('test_entity', 'solar_consumption'); selement.setValueAndUnitOfMeasurement('1.1111', 'kW'); expect(selement.value).to.equal(1111); }); it('should setValueAndUnitOfMeasurement from 0.0111 kW to W', () => { const selement = new SensorElement('test_entity', 'solar_consumption'); selement.setValueAndUnitOfMeasurement('0.0111', 'kW'); expect(selement.value).to.equal(11); }); it('should setValueAndUnitOfMeasurement from W rounded to two decimals get kW', () => { const selement = new SensorElement('test_entity', 'solar_consumption'); selement.setValueAndUnitOfMeasurement('1100.1', 'W'); expect(selement.value).to.equal(1100); }); it('should setValueAndUnitOfMeasurement from W rounded to 1 decimals but get W', () => { const selement = new SensorElement('test_entity', 'solar_consumption'); selement.value = 1; selement.setValueAndUnitOfMeasurement('1111.111', 'W'); expect(selement.value).to.equal(1111); }); it('should setValueAndUnitOfMeasurement from percentage', () => { const selement = new SensorElement('test_entity', 'solar_consumption'); selement.value = 1; selement.setValueAndUnitOfMeasurement('1', '%'); expect(selement.value).to.equal(1); }); it('should setValueAndUnitOfMeasurement from undefined', () => { const selement = new SensorElement('test_entity', 'solar_consumption'); selement.value = 1; selement.setValueAndUnitOfMeasurement(undefined, '%'); expect(selement.value).to.equal(0); }); it('should setValueAndUnitOfMeasurement from normal string without unit', () => { const selement = new SensorElement('test_entity', 'solar_consumption'); selement.value = 1; selement.setValueAndUnitOfMeasurement('on', undefined); expect(selement.value).to.equal('on'); }); }); ================================================ FILE: test/battery.test.ts ================================================ import { expect, elementUpdated, assert } from '@open-wc/testing'; import { LovelaceCardConfig } from 'custom-card-helpers'; import { setViewport } from '@web/test-runner-commands'; import { TeslaStyleSolarPowerCard } from '../src/TeslaStyleSolarPowerCard.js'; import '../tesla-style-solar-power-card.js'; import { setCard } from './setters.js'; describe('TeslaStyleSolarPowerCard battery tests', () => { let card: TeslaStyleSolarPowerCard; let haCard: HTMLElement | null; let teslaCard: HTMLElement | null | undefined; let hass: any; let config: LovelaceCardConfig; /** Tests are extended in energy_capable. * */ beforeEach(async () => { config = { type: 'custom:tesla-style-solar-power-card', name: 'Powerhouse', house_entity: 'sensor.house_consumption', battery_extra_entity: 'sensor.battery_charge', battery_entity: 'sensor.battery_consumption', battery_to_house_entity: 'sensor.battery_consumption', }; hass = { states: { 'sensor.house_consumption': { attributes: { unit_of_measurement: 'W', friendly_name: 'House consumption', }, entity_id: 'sensor.house_consumption', state: '1300', }, 'sensor.battery_consumption': { attributes: { unit_of_measurement: 'W', }, entity_id: 'battery_consumption', state: '1300', }, 'sensor.battery_charge': { attributes: { unit_of_measurement: '%', }, entity_id: 'sensor.battery_charge', state: '100', }, 'sensor.battery_to_house': { attributes: { unit_of_measurement: 'W', }, entity_id: 'sensor.battery_to_house', state: '1300', }, }, }; await setViewport({ width: 1200, height: 1000 }); card = await setCard(hass, config); // let iframe = document.createElement('iframe'); // document.body.appendChild(iframe); // let div = document.createElement('div'); // document.body. // console.log("document width " + document.body.clientWidth); // document.body.appendChild(card); if (card.shadowRoot === null) assert.fail('No Card Shadowroot'); haCard = card.shadowRoot.querySelector('ha-card'); if (haCard === null || haCard === undefined) assert.fail('No ha-card'); teslaCard = ( haCard.querySelector('#tesla-style-solar-power-card') ); if (teslaCard === null || teslaCard === undefined) assert.fail('No tesla-style-card'); }); const setBatteryState = async (state: string) => { hass.states['sensor.battery_charge'].state = state; card.setAttribute('hass', JSON.stringify(hass)); await elementUpdated(card); await card.setConfig(config); }; it('has house_entity, text and icon', async () => { const houseEntity = teslaCard?.querySelector('.house_entity'); if (houseEntity === null || houseEntity === undefined) assert.fail('No house_entity element found'); expect(houseEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, '')).contains( '1.3 kW' ); expect( houseEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString() ).to.equal('mdi:home'); }); it('has battery_entity, text and icon', async () => { const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect(batteryEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, '')).contains( '1.3 kW' ); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '100 %' ); }); it('has battery to house consumption line and circle', async () => { // await setCardConsumingFromGrid(); const batteryToHouseLine = teslaCard?.querySelector( '#battery_to_house_entity_line' ); if (batteryToHouseLine === null || batteryToHouseLine === undefined) { assert.fail('No battery_to_house_line element found'); } const batteryToHouseCircle = teslaCard?.querySelector( '#battery_to_house_entity_circle' ); if (batteryToHouseCircle === null || batteryToHouseCircle === undefined) { assert.fail('No batter_to_house_entity_circle element found'); } if (haCard === null || haCard === undefined) assert.fail('No ha-card'); expect(batteryToHouseLine?.getAttribute('hidden')).to.equal(null); }); it('has no pv, grid or appliance icons', async () => { const pvEntity = teslaCard?.querySelector('.pv_consumption_entity'); if (pvEntity !== null) assert.fail('No pv_consumption_entity element found'); const gridEntity = teslaCard?.querySelector('.grid_entity'); if (gridEntity !== null) assert.fail('No battery_entity element found'); const appliance1Entity = teslaCard?.querySelector( '.appliance1_consumption_entity' ); if (appliance1Entity !== null) assert.fail('No appliance1_consumption_entity element found'); const appliance2Entity = teslaCard?.querySelector( '.appliance2_consumption_entity' ); if (appliance2Entity !== null) assert.fail('No appliance2_consumption_entity element found'); }); it('has battery at 90%', async () => { await setBatteryState('90'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-90'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '90 %' ); }); it('has battery at 83%', async () => { await setBatteryState('83'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-90'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '83 %' ); }); it('has battery at 73%', async () => { await setBatteryState('73'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-80'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '73 %' ); }); it('has battery at 65%', async () => { await setBatteryState('65'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-70'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '65 %' ); }); it('has battery at 15%', async () => { await setBatteryState('15'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-20'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '15 %' ); }); it('has battery at 6%', async () => { await setBatteryState('6'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-10'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '6 %' ); }); it('has battery at 5%', async () => { await setBatteryState('5'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-outline'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '5 %' ); }); it('has battery at 5%', async () => { await setBatteryState('5'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-outline'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '5 %' ); }); }); ================================================ FILE: test/batteryCharging.test.ts ================================================ import { expect, elementUpdated, assert } from '@open-wc/testing'; import { LovelaceCardConfig } from 'custom-card-helpers'; import { setViewport } from '@web/test-runner-commands'; import { TeslaStyleSolarPowerCard } from '../src/TeslaStyleSolarPowerCard.js'; import '../tesla-style-solar-power-card.js'; import { setCard } from './setters.js'; describe('TeslaStyleSolarPowerCard with defaultConfig', () => { let card: TeslaStyleSolarPowerCard; let haCard: HTMLElement | null; let teslaCard: HTMLElement | null | undefined; let hass: any; let config: LovelaceCardConfig; /** Tests are extended in energy_capable. * */ beforeEach(async () => { config = { type: 'custom:tesla-style-solar-power-card', name: 'Powerhouse', generation_to_battery_entity: 'sensor.battery_charging', grid_to_battery_entity: 'sensor.grid_to_battery', battery_extra_entity: 'sensor.battery_charge', battery_entity: 'sensor.battery_consumption', }; hass = { states: { 'sensor.grid_to_battery': { attributes: { unit_of_measurement: 'W', friendly_name: 'Grid to battery', }, entity_id: 'sensor.grid_to_battery', state: '1000', }, 'sensor.battery_consumption': { attributes: { unit_of_measurement: 'W', }, entity_id: 'battery_consumption', state: '0', }, 'sensor.battery_charging': { attributes: { unit_of_measurement: 'W', }, entity_id: 'battery_charging', state: '1000', }, 'sensor.battery_charge': { attributes: { unit_of_measurement: '%', }, entity_id: 'sensor.battery_charge', state: '99', }, }, }; await setViewport({ width: 1200, height: 1000 }); card = await setCard(hass, config); if (card.shadowRoot === null) assert.fail('No Card Shadowroot'); haCard = card.shadowRoot.querySelector('ha-card'); if (haCard === null || haCard === undefined) assert.fail('No ha-card'); teslaCard = ( haCard.querySelector('#tesla-style-solar-power-card') ); if (teslaCard === null || teslaCard === undefined) assert.fail('No tesla-style-card'); }); const setBatteryState = async (state: string) => { hass.states['sensor.battery_charge'].state = state; card.setAttribute('hass', JSON.stringify(hass)); await elementUpdated(card); await card.setConfig(config); }; it('has battery_entity, text and icon', async () => { const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, ''), 'No sum of battery charging flows in acc_text of battery_entity' ).contains('2 kW'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-charging'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '99 %' ); }); it('has grid to battery charging line and circle', async () => { const gridToBatteryLine = teslaCard?.querySelector( '#grid_to_battery_entity_line' ); if (gridToBatteryLine === null || gridToBatteryLine === undefined) { assert.fail('No grid_to_battery_entity_line element found'); } const gridToBatteryCircle = teslaCard?.querySelector( '#grid_to_battery_entity_circle' ); if (gridToBatteryCircle === null || gridToBatteryCircle === undefined) { assert.fail('No grid_to_battery_entity_circle element found'); } expect(gridToBatteryLine?.getAttribute('hidden')).to.equal(null); }); it('has solar to battery charging line and circle', async () => { const SolarToBatteryLine = teslaCard?.querySelector( '#generation_to_battery_entity_line' ); if (SolarToBatteryLine === null || SolarToBatteryLine === undefined) { assert.fail('No generation_to_battery_entity_line element found'); } const SolarToBatteryCircle = teslaCard?.querySelector( '#generation_to_battery_entity_circle' ); if (SolarToBatteryCircle === null || SolarToBatteryCircle === undefined) { assert.fail('No generation_to_battery_entity_circle element found'); } expect(SolarToBatteryLine?.getAttribute('hidden')).to.equal(null); }); it('has no pv, grid or appliance icons', async () => { const pvEntity = teslaCard?.querySelector('.pv_consumption_entity'); if (pvEntity !== null) assert.fail('No pv_consumption_entity element found'); const gridEntity = teslaCard?.querySelector('.grid_consumption_entity'); if (gridEntity !== null) assert.fail('No battery_entity element found'); const appliance1Entity = teslaCard?.querySelector( '.appliance1_consumption_entity' ); if (appliance1Entity !== null) assert.fail('No appliance1_consumption_entity element found'); const appliance2Entity = teslaCard?.querySelector( '.appliance2_consumption_entity' ); if (appliance2Entity !== null) assert.fail('No appliance2_consumption_entity element found'); }); it('has battery at 100%', async () => { await setBatteryState('100'); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-charging'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '100 %' ); }); it('has battery at 83%', async () => { await setBatteryState('83'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-charging-90'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '83 %' ); }); it('has battery at 73%', async () => { await setBatteryState('73'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-charging-80'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '73 %' ); }); it('has battery at 65%', async () => { await setBatteryState('65'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-charging-70'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '65 %' ); }); it('has battery at 15%', async () => { await setBatteryState('15'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-charging-20'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '15 %' ); }); it('has battery at 6%', async () => { await setBatteryState('6'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-charging-10'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '6 %' ); }); it('has battery at 5%', async () => { await setBatteryState('5'); card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-charging-outline'); expect(batteryEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, '')).contains( '5 %' ); }); }); ================================================ FILE: test/batteryWithoutExtra.test.ts ================================================ import { expect, assert } from '@open-wc/testing'; import { LovelaceCardConfig } from 'custom-card-helpers'; import { setViewport } from '@web/test-runner-commands'; import { TeslaStyleSolarPowerCard } from '../src/TeslaStyleSolarPowerCard.js'; import '../tesla-style-solar-power-card.js'; import { setCard } from './setters.js'; describe('TeslaStyleSolarPowerCard battery tests', () => { let card: TeslaStyleSolarPowerCard; let haCard: HTMLElement | null; let teslaCard: HTMLElement | null | undefined; let hass: any; let config: LovelaceCardConfig; /** Tests are extended in energy_capable. * */ beforeEach(async () => { config = { type: 'custom:tesla-style-solar-power-card', name: 'Powerhouse', house_entity: 'sensor.house_consumption', // battery_entity: 'sensor.battery_consumption', battery_to_house_entity: 'sensor.battery_consumption', }; hass = { states: { 'sensor.house_consumption': { attributes: { unit_of_measurement: 'W', friendly_name: 'House consumption', }, entity_id: 'sensor.house_consumption', state: '1300', }, 'sensor.battery_consumption': { attributes: { unit_of_measurement: 'W', }, entity_id: 'battery_consumption', state: '1300', }, 'sensor.battery_to_house': { attributes: { unit_of_measurement: 'W', }, entity_id: 'sensor.battery_to_house', state: '1300', }, }, }; await setViewport({ width: 1200, height: 1000 }); card = await setCard(hass, config); // let iframe = document.createElement('iframe'); // document.body.appendChild(iframe); // let div = document.createElement('div'); // document.body. // console.log("document width " + document.body.clientWidth); // document.body.appendChild(card); if (card.shadowRoot === null) assert.fail('No Card Shadowroot'); haCard = card.shadowRoot.querySelector('ha-card'); if (haCard === null || haCard === undefined) assert.fail('No ha-card'); teslaCard = ( haCard.querySelector('#tesla-style-solar-power-card') ); if (teslaCard === null || teslaCard === undefined) assert.fail('No tesla-style-card'); }); it('has house_entity, text and icon', async () => { const houseEntity = teslaCard?.querySelector('.house_entity'); if (houseEntity === null || houseEntity === undefined) assert.fail('No house_entity element found'); expect(houseEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, '')).contains( '1.3 kW' ); expect( houseEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString() ).to.equal('mdi:home'); }); it('has battery_entity, text and icon', async () => { const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect(batteryEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, '')).contains( '1.3 kW' ); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-medium'); }); it('has battery to house consumption line and circle', async () => { // await setCardConsumingFromGrid(); const batteryToHouseLine = teslaCard?.querySelector( '#battery_to_house_entity_line' ); if (batteryToHouseLine === null || batteryToHouseLine === undefined) { assert.fail('No battery_to_house_line element found'); } const batteryToHouseCircle = teslaCard?.querySelector( '#battery_to_house_entity_circle' ); if (batteryToHouseCircle === null || batteryToHouseCircle === undefined) { assert.fail('No batter_to_house_entity_circle element found'); } if (haCard === null || haCard === undefined) assert.fail('No ha-card'); expect(batteryToHouseLine?.getAttribute('hidden')).to.equal(null); }); it('has no pv, grid or appliance icons', async () => { const pvEntity = teslaCard?.querySelector('.generation_entity'); if (pvEntity !== null) assert.fail('No generation_entity element found'); const gridEntity = teslaCard?.querySelector('.grid_consumption_entity'); if (gridEntity !== null) assert.fail('No battery_entity element found'); const appliance1Entity = teslaCard?.querySelector( '.appliance1_consumption_entity' ); if (appliance1Entity !== null) assert.fail('No appliance1_consumption_entity element found'); const appliance2Entity = teslaCard?.querySelector( '.appliance2_consumption_entity' ); if (appliance2Entity !== null) assert.fail('No appliance2_consumption_entity element found'); }); it('has battery icon', async () => { card.requestUpdate(); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity === null || batteryEntity === undefined) assert.fail('No battery_entity element found'); expect( batteryEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:battery-medium'); }); }); ================================================ FILE: test/colouringOfBubblesDependingOnProduction.test.ts ================================================ import { expect, elementUpdated, assert } from '@open-wc/testing'; import { LovelaceCardConfig } from 'custom-card-helpers'; import { setViewport } from '@web/test-runner-commands'; import { TeslaStyleSolarPowerCard } from '../src/TeslaStyleSolarPowerCard.js'; import '../tesla-style-solar-power-card.js'; import { setCard } from './setters.js'; describe('Colouring of bubble depending on production test', () => { let card: TeslaStyleSolarPowerCard; let haCard: HTMLElement | null; let teslaCard: HTMLElement | null | undefined; let hass: any; let config: LovelaceCardConfig; /** Tests are extended in energy_capable. * */ beforeEach(async () => { config = { type: 'custom:tesla-style-solar-power-card', name: 'Powerhouse', generation_to_house_entity: 'sensor.generation_to_house', battery_to_house_entity: 'sensor.battery_to_house', grid_to_house_entity: 'sensor.grid_to_house', change_house_bubble_color_with_flow: 1, }; hass = { states: { 'sensor.generation_to_house': { attributes: { unit_of_measurement: 'W', }, entity_id: 'generation_to_house', state: '1100.1221', }, 'sensor.battery_to_house': { attributes: { unit_of_measurement: 'W', }, entity_id: 'battery_to_house', state: '2051.1221', }, 'sensor.grid_to_house': { attributes: { unit_of_measurement: 'W', }, entity_id: 'grid_to_house', state: '1050.1221', }, }, }; await setViewport({ width: 1200, height: 1000 }); card = await setCard(hass, config); if (card.shadowRoot === null) assert.fail('No Card Shadowroot'); haCard = card.shadowRoot.querySelector('ha-card'); if (haCard === null || haCard === undefined) assert.fail('No ha-card'); teslaCard = haCard.querySelector('#tesla-style-solar-power-card'); if (teslaCard === null || teslaCard === undefined) assert.fail('No tesla-style-card'); }); it('house_icon shoud have success colour', async () => { const houseEntity = teslaCard?.querySelector('.house_entity'); if (houseEntity === null || houseEntity === undefined) assert.fail('No house_entity element found'); expect(houseEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, ''), 'sum of consumptions in mixed house consumption is wrong').contains( '4.2 kW' ); expect(houseEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString()).to.equal('mdi:home'); expect(houseEntity.style.color).to.equal('var(--success-color)'); }); const setGenerationToHouseFlow = async (state: string) => { hass.states['sensor.generation_to_house'].state = state; card.setAttribute('hass', JSON.stringify(hass)); await elementUpdated(card); await card.setConfig(config); }; it('house_icon shoud have warning colour', async () => { await setGenerationToHouseFlow('2500'); const houseEntity = teslaCard?.querySelector('.house_entity'); if (houseEntity === null || houseEntity === undefined) assert.fail('No house_entity element found'); expect(houseEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, ''), 'sum of consumptions in mixed house consumption is wrong').contains( '5.6 kW' ); expect(houseEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString()).to.equal('mdi:home'); expect(houseEntity.style.color).to.equal('var(--warning-color)'); }); const setGridToHouseFlow = async (state: string) => { hass.states['sensor.grid_to_house'].state = state; card.setAttribute('hass', JSON.stringify(hass)); await elementUpdated(card); await card.setConfig(config); }; it('house_icon shoud have no colour', async () => { await setGridToHouseFlow('3500'); const houseEntity = teslaCard?.querySelector('.house_entity'); if (houseEntity === null || houseEntity === undefined) assert.fail('No house_entity element found'); expect(houseEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, ''), 'sum of consumptions in mixed house consumption is wrong').contains( '6.7 kW' ); expect(houseEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString()).to.equal('mdi:home'); expect(houseEntity.style.color).to.equal('var(--info-color)'); }); }); ================================================ FILE: test/defaultConfig.test.ts ================================================ import { expect, assert } from '@open-wc/testing'; import { LovelaceCardConfig } from 'custom-card-helpers'; import { setViewport } from '@web/test-runner-commands'; import { TeslaStyleSolarPowerCard } from '../src/TeslaStyleSolarPowerCard.js'; import '../tesla-style-solar-power-card.js'; import { setCard } from './setters.js'; describe('TeslaStyleSolarPowerCard with defaultConfig', () => { let card: TeslaStyleSolarPowerCard; let haCard: HTMLElement | null; let teslaCard: HTMLElement | null | undefined; let hass: any; let config: LovelaceCardConfig; /** Tests are extended in energy_capable. * */ beforeEach(async () => { config = { type: 'custom:tesla-style-solar-power-card', name: 'Powerhouse', grid_to_house_entity: 'sensor.grid_to_house', grid_entity: 'sensor.grid_consumption', }; hass = { states: { 'sensor.grid_to_house': { attributes: { unit_of_measurement: 'w', }, entity_id: 'sensor.grid_to_house', state: '500.000000001', }, 'sensor.grid_consumption': { attributes: { unit_of_measurement: 'w', }, entity_id: 'sensor.grid_consumption', state: '500.000000001', }, }, }; await setViewport({ width: 1200, height: 1000 }); card = await setCard(hass, config); // let iframe = document.createElement('iframe'); // document.body.appendChild(iframe); // let div = document.createElement('div'); // document.body. // console.log("document width " + document.body.clientWidth); // document.body.appendChild(card); if (card.shadowRoot === null) assert.fail('No Card Shadowroot'); haCard = card.shadowRoot.querySelector('ha-card'); if (haCard === null || haCard === undefined) assert.fail('No ha-card'); teslaCard = ( haCard.querySelector('#tesla-style-solar-power-card') ); if (teslaCard === null || teslaCard === undefined) assert.fail('No tesla-style-card'); }); it('has card size 5', () => { expect(card.getCardSize()).to.equal(5); }); /* it('has a title "Powerhouse"', async () => { if(haCard === null || haCard === undefined) assert.fail("No ha-card"); if(haCard.shadowRoot === null) assert.fail(haCard.outerHTML); console.log(haCard); expect(haCard.shadowRoot.querySelector('h1')?.innerText).is.equal('Powerhouse'); }); */ it('has no warnings or errors', async () => { if (card.shadowRoot === null) assert.fail('No Card Shadowroot'); console.log(card.innerHTML); expect(card.shadowRoot.querySelectorAll('.message').length).to.equal(0); }); it('has grid_entity and icon', async () => { const gridEntity = teslaCard?.querySelector('.grid_entity'); if (gridEntity === null || gridEntity === undefined) assert.fail('No grid_entity element found'); expect(gridEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, '')).contains( '0.5 kW' ); expect( gridEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString() ).to.equal('mdi:transmission-tower'); }); it('has house_entity, text and icon', async () => { const houseEntity = teslaCard?.querySelector('.house_entity'); if (houseEntity === null || houseEntity === undefined) assert.fail('No house_entity element found'); expect(houseEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, '')).contains( '0.5 kW' ); expect( houseEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString() ).to.equal('mdi:home'); }); it('has grid to house consumption line and circle', async () => { // await setCardConsumingFromGrid(); const gridConsumptionLine = teslaCard?.querySelector( '#grid_to_house_entity_line' ); if (gridConsumptionLine === null || gridConsumptionLine === undefined) { assert.fail('No grid_to_house_entity_line element found'); } const gridConsumptionCircle = teslaCard?.querySelector( '#grid_to_house_entity_circle' ); if (gridConsumptionCircle === null || gridConsumptionCircle === undefined) { assert.fail('No grid_to_house_entity_circle element found'); } if (haCard === null || haCard === undefined) assert.fail('No ha-card'); expect(gridConsumptionLine?.getAttribute('hidden')).to.equal(null); }); it('has no pv, battery or appliance icons', async () => { const pvEntity = teslaCard?.querySelector('.generation_entity'); if (pvEntity !== null) assert.fail('No generation_entity element found'); const batteryEntity = teslaCard?.querySelector('.battery_entity'); if (batteryEntity !== null) assert.fail('No battery_entity element found'); const appliance1Entity = teslaCard?.querySelector( '.appliance1_consumption_entity' ); if (appliance1Entity !== null) assert.fail('No appliance1_consumption_entity element found'); const appliance2Entity = teslaCard?.querySelector( '.appliance2_consumption_entity' ); if (appliance2Entity !== null) assert.fail('No appliance2_consumption_entity element found'); }); }); ================================================ FILE: test/extraAppliances.test.ts ================================================ import { expect, assert } from '@open-wc/testing'; import { LovelaceCardConfig } from 'custom-card-helpers'; import { setViewport } from '@web/test-runner-commands'; import { TeslaStyleSolarPowerCard } from '../src/TeslaStyleSolarPowerCard.js'; import '../tesla-style-solar-power-card.js'; import { setCard } from './setters.js'; describe('TeslaStyleSolarPowerCard with extra appliances', () => { let card: TeslaStyleSolarPowerCard; let haCard: HTMLElement | null; let teslaCard: HTMLElement | null | undefined; let hass: any; let config: LovelaceCardConfig; /** Tests are extended in energy_capable. * */ beforeEach(async () => { config = { type: 'custom:tesla-style-solar-power-card', name: 'Powerhouse', house_entity: 'sensor.house_consumption', grid_to_house_entity: 'sensor.grid_to_house', appliance1_consumption_entity: 'sensor.car_consumption', appliance1_extra_entity: 'sensor.car_soc', appliance2_consumption_entity: 'sensor.heating_consumption', appliance2_extra_entity: 'sensor.heating_current_function', }; hass = { states: { 'sensor.heating_consumption': { attributes: { unit_of_measurement: 'W', friendly_name: 'Heating consumption', }, entity_id: 'heating_consumption', state: '1000', }, 'sensor.heating_current_function': { attributes: { unit_of_measurement: null, friendly_name: 'Heating function', }, entity_id: 'heating_current_function', state: 'Warm water', }, 'sensor.car_consumption': { attributes: { unit_of_measurement: 'W', }, entity_id: 'car_consumption', state: '2000', }, 'sensor.car_soc': { attributes: { unit_of_measurement: '%', }, entity_id: 'car_soc', state: '90', }, 'sensor.house_consumption': { attributes: { unit_of_measurement: 'W', }, entity_id: 'house_consumption', state: '4000', }, 'sensor.grid_to_house': { attributes: { unit_of_measurement: 'W', }, entity_id: 'grid_to_house', state: '4000', }, }, }; await setViewport({ width: 1200, height: 1000 }); card = await setCard(hass, config); if (card.shadowRoot === null) assert.fail('No Card Shadowroot'); haCard = card.shadowRoot.querySelector('ha-card'); if (haCard === null || haCard === undefined) assert.fail('No ha-card'); teslaCard = ( haCard.querySelector('#tesla-style-solar-power-card') ); if (teslaCard === null || teslaCard === undefined) assert.fail('No tesla-style-card'); }); it('has house_entity, text and icon', async () => { const gridEntity = teslaCard?.querySelector('.house_entity'); if (gridEntity === null || gridEntity === undefined) assert.fail('No house_entity element found'); expect(gridEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, '')).contains('4 kW'); expect( gridEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString() ).to.equal('mdi:home'); }); it('has appliance1_consumption_entity, text and icon', async () => { const gridEntity = teslaCard?.querySelector( '.appliance1_consumption_entity' ); if (gridEntity === null || gridEntity === undefined) assert.fail('No appliance1_consumption_entity element found'); expect( gridEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, ''), 'No sum of appliance1 charging flows in acc_text of grid_entity' ).contains('2 kW'); expect( gridEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString() ).to.equal('mdi:car-sports'); expect( gridEntity?.querySelector('.acc_text_extra')?.innerHTML.replace(/)]+-->/g, ''), 'Appliance 1 extra text is wrong' ).contains('90 %'); }); it('has appliance2_consumption_entity, text and icon', async () => { const gridEntity = teslaCard?.querySelector( '.appliance2_consumption_entity' ); if (gridEntity === null || gridEntity === undefined) assert.fail('No appliance2_consumption_entity element found'); expect( gridEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, ''), 'No sum of appliance2 charging flows in acc_text of grid_entity' ).contains('1 kW'); expect( gridEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString() ).to.equal('mdi:air-filter'); expect( gridEntity?.querySelector('.acc_text_extra')?.innerHTML, 'Appliance 2 extra text is wrong' ).contains('Warm water'); }); it('has appliance1 line and circle', async () => { const appliance = teslaCard?.querySelector( '#appliance1_consumption_entity_line' ); if (appliance === null || appliance === undefined) { assert.fail('No appliance1_consumption_entity_line element found'); } const applianceCircle = teslaCard?.querySelector( '#appliance1_consumption_entity_circle' ); if (applianceCircle === null || applianceCircle === undefined) { assert.fail('No appliance1_consumption_entity_circle element found'); } expect(appliance?.getAttribute('hidden')).to.equal(null); }); it('has appliance2 line and circle', async () => { const applianceLine = teslaCard?.querySelector( '#appliance2_consumption_entity_line' ); if (applianceLine === null || applianceLine === undefined) { assert.fail('No appliance2_consumption_entity_line element found'); } const applianceCircle = teslaCard?.querySelector( '#appliance1_consumption_entity_circle' ); if (applianceCircle === null || applianceCircle === undefined) { assert.fail('No appliance2_consumption_entity_circle element found'); } expect(applianceLine?.getAttribute('hidden')).to.equal(null); }); it('has no pv, grid or battery icons', async () => { // assert.fail(haCard?.innerHTML); const pvEntity = teslaCard?.querySelector('.generation_yield_entity'); if (pvEntity !== null) assert.fail('pv_consumption_entity element found, should not be there'); const batteryEntity = teslaCard?.querySelector( '.battery_consumption_entity' ); if (batteryEntity !== null) assert.fail( 'battery_consumption_entity element found, should not be there' ); const gridEntity = teslaCard?.querySelector('.grid_consumption_entity'); if (gridEntity !== null) assert.fail('grid_consumption_entity element found, should not be there'); }); }); ================================================ FILE: test/extraAppliancesNotInHouse.test.ts ================================================ import { expect, assert } from '@open-wc/testing'; import { LovelaceCardConfig } from 'custom-card-helpers'; import { setViewport } from '@web/test-runner-commands'; import { TeslaStyleSolarPowerCard } from '../src/TeslaStyleSolarPowerCard.js'; import '../tesla-style-solar-power-card.js'; import { setCard } from './setters.js'; describe('TeslaStyleSolarPowerCard with extra appliances', () => { let card: TeslaStyleSolarPowerCard; let haCard: HTMLElement | null; let teslaCard: HTMLElement | null | undefined; let hass: any; let config: LovelaceCardConfig; /** Tests are extended in energy_capable. * */ beforeEach(async () => { config = { type: 'custom:tesla-style-solar-power-card', name: 'Powerhouse', house_entity: 'sensor.house_consumption', grid_to_house_entity: 'sensor.grid_to_house', appliance1_consumption_entity: 'sensor.car_consumption', appliance1_extra_entity: 'sensor.car_soc', appliance2_consumption_entity: 'sensor.heating_consumption', appliance2_extra_entity: 'sensor.heating_current_function', house_without_appliances_values: 'sensor.heating_current_function', }; hass = { states: { 'sensor.heating_consumption': { attributes: { unit_of_measurement: 'W', friendly_name: 'Heating consumption', }, entity_id: 'heating_consumption', state: '1000', }, 'sensor.heating_current_function': { attributes: { unit_of_measurement: null, friendly_name: 'Heating function', }, entity_id: 'heating_current_function', state: 'Warm water', }, 'sensor.car_consumption': { attributes: { unit_of_measurement: 'W', }, entity_id: 'car_consumption', state: '2000', }, 'sensor.car_soc': { attributes: { unit_of_measurement: '%', }, entity_id: 'car_soc', state: '90', }, 'sensor.house_consumption': { attributes: { unit_of_measurement: 'W', }, entity_id: 'house_consumption', state: '4000', }, 'sensor.grid_to_house': { attributes: { unit_of_measurement: 'W', }, entity_id: 'grid_to_house', state: '4000', }, }, }; await setViewport({ width: 1200, height: 1000 }); card = await setCard(hass, config); if (card.shadowRoot === null) assert.fail('No Card Shadowroot'); haCard = card.shadowRoot.querySelector('ha-card'); if (haCard === null || haCard === undefined) assert.fail('No ha-card'); teslaCard = ( haCard.querySelector('#tesla-style-solar-power-card') ); if (teslaCard === null || teslaCard === undefined) assert.fail('No tesla-style-card'); }); it('has house_entity, text and icon', async () => { const houseEntity = teslaCard?.querySelector('.house_entity'); if (houseEntity === null || houseEntity === undefined) assert.fail('No house_entity element found'); expect(houseEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, '')).contains('1 kW'); expect( houseEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString() ).to.equal('mdi:home'); }); }); ================================================ FILE: test/gridFeed.test.ts ================================================ import { expect, assert } from '@open-wc/testing'; import { LovelaceCardConfig } from 'custom-card-helpers'; import { setViewport } from '@web/test-runner-commands'; import { TeslaStyleSolarPowerCard } from '../src/TeslaStyleSolarPowerCard.js'; import '../tesla-style-solar-power-card.js'; import { setCard } from './setters.js'; describe('TeslaStyleSolarPowerCard with defaultConfig', () => { let card: TeslaStyleSolarPowerCard; let haCard: HTMLElement | null; let teslaCard: HTMLElement | null | undefined; let hass: any; let config: LovelaceCardConfig; /** Tests are extended in energy_capable. * */ beforeEach(async () => { config = { type: 'custom:tesla-style-solar-power-card', name: 'Powerhouse', generation_to_grid_entity: 'sensor.generation_to_grid', battery_to_grid_entity: 'sensor.battery_to_grid', grid_entity: 'sensor.grid_consumption', }; hass = { states: { 'sensor.battery_to_grid': { attributes: { unit_of_measurement: 'W', friendly_name: 'Batter to battery', }, entity_id: 'sensor.battery_to_grid', state: '1000', }, 'sensor.grid_consumption': { attributes: { unit_of_measurement: 'W', }, entity_id: 'grid_consumption', state: '0', }, 'sensor.generation_to_grid': { attributes: { unit_of_measurement: 'W', }, entity_id: 'generation_to_grid', state: '1000', }, }, }; await setViewport({ width: 1200, height: 1000 }); card = await setCard(hass, config); if (card.shadowRoot === null) assert.fail('No Card Shadowroot'); haCard = card.shadowRoot.querySelector('ha-card'); if (haCard === null || haCard === undefined) assert.fail('No ha-card'); teslaCard = ( haCard.querySelector('#tesla-style-solar-power-card') ); if (teslaCard === null || teslaCard === undefined) assert.fail('No tesla-style-card'); }); it('has grid_entity, text and icon', async () => { const gridEntity = teslaCard?.querySelector('.grid_entity'); if (gridEntity === null || gridEntity === undefined) assert.fail('No grid_entity element found'); expect( gridEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, ''), 'No sum of grid charging flows in acc_text of grid_entity' ).contains('2 kW'); expect( gridEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString() ).to.equal('mdi:transmission-tower'); }); it('has battery to grid feed in line and circle', async () => { const batteryToGridLine = teslaCard?.querySelector( '#battery_to_grid_entity_line' ); if (batteryToGridLine === null || batteryToGridLine === undefined) { assert.fail('No battery_to_grid_entity_line element found'); } const batteryToGridCircle = teslaCard?.querySelector( '#battery_to_grid_entity_circle' ); if (batteryToGridCircle === null || batteryToGridCircle === undefined) { assert.fail('No battery_to_grid_entity_circle element found'); } expect(batteryToGridLine?.getAttribute('hidden')).to.equal(null); }); it('has solar to grid line and circle', async () => { const solarToGridLine = teslaCard?.querySelector( '#generation_to_grid_entity_line' ); if (solarToGridLine === null || solarToGridLine === undefined) { assert.fail('No generation_to_battery_entity_line element found'); } const solarToGridCircle = teslaCard?.querySelector( '#generation_to_grid_entity_circle' ); if (solarToGridCircle === null || solarToGridCircle === undefined) { assert.fail('No generation_to_battery_entity_circle element found'); } expect(solarToGridLine?.getAttribute('hidden')).to.equal(null); }); it('has no pv, grid or appliance icons', async () => { const pvEntity = teslaCard?.querySelector('.pv_consumption_entity'); if (pvEntity !== null) assert.fail('No pv_consumption_entity element found'); const batteryEntity = teslaCard?.querySelector( '.battery_consumption_entity' ); if (batteryEntity !== null) assert.fail('No battery_consumption_entity element found'); const houseEntity = teslaCard?.querySelector('.house_consumption_entity'); if (houseEntity !== null) assert.fail('No house_consumption_entity element found'); const appliance1Entity = teslaCard?.querySelector( '.appliance1_consumption_entity' ); if (appliance1Entity !== null) assert.fail('No appliance1_consumption_entity element found'); const appliance2Entity = teslaCard?.querySelector( '.appliance2_consumption_entity' ); if (appliance2Entity !== null) assert.fail('No appliance2_consumption_entity element found'); }); }); ================================================ FILE: test/setters.ts ================================================ import {elementUpdated, fixture, html} from "@open-wc/testing"; import { TeslaStyleSolarPowerCard } from "../src/TeslaStyleSolarPowerCard.js"; const setCard = async (hass:any, config:any) => { const card = await fixture( html` ` ); await card.setConfig(config); // Call firstUpdated() again because fixture already triggered it the first time. await card.firstUpdated(); // TODO: Why is this needed for one test case only: 'has debug warning'? await card.setConfig(config); return card; }; const setCardView = async (card:any, view:any) => { card.setAttribute('view', view); }; const setCardAllInactive = async (card:any, hass:any, config:any) => { hass.states['sensor.solar_power'].state = "0"; if (hass.states['sensor.grid_power']) { hass.states['sensor.grid_power'].state = "0"; } if (hass.states['sensor.grid_power_consumption']) { hass.states['sensor.grid_power_consumption'].state = "0"; } if (hass.states['sensor.grid_power_production']) { hass.states['sensor.grid_power_production'].state = "0"; } if (hass.states['sensor.battery_power']) { hass.states['sensor.battery_power'].state = "0"; } card.setAttribute('hass', JSON.stringify(hass)); await elementUpdated(card); await card.setConfig(config); }; export {setCard, setCardView, setCardAllInactive}; ================================================ FILE: test/solarProduction.test.ts ================================================ import { expect, assert } from '@open-wc/testing'; import { LovelaceCardConfig } from 'custom-card-helpers'; import { setViewport } from '@web/test-runner-commands'; import { TeslaStyleSolarPowerCard } from '../src/TeslaStyleSolarPowerCard.js'; import '../tesla-style-solar-power-card.js'; import { setCard } from './setters.js'; describe('TeslaStyleSolarPowerCard with solarConfig', () => { let card: TeslaStyleSolarPowerCard; let haCard: HTMLElement | null; let teslaCard: HTMLElement | null | undefined; let hass: any; let config: LovelaceCardConfig; /** Tests are extended in energy_capable. * */ beforeEach(async () => { config = { type: 'custom:tesla-style-solar-power-card', name: 'Powerhouse', generation_to_grid_entity: 'sensor.generation_to_grid', generation_to_battery_entity: 'sensor.generation_to_battery', generation_to_house_entity: 'sensor.generation_to_house', generation_entity: 'sensor.generation_entity', }; hass = { states: { 'sensor.generation_to_battery': { attributes: { unit_of_measurement: 'W', friendly_name: 'Batter to battery', }, entity_id: 'sensor.generation_to_battery', state: '3100.211', }, 'sensor.generation_entity': { attributes: { unit_of_measurement: 'W', }, entity_id: 'generation_entity', state: '8100', }, 'sensor.generation_to_grid': { attributes: { unit_of_measurement: 'W', }, entity_id: 'generation_to_grid', state: '4000.0000011221', }, 'sensor.generation_to_house': { attributes: { unit_of_measurement: 'W', }, entity_id: 'generation_to_house', state: '1000.1221', }, }, }; await setViewport({ width: 1200, height: 1000 }); card = await setCard(hass, config); if (card.shadowRoot === null) assert.fail('No Card Shadowroot'); haCard = card.shadowRoot.querySelector('ha-card'); if (haCard === null || haCard === undefined) assert.fail('No ha-card'); teslaCard = ( haCard.querySelector('#tesla-style-solar-power-card') ); if (teslaCard === null || teslaCard === undefined) assert.fail('No tesla-style-card'); }); it('has generation_entity, text and icon', async () => { const solarYieldEntity = teslaCard?.querySelector('.generation_entity'); if (solarYieldEntity === null || solarYieldEntity === undefined) assert.fail('No generation_entity element found'); expect( solarYieldEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, ''), 'No sum of grid charging flows in acc_text of grid_entity' ).contains('8.1 kW'); expect( solarYieldEntity ?.querySelector('.acc_icon') ?.getAttribute('icon') ?.toString() ).to.equal('mdi:solar-panel-large'); }); it('has solar to house feed line and circle', async () => { const solarToHouseLine = teslaCard?.querySelector( '#generation_to_house_entity_line' ); if (solarToHouseLine === null || solarToHouseLine === undefined) { assert.fail('No generation_to_house_entity_line element found'); } const solarToHouseCircle = teslaCard?.querySelector( '#generation_to_house_entity_circle' ); if (solarToHouseCircle === null || solarToHouseCircle === undefined) { assert.fail('No generation_to_house_entity_circle element found'); } expect(solarToHouseLine?.getAttribute('hidden')).to.equal(null); }); it('has solar to grid line and circle', async () => { const solarToGridLine = teslaCard?.querySelector( '#generation_to_grid_entity_line' ); if (solarToGridLine === null || solarToGridLine === undefined) { assert.fail('No generation_to_grid_entity_line element found'); } const solarToGridCircle = teslaCard?.querySelector( '#generation_to_grid_entity_circle' ); if (solarToGridCircle === null || solarToGridCircle === undefined) { assert.fail('No generation_to_grid_entity_circle element found'); } expect(solarToGridLine?.getAttribute('hidden')).to.equal(null); }); it('has solar to battery line and circle', async () => { const solarToBatteryLine = teslaCard?.querySelector( '#generation_to_battery_entity_line' ); if (solarToBatteryLine === null || solarToBatteryLine === undefined) { assert.fail('No generation_to_battery_entity_line element found'); } const solarToBatteryCircle = teslaCard?.querySelector( '#generation_to_battery_entity_circle' ); if (solarToBatteryCircle === null || solarToBatteryCircle === undefined) { assert.fail('No generation_to_battery_entity_circle element found'); } expect(solarToBatteryLine?.getAttribute('hidden')).to.equal(null); }); it('has no pv, grid or appliance icons', async () => { const gridEntity = teslaCard?.querySelector('.grid_consumption_entity'); if (gridEntity !== null) assert.fail('grid_consumption_entity element found'); const batteryEntity = teslaCard?.querySelector( '.battery_consumption_entity' ); if (batteryEntity !== null) assert.fail('battery_consumption_entity element found'); const houseEntity = teslaCard?.querySelector('.house_consumption_entity'); if (houseEntity !== null) assert.fail('house_consumption_entity element found'); const appliance1Entity = teslaCard?.querySelector( '.appliance1_consumption_entity' ); if (appliance1Entity !== null) assert.fail('appliance1_consumption_entity element found'); const appliance2Entity = teslaCard?.querySelector( '.appliance2_consumption_entity' ); if (appliance2Entity !== null) assert.fail('appliance2_consumption_entity element found'); }); }); ================================================ FILE: test/tesla-style-solar-power-card.test.ts ================================================ import { html, fixture, expect } from '@open-wc/testing'; import { TeslaStyleSolarPowerCard } from '../src/TeslaStyleSolarPowerCard.js'; import '../tesla-style-solar-power-card.js'; /* describe('TeslaStyleSolarPowerCard', () => { it('has a default title "Hey there" and counter 5', async () => { const el = await fixture(html``); expect(el.title).to.equal('Hey there'); expect(el.counter).to.equal(5); }); it('increases the counter on button click', async () => { const el = await fixture(html``); el.shadowRoot!.querySelector('button')!.click(); expect(el.counter).to.equal(6); }); it('can override the title via attribute', async () => { const el = await fixture(html``); expect(el.title).to.equal('attribute title'); }); it('has an accesible shadowDom', async () => { const el = await fixture(html``); await expect(el).shadowDom.to.be.accessible(); }); }); */ ================================================ FILE: test/threshold.test.ts ================================================ import { expect, /* elementUpdated, */ assert } from '@open-wc/testing'; import { LovelaceCardConfig } from 'custom-card-helpers'; import { setViewport } from '@web/test-runner-commands'; import { TeslaStyleSolarPowerCard } from '../src/TeslaStyleSolarPowerCard.js'; import '../tesla-style-solar-power-card.js'; import { setCard } from './setters.js'; describe('TeslaStyleSolarPowerCard with threshold', () => { let card: TeslaStyleSolarPowerCard; let haCard: HTMLElement | null; let teslaCard: HTMLElement | null | undefined; let hass: any; let config: LovelaceCardConfig; /** Tests are extended in energy_capable. * */ beforeEach(async () => { config = { type: 'custom:tesla-style-solar-power-card', name: 'Powerhouse', generation_to_house_entity: 'sensor.generation_to_house', generation_to_grid_entity: 'sensor.generation_to_grid', battery_to_house_entity: 'sensor.battery_to_house', threshold_in_k: 1, }; hass = { states: { 'sensor.generation_to_house': { attributes: { unit_of_measurement: 'W', }, entity_id: 'sensor.grid_to_house', state: '2801.000000001', }, 'sensor.generation_to_grid': { attributes: { unit_of_measurement: 'W', }, entity_id: 'sensor.grid_to_house', state: '1801.000000001', }, 'sensor.battery_to_house': { attributes: { unit_of_measurement: 'W', }, entity_id: 'sensor.battery_to_house', state: '801.000000001', }, }, }; await setViewport({ width: 1200, height: 1000 }); card = await setCard(hass, config); // let iframe = document.createElement('iframe'); // document.body.appendChild(iframe); // let div = document.createElement('div'); // document.body. // console.log("document width " + document.body.clientWidth); // document.body.appendChild(card); if (card.shadowRoot === null) assert.fail('No Card Shadowroot'); haCard = card.shadowRoot.querySelector('ha-card'); if (haCard === null || haCard === undefined) assert.fail('No ha-card'); teslaCard = haCard.querySelector('#tesla-style-solar-power-card'); if (teslaCard === null || teslaCard === undefined) assert.fail('No tesla-style-card'); // console.log(teslaCard); }); /* const setGridToHouseFlow = async (state: string) => { hass.states['sensor.grid_to_house'].state = state; card.setAttribute('hass', JSON.stringify(hass)); await elementUpdated(card); await card.setConfig(config); }; */ it('battery is below threshold', async () => { const gridEntity = teslaCard?.querySelector('.battery_entity'); if (gridEntity === null || gridEntity === undefined) assert.fail('No battery_entity element found'); expect(gridEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, '')).contains('801 W'); expect(gridEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString()).to.equal('mdi:battery-medium'); }); it('grid is above threshold', async () => { const gridEntity = teslaCard?.querySelector('.grid_entity'); if (gridEntity === null || gridEntity === undefined) assert.fail('No grid_entity element found'); expect(gridEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, '')).contains('1.8 kW'); expect(gridEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString()).to.equal('mdi:transmission-tower'); }); it('house is above threshold', async () => { card.requestUpdate(); const houseEntity = teslaCard?.querySelector('.house_entity'); if (houseEntity === null || houseEntity === undefined) assert.fail('No house_entity element found'); expect(houseEntity?.querySelector('.acc_text')?.innerHTML.replace(/)]+-->/g, '')).contains('3.6 kW'); expect(houseEntity?.querySelector('.acc_icon')?.getAttribute('icon')?.toString()).to.equal('mdi:home'); }); }); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es2018", "module": "esnext", "moduleResolution": "node", "noEmitOnError": true, "lib": ["es2017", "dom"], "strict": true, "esModuleInterop": false, "allowSyntheticDefaultImports": true, "experimentalDecorators": true, "importHelpers": true, "outDir": "dist", "sourceMap": true, "inlineSources": true, "rootDir": "./", "declaration": true, "suppressImplicitAnyIndexErrors": true, "resolveJsonModule": true }, "include": ["**/*.ts"] } ================================================ FILE: web-dev-server.config.mjs ================================================ // import { hmrPlugin, presets } from '@open-wc/dev-server-hmr'; /** Use Hot Module replacement by adding --hmr to the start command */ const hmr = process.argv.includes('--hmr'); export default /** @type {import('@web/dev-server').DevServerConfig} */ ({ nodeResolve: true, open: '/demo/', watch: !hmr, /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */ // esbuildTarget: 'auto' /** Set appIndex to enable SPA routing */ // appIndex: 'demo/index.html', /** Confgure bare import resolve plugin */ // nodeResolve: { // exportConditions: ['browser', 'development'] // }, plugins: [ /** Use Hot Module Replacement by uncommenting. Requires @open-wc/dev-server-hmr plugin */ // hmr && hmrPlugin({ exclude: ['**/*/node_modules/**/*'], presets: [presets.litElement] }), ], // See documentation for all available options }); ================================================ FILE: web-test-runner.config.mjs ================================================ // import { playwrightLauncher } from '@web/test-runner-playwright'; // import { legacyPlugin } from '@web/dev-server-legacy'; export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({ files: 'dist/test/**/*.test.js', nodeResolve: true, /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */ // esbuildTarget: 'auto', /** Confgure bare import resolve plugin */ // nodeResolve: { // exportConditions: ['browser', 'development'] // }, /** Amount of browsers to run concurrently */ // concurrentBrowsers: 2, /** Amount of test files per browser to test concurrently */ // concurrency: 1, /** Browsers to run tests on */ // browsers: [ // playwrightLauncher({ product: 'chromium' }), // playwrightLauncher({ product: 'firefox' }), // playwrightLauncher({ product: 'webkit' }), // ], // See documentation for all available options });