├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 1-report-an-issue.yaml │ └── 2-feature-request.yaml └── workflows │ ├── hacs.yaml │ ├── hassfest.yaml │ └── release.yaml ├── .gitignore ├── HA_AS_EVCC_SOURCE.md ├── HA_CONTROLLED_BY_EVCC.md ├── LICENSE ├── README.md ├── custom_components ├── __init__.py └── evcc_intg │ ├── __init__.py │ ├── binary_sensor.py │ ├── button.py │ ├── config_flow.py │ ├── const.py │ ├── icons.json │ ├── manifest.json │ ├── number.py │ ├── pyevcc_ha │ ├── __init__.py │ ├── const.py │ └── keys.py │ ├── select.py │ ├── sensor.py │ ├── service.py │ ├── services.yaml │ ├── switch.py │ └── translations │ ├── de.json │ └── en.json ├── evcc-with-integrated-devices.png ├── hacs.json ├── logo-ha.png ├── logo.png ├── requirements.txt ├── requirements_dev.txt └── sample-dashboard.png /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: marq24 2 | #buy_me_a_coffee: marquardt24 3 | #custom: https://paypal.me/marq24 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-report-an-issue.yaml: -------------------------------------------------------------------------------- 1 | name: Problem Report 2 | description: please report any technical issue with this home assistant integration - please note this is not a official evcc repository or service 3 | labels: bug 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | ## HACs does not always notify you about new version's - you must check do this manually! 9 | Please follow this routine: 10 | 1. In Home Assistant go to `HACS` 11 | 2. In the list of installed integrations search for `evcc` 12 | 3. Click on the 3-dot menu on the right side of the `evcc` integration list entry 13 | 4. Click on `Update Information` 14 | 5. Note now that a new version is available 15 | 6. Install the update 16 | - type: checkboxes 17 | id: checklist 18 | attributes: 19 | label: Checklist 20 | description: Please go though this short checklist - TIA 21 | options: 22 | - label: I confirm that I have [read & understand the main Integration 'check-list'](https://github.com/marq24/ha-evcc/discussions/166) 23 | required: true 24 | - label: I confirm, that I my evcc instance is configured, is up & running and that **I can access the default evcc webinterface** 25 | required: true 26 | - label: My home assistant version is up to date. 27 | required: true 28 | - label: I am using the **latest** version of the integration | See the [release list @github](https://github.com/marq24/ha-evcc/releases) or in home assistant use the HACS 'Update Information' function to ensure that the **latest** released version of the integration is installed. This HACS function can be accessed via the 3-dot menu on the right side for each integration in the HACS integration list. 29 | required: true 30 | - label: I have [checked all issues (incl. the closed ones)](https://github.com/marq24/ha-evcc/issues?q=is%3Aissue+is%3Aclosed) for similar issues in order to avoid reporting a duplicate issue . 31 | required: true 32 | - label: I have prepared DEBUG log output (for technical issues) | In most of the cases of a technical error/issue I would have the need to ask for DEBUG log output of the integration. There is a short [tutorial/guide 'How to provide DEBUG log' here](https://github.com/marq24/ha-senec-v3/blob/master/docs/HA_DEBUG.md) 33 | required: true 34 | - label: I confirm it's really an issue | In the case that you want to understand the functionality of a certain feature/sensor Please be so kind and make use if the discussion feature of this repo (and do not create an issue) - TIA 35 | - label: | 36 | I confirm, that I did not read any of the previous bulletin-points and just checked them all. | I don't wanted to waste my time with details, I don't read or follow any existing instructions. | Instead, I want that the maintainer of this repo will spend time explaining the world to me — that's marq24's job!. | I live by the motto: Better to ask twice, than to think about it once. | It's marq24's own fault that he provides open-source software and is willing to offer free support. 37 | - type: textarea 38 | id: content 39 | attributes: 40 | label: Add a description 41 | placeholder: "Please provide details about your issue - in the best case a short step by step instruction how to reproduce the issue - TIA." 42 | - type: textarea 43 | id: logs 44 | attributes: 45 | label: Add your DEBUG log output 46 | placeholder: "Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks." 47 | render: shell 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this home assistant integration 3 | labels: enhancement 4 | body: 5 | - type: textarea 6 | id: content 7 | attributes: 8 | label: Description 9 | placeholder: "Let me know what do you miss..." 10 | - type: textarea 11 | id: logs 12 | attributes: 13 | label: API Keys 14 | placeholder: "When you miss some data - please let me know the API-Key that provide the data - TIA" 15 | render: shell -------------------------------------------------------------------------------- /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v2" 15 | - name: HACS Action 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Zip Release 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | release: 10 | name: Release ha-evcc 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | # Step 1: Check out the code 16 | - name: ⤵️ Check out code from GitHub 17 | uses: actions/checkout@v4 18 | 19 | # Step 2: (Optional, but recommended) Install yq (in case it's not present) 20 | #- name: Install yq 21 | # run: sudo apt-get update && sudo apt-get install -y yq 22 | 23 | # Step 3: Update version in manifest.json to match the release tag 24 | #- name: 🔢 Adjust version number 25 | # shell: bash 26 | # run: | 27 | # version="${{ github.event.release.tag_name }}" 28 | # version="${version,,}" # convert to lowercase (e.g., V2024.10.1 -> v2024.10.1) 29 | # version="${version#v}" # remove leading 'v' if present 30 | # yq e -P -o=json \ 31 | # -i ".version = \"${version}\"" \ 32 | # "${{ github.workspace }}/custom_components/evcc_intg/manifest.json" 33 | 34 | # Step 4: Create a ZIP archive of the component directory 35 | - name: 📦 Create zipped release package 36 | shell: bash 37 | run: | 38 | cd "${{ github.workspace }}/custom_components/evcc_intg" 39 | zip evcc_intg.zip -r ./ 40 | 41 | # Step 5: Upload ZIP as a release asset (for HACS) 42 | - name: ⬆️ Upload zip to release 43 | uses: softprops/action-gh-release@v2 44 | with: 45 | files: ${{ github.workspace }}/custom_components/evcc_intg/evcc_intg.zip -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | pythonenv* 3 | .python-version 4 | .coverage 5 | venv 6 | .venv 7 | core.* 8 | *.iml 9 | .misc 10 | .idea 11 | .vscode 12 | -------------------------------------------------------------------------------- /HA_AS_EVCC_SOURCE.md: -------------------------------------------------------------------------------- 1 | # Use evcc with your Home Assistant sensor data 2 | 3 | [evcc](https://github.com/evcc-io/evcc) is a common interface to charge electric vehicles. By default, evcc fetching the required data (meters/sites: grid, PV & battery states) directly from your PV/Battery System. This implies that the load on your PV/Battery system will increase and this could lead unfortunately to internal errors (this just happens to me when I tried to configue evcc via the default SENEC template included in evcc). 4 | 5 | __We have already all data (evcc needs) in Home Assistant!__ — so why not use this information and provide it to evcc? The good news is, that this is possible — the bad news is, that' IMHO way to complicate for the average user to configure this. 6 | 7 | So I will provide here an example evcc.yml (meters and site section) that can be used in order that evcc will be fed with data from your HA installation (having my SENEC.Home Integration installed). 8 | 9 | This tutorial could be used also with other solar integrations — but you need to replace the corresponding sensor entities with the ones that match. 10 | 11 | ## Preparation: 1'st Make Home Assistant sensor data accessible via API calls 12 | 13 | As mentioned in the introduction, with the SENEC.Home integration for HA we have already all home-installation data evcc going to need — we just need a way to provide this HA sensor data to evcc. 14 | 15 | ### Create a Long-lived access token 16 | 17 | Before we can use the HA-API to read sensor data via http, we need some sort of access (for evcc) to the API. Therefor you need to create so-called __Long-lived access token__. This can be done in the _Security_ tab of your _Profile_. 18 | 19 | You can open this via `http://[YOUR-HA-INSTANCE]:8123/profile/security` 20 | 21 | ![screenshot_tokens](https://github.com/marq24/ha-senec-v3/blob/master/images/evcc_token01.png) 22 | 23 | Create a new token via the _Create Token_ button, specify a name (e.g. 'evcc-access') and then copy the generated token to your clipboard (and paste it to a secure place). A token will look like this: 24 | 25 | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzNWVjNzg5M2Y0ZjQ0MzBmYjUwOGEwMmU4N2Q0MzFmNyIsImlhdCI6MTcxNTUwNzYxMCwiZXhwIjoyMDMwODY3NjEwfQ.GMWO8saHpawkjNzk-uokxYeaP0GFKPQSeDoP3lCO488` 26 | 27 | Please do not share your token anywhere! [the token above is really just an example!] — you need to replace all occurrences of `[YOUR-TOKEN-HERE]` in the following example evcc.yml section with __your generated token__. 28 | 29 | ## Preparation: 2'nd Collect all required Home Assistant sensor names 30 | 31 | This step is only required, if you want to use alternative HA sensors. In order to support the core features of evcc we need at least current grid power. __SENEC.Home.V4 users must use the alternative sensors as they are provided by the 'webapi'__. But since this is a SENEC.Home tutorial I will provide here (my) full list of sensors: 32 | 33 | - __GRID__: 34 | - Power (negative when exporting power to the grid, positive when importing power from the grid) 35 | - `sensor.senec_grid_state_power` 36 | - Current of P1,P2 & P3: 37 | - `sensor.senec_enfluri_net_current_p1` 38 | - `sensor.senec_enfluri_net_current_p2` 39 | - `sensor.senec_enfluri_net_current_p3` 40 | 41 | 42 | - __PV__: 43 | - Generated power by PV (total): 44 | - `sensor.senec_solar_generated_power` 45 | 46 | 47 | - __Battery__: 48 | - Battery power, negative when consumed by battery (charging) (Please note, that evcc expects a positive value when battery will be charged and a negative when energy from battery will be consumed — we deal with this in the meters configuration later) 49 | - `sensor.senec_battery_state_power` 50 | - State of Charge (in percent) 51 | - `sensor.senec_battery_charge_percent` 52 | 53 | 54 | - _Optional_ __Aux__: 55 | - Electrical consumption of my waterkotte heatpump: 56 | - `sensor.wkh_power_electric` 57 | 58 | - Electrical consumption of my pool pump & heating (Shelly): 59 | - `sensor.kanal_1_pool_power` 60 | 61 | - Electrical consumption of my garden (water) pump (Shelly): 62 | - `sensor.kanal_2_power` 63 | 64 | 65 | ## Example evcc.yaml (`meters` section) 66 | 67 | ### Required replacements 68 | 69 | Below you will find a valid evcc meters configuration — __but you have to make two replacements__: 70 | 1. The text '__[YOUR-HA-INSTANCE]__' has to be replaced with the IP/host name of your Home Assistant installation. 71 | 72 | E.g., when your HA is reachable via: http://192.168.10.20:8123, then you need to replace `[YOUR-HA-INSTANCE]` with `192.168.10.20` 73 | 74 | 75 | 2. The text '__[YOUR-TOKEN-HERE]__' has to be replaced with the _Long-lived access token_ you have just created in HA. 76 | 77 | E.g. when your token is: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzNWVjNzg5M2Y0ZjQ0MzBmYjUwOGEwMmU4N2Q0MzFmNyIsImlhdCI6MTcxNTUwNzYxMCwiZXhwIjoyMDMwODY3NjEwfQ.GMWO8saHpawkjNzk-uokxYeaP0GFKPQSeDoP3lCO488`, then you need to replace `[YOUR-TOKEN-HERE]` with this (long) token text. 78 | 79 | So as a short example (with all replacements) would look like: 80 | 81 | ``` 82 | ... 83 | source: http 84 | uri: http://192.168.10.20:8123/api/states/sensor.senec_grid_state_power 85 | method: GET 86 | headers: 87 | - Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzNWVjNzg5M2Y0ZjQ0MzBmYjUwOGEwMmU4N2Q0MzFmNyIsImlhdCI6MTcxNTUwNzYxMCwiZXhwIjoyMDMwODY3NjEwfQ.GMWO8saHpawkjNzk-uokxYeaP0GFKPQSeDoP3lCO488 88 | insecure: true 89 | ... 90 | ``` 91 | 92 | ### Complete sample evcc.yaml meters section for SENEC.Home Sensors 93 | ```yaml 94 | meters: 95 | - name: SENEC.grid 96 | type: custom 97 | power: 98 | source: http 99 | uri: http://[YOUR-HA-INSTANCE]:8123/api/states/sensor.senec_grid_state_power 100 | method: GET 101 | headers: 102 | - Authorization: Bearer [YOUR-TOKEN-HERE] 103 | insecure: true 104 | jq: .state|tonumber 105 | timeout: 2s 106 | currents: 107 | - source: http 108 | uri: http://[YOUR-HA-INSTANCE]:8123/api/states/sensor.senec_enfluri_net_current_p1 109 | method: GET 110 | headers: 111 | - Authorization: Bearer [YOUR-TOKEN-HERE] 112 | insecure: true 113 | jq: .state|tonumber 114 | timeout: 2s 115 | - source: http 116 | uri: http://[YOUR-HA-INSTANCE]:8123/api/states/sensor.senec_enfluri_net_current_p2 117 | method: GET 118 | headers: 119 | - Authorization: Bearer [YOUR-TOKEN-HERE] 120 | insecure: true 121 | jq: .state|tonumber 122 | timeout: 2s 123 | - source: http 124 | uri: http://[YOUR-HA-INSTANCE]:8123/api/states/sensor.senec_enfluri_net_current_p3 125 | method: GET 126 | headers: 127 | - Authorization: Bearer [YOUR-TOKEN-HERE] 128 | insecure: true 129 | jq: .state|tonumber 130 | timeout: 2s 131 | 132 | - name: SENEC.pv 133 | type: custom 134 | power: 135 | source: http 136 | uri: http://[YOUR-HA-INSTANCE]:8123/api/states/sensor.senec_solar_generated_power 137 | method: GET 138 | headers: 139 | - Authorization: Bearer [YOUR-TOKEN-HERE] 140 | insecure: true 141 | jq: .state|tonumber 142 | timeout: 2s 143 | 144 | - name: SENEC.bat 145 | type: custom 146 | power: 147 | source: http 148 | uri: http://[YOUR-HA-INSTANCE]:8123/api/states/sensor.senec_battery_state_power 149 | method: GET 150 | headers: 151 | - Authorization: Bearer [YOUR-TOKEN-HERE] 152 | insecure: true 153 | jq: .state|tonumber * -1 # this does the trick to invert the sensor value for evcc 154 | timeout: 2s 155 | soc: 156 | source: http 157 | uri: http://[YOUR-HA-INSTANCE]:8123/api/states/sensor.senec_battery_charge_percent 158 | method: GET 159 | headers: 160 | - Authorization: Bearer [YOUR-TOKEN-HERE] 161 | insecure: true 162 | jq: .state|tonumber 163 | timeout: 2s 164 | ``` 165 | 166 | ### My additional AUX entries 167 | 168 | Just to demonstrate the general concept of adding additional AUX senors (all this will be part of the evcc.yaml meters section) 169 | 170 | ``` 171 | - name: AUX.heat 172 | type: custom 173 | power: 174 | source: http 175 | uri: http://[YOUR-HA-INSTANCE]:8123/api/states/sensor.wkh_power_electric 176 | method: GET 177 | headers: 178 | - Authorization: Bearer [YOUR-TOKEN-HERE] 179 | insecure: true 180 | jq: .state|tonumber 181 | timeout: 2s 182 | 183 | - name: AUX.pool 184 | type: custom 185 | power: 186 | source: http 187 | uri: http://[YOUR-HA-INSTANCE]:8123/api/states/sensor.kanal_1_pool_power 188 | method: GET 189 | headers: 190 | - Authorization: Bearer [YOUR-TOKEN-HERE] 191 | insecure: true 192 | jq: .state|tonumber 193 | timeout: 2s 194 | 195 | - name: AUX.gardenpump 196 | type: custom 197 | power: 198 | source: http 199 | uri: http://[YOUR-HA-INSTANCE]:8123/api/states/sensor.kanal_2_power 200 | method: GET 201 | headers: 202 | - Authorization: Bearer [YOUR-TOKEN-HERE] 203 | insecure: true 204 | jq: .state|tonumber 205 | timeout: 2s 206 | ``` 207 | 208 | ## Final step: configure the evcc `site` 209 | 210 | Use the following meters in your evcc.ymal `site` configuration (obviously use the `aux` only if you have similar entries): 211 | 212 | ``` 213 | site: 214 | ... 215 | meters: 216 | grid: SENEC.grid 217 | pv: 218 | - SENEC.pv 219 | battery: 220 | - SENEC.bat 221 | aux: 222 | - AUX.heat 223 | - AUX.pool 224 | - AUX.gardenpump 225 | ... 226 | ``` 227 | 228 | # Summary 229 | 230 | 1. Create a [Long-lived access token in HA](http://[YOUR-HA-INSTANCE]:8123/profile/security) 231 | 2. Configure the different evcc meter's (in evcc.yaml) via the `type: custom` and provide `power` (& `currents` and/or `soc`) 232 | 3. _Optional_: Add additional AUX sources 233 | 4. Configure your evcc site 234 | 5. Be happy evcc user __without putting extra load__ on your SENEC.Home! 235 | 236 | 237 | ## Enable evcc TRACE logging 238 | 239 | When you have issues with the http requests to your HA instance, you might like to enable TRACE log level of evcc to find the root cause. 240 | 241 | ### Trace log level in evcc.yaml 242 | ``` 243 | log: trace 244 | ``` 245 | 246 | 247 | # Additional Resources: 248 | - [Create __Long-lived access Token__ in HA](https://github.com/marq24/ha-evcc/blob/main/HA_AS_EVCC_SOURCE.md#preparation-1st-make-home-assistant-sensor-data-accessible-via-api-calls) 249 | - [Provide HA PV/Grid Data to evcc](https://github.com/marq24/ha-evcc/blob/main/HA_AS_EVCC_SOURCE.md) 250 | - [Provide HA vehicle data to evcc](https://github.com/marq24/ha-fordpass/blob/main/doc/EVCC.md) 251 | - [Let evcc control your HA entities (PV surplus handling)](https://github.com/marq24/ha-evcc/blob/main/HA_CONTROLLED_BY_EVCC.md) 252 | -------------------------------------------------------------------------------- /HA_CONTROLLED_BY_EVCC.md: -------------------------------------------------------------------------------- 1 | # Master PV surplus handling with evcc and Home Assistant 2 | 3 | Using power from your own solar panels to charge your EV is great, but what if you have more power available — or if your EV is already fully charged? I am personally facing this challenge for quite a while now and used several helpers or blueprints, with and without Home Assistant, to control my various devices (with noticeable energy consumption) at my home. 4 | 5 | For me, the search is over — I already use evcc to charge my car with PV power — it seems consequent to do the same for other home devices as well. 6 | 7 | Since the end of May 2025 you can use [evcc](https://github.com/evcc-io/evcc) to control any Home Assistant entity that implements the `turn_on` and `turn_off` services. This means evcc can switch devices like smart plugs, or even Home Assistant automations (which is IMHO the entrance to 'PV surplus handling' heaven). 8 | 9 | __Kudos to [@niklaswa](https://github.com/niklaswa)__ for adding this feature to evcc! ... and also __Kudos to [@mfuchs1984](https://github.com/mfuchs1984)__ & __[@VolkerK62](https://github.com/VolkerK62)__ for their additional explanations in the [evcc github discussion](https://github.com/evcc-io/evcc/discussions/22378), which helped me a lot to understand the theory of operation. 10 | 11 | 12 | ## Some basic stuff first: A = W / V 13 | 14 | Something you must have in mind — evcc is __current__ (in Ampere) based — for most of the other electrical devices in my home __power__ (in Watt) is the main unit. So you must convert the Power (in Watt) to current (in Ampere) by using the formula `A = W / V` (where `V` is the voltage, `W` is the watts and `A` is the current) [must be related to this `U = R * I` thing] . In my case, I am using 230V as voltage, so I can calculate the current by dividing the power in Watt by 230V. 15 | 16 | So when I want to specify a device that requires 1000W, I can calculate the current by dividing 1000W by 230V, which gives me approximately 4.35A. 17 | 18 | 19 | ## Introduction to my use case 20 | 21 | Let's go through my personal use case, and then you can decide if this is also useful for you. Hopefully this will help you to decide If you simply copy & paste this to your installation or if you want to adapt it to your needs (or if you just want to stick with your current solution). 22 | 23 | 24 | ### My home 25 | 26 | Here at my home I have (beside my lovely family)... 27 | 28 | #### Power generation side 29 | - 16kWp — via 42 solar panels on the east and west side of my roof 30 | - a SENEC.Home V3 system (a decision __I very much regret__) 31 | - with integrated 10 kW battery 32 | - with integrated two inverters (where I can fetch data separate) 33 | - an additional SMA inverter 34 | - a tibber pulse/bridge (to be independent of any data from the SENEC.Home V3 system) 35 | - grid price data from the Tibber API 36 | - PV forcast data 37 | 38 | #### Power consumption side 39 | - An EV (Ford Mach-E extended range) — fully integrated into Home Assistant via the [FordPass integration](https://github.com/marq24/ha-fordpass) (connected via a goE-Charger) 40 | - A Waterkotte heat pump (for heating and hot water) [~ 3000W] — fully integrated into Home Assistant via the [Waterkotte +2020 integration](https://github.com/marq24/ha-waterkotte) 41 | - A Pool pump and an additional pool heater/cooler [~ 1000W] — controlled via a Shelly Pro 4PM and integrated into Home Assistant via the Shelly integration 42 | - A garden water pump [~ 500W] — controlled via the same Shelly Pro 4PM (but a different channel) 43 | 44 | #### Additional challenge — The power consumption of the devices must not be constant 45 | 46 | The power consumption of the Waterkotte heat pump, the pool pump & heating and from the garden pump is __not constant__. As examples: 47 | 48 | - The Waterkotte heat pump is running in a cycle, so the power consumption will vary between 0W (when not running) and ~3000W (when running at full power). The power consumption will also vary depending on the current temperature of the water and the target temperature. 49 | 50 | - When the garden pump is running, the pump will automatically stop if the water-pressure is too high and start again when the pressure has dropped under a specific threshold (good old mechanical switch). 51 | 52 | - The pool pump has a rpm control, so the power consumption will vary. And last but not least, the pool heater/cooler is also not running all the time (it stops when the target temperature is reached). 53 | 54 | ### The overall goal 55 | 56 | I want that evcc will control all these devices at my home and make a smart decition, when to turn on and off which device to maximize the use of the available surplus power. 57 | 58 | As already mentioned — since a short while evcc is able to control Home Assistant entities and this allows me finally to archive my overall goal (from a single source). The key is the recently added `integrateddevice` and the smart-switches support in evcc. Continue to read what I set up here @ home. 59 | 60 | ## The Key: define so called `integrateddevice` as `charger` in evcc 61 | 62 | The [evcc documentation for smart switches](https://docs.evcc.io/docs/devices/smartswitches#home-assistant-schalter) explains how to define a Home Assistant device (or Shelly's) as a `charger` in evcc. 63 | 64 | This documentation is quite short (for sure, containing everything you need to know) — but this is offering so many new options that go way beyond 'simple' EV charging. I have to admit that I was not aware of this feature before — and I am still surprised __how powerful__ this can be. __The `integrateddevice: true` in combination with smart-switches is a game changer__ for me! 65 | 66 | But let's get back to the basics. The main effect of the `integrateddevice` property is (in the configuration GUI you must enable the 'extended options'), that a configured loadpoint using such an 'integrateddevice'-charger will not have an option to select a vehicle (at the loadpoint). Which is very logical at the end. 67 | 68 | Once you have defined such a charger and configured a loadpoint with it, you can instantly use it to `turn_on` or `turn_off` _any_ Home Assistant entity supporting the `turn_on`, `turn_off` service (starting with evcc v0.206) or any other smart switch directly supported by evcc (like Shelly, Tasmota or other smart plugs). But at the end of the day, when you have a device that is switchable in Home Assistant, then now evcc can turn it on and off. 69 | 70 | __Turn ON__ means that the device will be switched on when the evcc has detected that there is enough surplus power available (or when the grid price is low enough). __Turn OFF__ means, that the device will be switched off, when there is not enough surplus power available (or when the grid price is too high). 71 | 72 | 73 | ## Understanding priority, minCurrent/maxCurrent and how evcc control on/off of a device 74 | 75 | But once you have defined such a charger(s) and loadpoint(s) in evcc, how does evcc decide which device to turn on or off? 76 | 77 | So coming back to my home scenario — First I want that my EV get charged till the configured SOC — I am doing this since a while now — the only adjustment I made recently to specify my loadpoint with the configured goE-Charger the `priority: 9` (the highest priority). 78 | 79 | Second, I want is that my pool pump (and heating/cooling) will be switched on when there is enough surplus power available. I have configured this with a `priority: 8` and a `minCurrent: 4.5` (1000W/230V = 4,3478...) (so that the pool pump will only be switched on when there is at least 1000W surplus power available). 80 | 81 | Third is the garden pump, which is configured with a `priority: 7` and a `minCurrent: 2.17` (500W/230V = 2,1739...). 82 | 83 | And finally, the hot-water-boost for the Waterkotte heat pump [where I raise the set-temperature of the hot water from 46°C up to 58°C via a Home Assistant automation (and ignoring all the possible existing SG-Ready stuff)]. It's configured with the lowest `priority: 1` and a `minCurrent: 13` and should only run, if there is at least for more than one-hour additional surplus power available (I have left the logic about remaining (expected) PV-production/forcast in the ON-SWITCH logic of my HA automation. 84 | 85 | ### So how all these requirements are handled by evcc? 86 | 87 | The configured minCurrent must be considered as the required available power 'overproduction' (that might be currently just fed into the grid). As soon as evcc detects that there is enough surplus power available, it will turn on the device (or devices) that can be powered by the available energy. 88 | 89 | When you have a device A with a high priority and a `minCurrent` of 4.5A (~1000W) and a second device B with lower priority with `minCurrent` of 2A (460W) and the current surplus power is 3.5A (~800W), then evcc will turn on the second device B first. 90 | 91 | When the second device B is now running and consuming 2A (460W), the remaining surplus power will be 340W. 92 | 93 | When now this remaining surplus power will increase over 540W then (and if I have not misunderstood something fundamentally wrong): 94 | - The total available surplus power becomes over 1000W, since 540W + 460W (of the running second device) 95 | - So the total available surplus power can feed the demand of device A 96 | - Then evcc will turn off the second device B and turn on the first device A, since device A has a higher priority than device B 97 | 98 | [Please let me know if I am wrong with this]. 99 | 100 | If then the surplus power is increasing to a total of 1460W, then evcc will turn back on the second device. 101 | 102 | __And__ it's also important to know, that evcc will always just monitor the actual power consumption of the devices that are currently running (no matter of the configured min/maxCurrents). E.g., if a device turned on by evcc has a configured minCurrent of 4.5A but is actually consuming less power (or none at all) — such as when a heat pump reaches its target temperature — evcc will use all available surplus power to determine which device to turn on next, without subtracting any "theoretical power consumption" (from the none running - but turn on - device). 103 | 104 | So evcc uses __only the actual power consumption__ of the turned-on devices to calculate the total surplus power available. 105 | 106 | --- 107 | 108 | Mhh - does this all sound too complicated? Well, at the end of the day, evcc will turn on the devices with the highest priority first. If the power is not enough to turn on the device with the highest priority, evcc will turn on the next device with the next highest priority where the minCurrent is lower than the available power. 109 | 110 | 111 | ## Putting it all together — how I have configured my home in evcc? 112 | 113 | To solve my use case, I have defined three additional chargers and loadpoints in evcc, which are finally controlling the garden-pump, the pool-pump and the Waterkotte. When you take a look at the configuration below, you will realize that the overall configuration is quite simple and straightforward (no matter if it's done via the Configuration GUI or via the evcc.yaml. 114 | 115 | This makes it ever way more astonishing that I have not thought about this before. The __Simplest solutions__ are __always the best__ ones, right? 116 | 117 | ### My evcc main view 118 | ![evcc loadpoints](./evcc-with-integrated-devices.png) 119 | 120 | ### My evcc.yaml charger & loadpoint configuration 121 | 122 | The minCurrent and maxCurrent values can be adjusted in the GUI-based configuration as numeric inputs or on the main screen of evcc per loadpoint (dropdown selects). 123 | 124 | _I haven't figured out yet, how to adjust the minCurrent/maxCurrent via the evcc.yaml configuration file. Using minCurrent for a loadpoint, give me 'ignoring deprecated minCurrent...' during evcc startup, so I am using the GUI for this._ 125 | 126 | Please find below my evcc configuration for the chargers and loadpoints, that I currently used to control my home devices (as described above): 127 | 128 | ```yaml 129 | interval: 10s # 10 seconds works fine in my setup - does not have to be the case in yours 130 | 131 | log: info 132 | 133 | chargers: 134 | - name: go-e 135 | type: template 136 | template: go-e-v3 137 | host: [IP-OF-MY-GOECHARGER] 138 | 139 | - name: pool-shelly_switch 140 | type: template 141 | template: shelly 142 | standbypower: 0 143 | integrateddevice: true 144 | heating: false 145 | host: [IP-OF-MY-SHELLY-PRO-4PM] 146 | channel: 0 147 | icon: heatexchange 148 | 149 | - name: garden-shelly_switch 150 | type: template 151 | template: shelly 152 | standbypower: 0 153 | integrateddevice: true 154 | heating: false 155 | host: [IP-OF-MY-SHELLY-PRO-4PM] 156 | channel: 1 157 | icon: compute 158 | 159 | - name: heatpump-water_ha_switch 160 | type: template 161 | template: homeassistant-switch 162 | standbypower: 0 163 | integrateddevice: true 164 | heating: true 165 | uri: http://[MY-HA-INSTANCE]:8123 166 | token: [MY-HA-TOKEN] 167 | # the homeassistant entity that is used to turn on/off the hot water-boost 168 | # (in my case this is a helper switch used by an automation) 169 | switch: input_boolean.wkh_hotwater_switch 170 | # the homeassistant entity providing the current power consumption 171 | # of the heat pump (must be in Watt)! 172 | power: sensor.wkh_power_electric_in_watt 173 | icon: waterheater 174 | 175 | meters: 176 | - name: [MY METERS HERE] 177 | ... 178 | 179 | vehicles: 180 | - name: [MY VEHCILE HERE] 181 | ... 182 | 183 | loadpoints: 184 | - title: HH-7 185 | charger: go-e 186 | priority: 3 187 | enable: 188 | threshold: 0 189 | delay: 0.5m 190 | disable: 191 | threshold: 0 192 | delay: 1.5m 193 | 194 | - title: Pool 195 | charger: pool-shelly_switch 196 | priority: 4 197 | enable: 198 | threshold: 0 199 | delay: 5m 200 | disable: 201 | threshold: 0 202 | delay: 5m 203 | 204 | - title: Gartenpumpe 205 | charger: garden-shelly_switch 206 | priority: 5 207 | mode: now # garden pump should always be 'ON' right now 208 | enable: 209 | threshold: 0 210 | delay: 0.5m 211 | disable: 212 | threshold: 0 213 | delay: 10m 214 | 215 | - title: Warmwasser-Boost 216 | charger: heatpump-water_ha_switch 217 | priority: 1 218 | enable: 219 | threshold: 0 220 | delay: 60m # at least 60min PV surplus power 221 | disable: 222 | threshold: 0 223 | delay: 5m 224 | 225 | ``` 226 | 227 | ### Things that are not running 100% right now 228 | 229 | Since all this is quite new (to me), some things are not running perfect yet: 230 | 231 | - When the Waterkotte hot water temperature is below the default set-point (~42°C) then 'of course the heat pump will start running' — therefore, the heatpump power-meter (configured as `powerentity`) will report energy consumption to evcc. And since evcc did not turn the power `on` (for the hot water boost), there is a log message inform you about this inconsistency. [This can be solved by creating a separate template-based HA sensor, that will only repot the power usage, if the boost-switch in HA is turned on. But I have not done this yet.] 232 | 233 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant Integration: __evcc☀️🚘- Solar Charging__ (unofficial) 2 | 3 | ![ha-logo](https://github.com/marq24/ha-evcc/raw/main/logo-ha.png)  ![evcc-logo](https://github.com/marq24/ha-evcc/raw/main/logo.png) 4 | 5 | I was surprised that looks like that there does not exist a simple Home Assistant integration for the very popular evcc. So before my first EV spawned at my driveway, I want to contribute a very simple & basic integration which allow you to control evcc objects simply via the default HA gui and use sensors and switches in your automations. 6 | 7 | __Please note__, _that this Home Assistant integration is not official and not supported by the evcc developers. I am not affiliated with evcc in any way. This integration is based on the evcc API and the evcc API documentation._ 8 | 9 | [![hacs_badge][hacsbadge]][hacs] [![github][ghsbadge]][ghs] [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] [![PayPal][paypalbadge]][paypal] [![hainstall][hainstallbadge]][hainstall] 10 | 11 | ## Disclaimer 12 | 13 | Please be aware that we are developing this integration to the best of our knowledge and belief, but can't give a guarantee. Therefore, use this integration **at your own risk**. 14 | 15 | ## Requirements 16 | 17 | - A __running__ & configured evcc instance in your network 18 | - A Home Assistant instance that can reach your evcc instance 19 | 20 | ## Main features 21 | 22 | - Supporting all evcc API-POST & DELETE requests (except `POST /api/settings/telemetry/`) to adjust the evcc settings, loadpoints and the corresponding vehicles 23 | - Loadpoint mode [Off, Solar, Min+Solar, Fast] 24 | - Phases to use [Auto, 1p, 3p] 25 | - Assign vehicles to loadpoints 26 | - Configure min & max charging currents 27 | - Configure cost limits (€ or CO₂) 28 | - Adjust home-battery settings 29 | - Adjust/create Vehicle & Loadpoint charging plan via HA-Services [http://[YOUR-HA-INSTANCE]:8123/developer-tools/service](http://[YOUR-HA-INSTANCE]:8123/developer-tools/service) 30 | 31 | - Supporting most of the other loadpoint and vehicle data that is available via the API - please let me know, if you miss some data - probably it just slipped through my attention during testing. 32 | 33 | ### Example Dashboard 34 | 35 | Take a look at this sample Dashboard (showing Sensors from one load point): 36 | 37 | ![sampledashboard](https://github.com/marq24/ha-evcc/raw/main/sample-dashboard.png) 38 | 39 | ## Installation 40 | 41 | ### Before you start — there are two 'tiny' requirements! 42 | 43 | 1. **You must have installed & configured an evcc instance in your network.** This can be either a stand-alone installation (e.g via Docker) or as a HASS-IO-AddOn[^1]. This __AddOn__ is available via the [official evcc hassio-addon repository](https://github.com/evcc-io/hassio-addon). 44 | 2. You **must know the URL** from which your HA-Instance can reach your evcc instance. 45 | - This is usually the IP address of your evcc server and the port on which the evcc server is running (default is `7070`). 46 | - If you are using a reverse proxy, you need to know the URL that your HA instance can use to reach your evcc instance. 47 | - When you are using docker (or docker-compose), you must ensure that the containers can communicate with each other. This means that the network and the port must be configured & exposed correctly. It's not enough that you can reach your evcc instance via a browser — your HA container must be also able to reach it! 48 | 49 | [^1]: There is a known issue when using HASS-IO - For what ever reason it has been reported, that the startup sequence of the containers (the different installed AddOns) _might not match the requirements_ of this Integration: Your evcc-server __must be up and running before the Integration can be initialized__ in HA. This is because during the initial start of the Integration your evcc configuration (active loadpoints and configured vehicles) will be evaluated in order to create the corresponding HA entities.

So when you restart your HASS-IO server it _can happen_ that HA already starting to initialize this Integration before the HASS-OS have started the evcc-instance. __This will lead to the situation that the Integration is not started correctly__.

I am sorry that my HA developer skills are not sufficient to find a solution that work in this scenario - nor did I found other integrations with similar challenges (to find inspiration). If you know one (that's actively maintained), please let me know. Of course, I am also open to any PR solving this issue for AddOn users that will work in the different scenarios (deactivating/activating the integration, with WebSocket support and without).

As a personal note on this: IMHO restarts of HA Servers should only happen under observation, and should not be automated in any kind - but I might be alone with my opinion. 50 | 51 | ### Step I: Install the integration 52 | 53 | #### Option 1: via HACS 54 | 55 | [![Open your Home Assistant instance and adding repository to HACS.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=marq24&repository=ha-evcc&category=integration) 56 | 57 | 1. ~~Add a custom **integration** repository to HACS: [https://github.com/marq24/ha-evcc](https://github.com/marq24/ha-evcc)
**Let me repeat**: This is an **HACS _integration_**, not an **HASS-IO _AddOn_**, so you need to have HACS installed, and you need to add this as custom **integration repository** to HACS.~~ 58 | 2. ~~Once the repository is added,~~ use the search bar and type `evcc☀️🚘- Solar Charging` 59 | 3. Use the 3-dots at the right of the list entry (not at the top bar!) to download/install the custom integration — the latest release version is automatically selected. Only select a different version if you have specific reasons. 60 | 4. After you press download and the process has completed, you must __Restart Home Assistant__ to install all dependencies 61 | 5. Setup the evcc custom integration as described below (see _Step II: Adding or enabling the integration_) 62 | 63 | 64 | 65 | #### Option 2: manual steps 66 | 67 | 1. Using the tool of choice, open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 68 | 2. If you do not have a `custom_components` directory (folder) there, you need to create it. 69 | 3. In the `custom_components` directory (folder) create a new folder called `evcc_intg`. 70 | 4. Download _all_ the files from the `custom_components/evcc_intg/` directory (folder) in this repository. 71 | 5. Place the files you downloaded in the new directory (folder) you created. 72 | 6. Restart Home Assistant 73 | 7. Setup the evcc custom integration as described below (see _Step II: Adding or enabling the integration_) 74 | 75 | ### Step II: Adding or enabling the integration 76 | 77 | __You must have installed the integration (manually or via HACS before)!__ 78 | 79 | #### Option 1: My Home Assistant (2021.3+) 80 | 81 | Just click the following Button to start the configuration automatically (for the rest see _Option 2: Manually steps by step_): 82 | 83 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=evcc_intg) 84 | 85 | 86 | #### Option 2: Manually — step by step 87 | 88 | Use the following steps for a manual configuration by adding the custom integration using the web interface and follow instruction on screen: 89 | 90 | - Go to `Configuration -> Integrations` and add "evcc☀️🚘- Solar Charging" integration 91 | 92 | #### Common further steps 93 | 94 | - Provide a unique name for the integration installation (will be used in each sensor entity_id) created by the integration 95 | - Provide the URL of your __evcc web server__ (including the port) — e.g. `http://your-evcc-server-ip:7070` 96 | - [optional] Provide the area where the evcc installation is located 97 | 98 | After the integration was added, you can use the 'config' button to adjust your settings, you can additionally modify the update interval 99 | 100 | Please note that some of the available sensors are __not__ enabled by default. 101 | 102 | ## Using evcc with Home Assistant 103 | 104 | ### Use Home Assistant sensor data as input for evcc 105 | 106 | Please see the separate document where you can find examples [how to provide your evcc instance with HA sensor data](https://github.com/marq24/ha-evcc/blob/main/HA_AS_EVCC_SOURCE.md). 107 | 108 | ### Use evcc to control any of your Home Assistant devices 109 | 110 | This is IMHO a very powerful feature of evcc, that is not known to many users (yet). You can use evcc to control your Home Assistant devices or Automation, e.g., to switch on/off anything you like when you are actually feeding power into the grid. 111 | 112 | Using the maximum out of your solar panels to charge your EV is great, but what if you have more power available — or if your EV is already fully charged? I am facing this challenge for quite a long time now and used several helpers & Blueprints to control my home devices with larger energy consumption. 113 | 114 | You can use evcc to control any Home Assistant entity that has implemented the `turn_on` and `turn_off` services. This means you can control devices like smart plugs, or even Home Assistant automations (which is IMHO the door to PV surplus handling heaven). 115 | 116 | Since the setup & configuration of this evcc feature might not be straight forward, I have created a separate document where you can find examples [Master PV surplus handling with evcc & Home Assistent](https://github.com/marq24/ha-evcc/blob/main/HA_CONTROLLED_BY_EVCC.md). 117 | 118 | 119 | I use this feature to control the hot-water temperature of my waterkotte heat pump. 120 | 121 | 122 | 123 | ## Are you are go-eCharger V3 (or higher) User? 124 | 125 | Do you know, that as owners of a go-eCharger (V3+) there is no need to use evcc for solar surplus charging? Even without any additional hardware! Home Assistant and the __go-eCharger APIv2 Connect__ Integration is all you need. Get all details from [https://github.com/marq24/ha-goecharger-api2](https://github.com/marq24/ha-goecharger-api2). 126 | 127 | 128 | 129 | ## Accessing your vehicle SOC & Range when the vehicle is not connected to a loadpoint 130 | 131 | By default, evcc and this integration focus on vehicles connected to a loadpoint, this implies that data like SOC or range are _only available when the vehicle is actually connected_. 132 | 133 | Nevertheless, evcc provides this data in the configuration section (no matter of the connection state). If you want to access your vehicle SOC and range, when the vehicle is not connected to a loadpoint, you can do this by adding a command_line sensor to your Home Assistant configuration.yaml file. 134 | 135 | > [!IMPORTANT] 136 | > You need to know the technical `vehicle_id`. Depending on from your configuration this is either the value you have specified in the `evcc.yaml` file or it had been automatically generated. 137 | > 138 | > In any case you can request: `http://[YOUR_EVCC_IP]:7070/api/config/devices/vehicle` and check the value of the `name` attribute to get your `vehicle_id`. 139 | 140 | > [!NOTE] 141 | > You must authorize your request(s) with the evcc password. 142 | > 143 | 144 | ### Command-Line in your HA configuration.yaml 145 | 146 | requesting `http://[YOUR_EVCC_IP]:7070/api/config/devices/vehicle/[YOUR_VEHICLE_ID]/status` will return a JSON like this one here 147 | 148 | ```json 149 | { 150 | "result": { 151 | "capacity": { 152 | "value": 84.68, 153 | "error": "" 154 | }, 155 | "chargeStatus": { 156 | "value": "B", 157 | "error": "" 158 | }, 159 | "range": { 160 | "value": 167, 161 | "error": "" 162 | }, 163 | "soc": { 164 | "value": 39.5, 165 | "error": "" 166 | } 167 | } 168 | } 169 | ``` 170 | 171 | Check if you have already a `command_line` section in your `configuration.yaml` file - if there is none - create one on as top level entry like this (the line ' - sensor: ...' must (obviously) be replaced with the complete sections shown further below): 172 | 173 | ```yaml 174 | command_line: 175 | - sensor: ... 176 | ``` 177 | 178 | Add in the `command_line` section of your `configuration.yaml` file the following content: sections with `[CHANGE_ME:xxx]` have to be modified to your requirements. E.g., assuming your assuming `vehicle_id` is __ford_mach_e__, then you have to replace `[CHANGE_ME:YourVehicleId]` with just `ford_mach_e` 179 | 180 | ```yaml 181 | - sensor: 182 | name: '[CHANGE_ME:Your Vehicle SOC & Range]' 183 | unique_id: [CHANGE_ME:evcc_vehicle_soc_and_range] 184 | command: |- 185 | data='{"password":"[CHANGE_ME:YourEVCCPassword]"}'; ip='http://[CHANGE_ME:YourEVCCServerIP]:7070';\ 186 | c=$(curl -H 'Content-Type: application/json' -d $data -ksc - $ip/api/auth/login -o /dev/null);\ 187 | echo "${c}" | curl -ksb - $ip/api/config/devices/vehicle/[CHANGE_ME:YourVehicleId]/status 188 | json_attributes_path: '$.result.range' 189 | json_attributes: 190 | - value 191 | value_template: '{{ value_json.result.soc.value | float }}' 192 | unit_of_measurement: '%' 193 | # the scan_interval will be specified in seconds... 194 | # for update every 5min use 300 (60sec * 5min = 300sec) 195 | # for update every 15min use 900 (60sec * 15min = 900sec) 196 | # for update every 1h use 3600 (60sec * 60min = 3600sec) 197 | # for update every 24h use 86400 (60sec * 60min * 24h = 86400sec) 198 | scan_interval: 900 199 | ``` 200 | 201 | Here is a complete example assuming: 202 | - that your `vehicle_id` is: __ford_mach_e__ 203 | - the IP of your evcc server is: __192.168.2.213__ 204 | - the EVCC password is: __myEvCCPwd__ 205 | and you want to capture the __soc__ as main entity information and the `range` as additional attribute of the entity that will be requested every 5 minutes: 206 | 207 | ```yaml 208 | - sensor: 209 | name: 'My Ford Mach-E SOC & Range' 210 | unique_id: evcc_mach_e_soc_and_range 211 | command: |- 212 | data='{"password":"myEvCCPwd"}'; ip='http://192.168.2.213:7070';\ 213 | c=$(curl -H 'Content-Type: application/json' -d $data -ksc - $ip/api/auth/login -o /dev/null);\ 214 | echo "${c}" | curl -ksb - $ip/api/config/devices/vehicle/ford_mach_e/status 215 | json_attributes_path: '$.result.range' 216 | json_attributes: 217 | - value 218 | value_template: '{{ value_json.result.soc.value | float }}' 219 | unit_of_measurement: '%' 220 | scan_interval: 300 221 | ``` 222 | ### Don't want to store your evcc password in the ha configuration.yaml? 223 | [@BDBAfH was so kind to post an alternative example here](https://github.com/marq24/ha-evcc/discussions/137), showing the way how to store and use the evcc password from a separate file. 224 | 225 | ## Want to report an issue? 226 | 227 | Please use the [GitHub Issues](https://github.com/marq24/ha-evcc/issues) for reporting any issues you encounter with this integration. Please be so kind before creating a new issues, check the closed ones if your problem has been already reported (& solved). 228 | 229 | To speed up the support process, you might like to already prepare and provide DEBUG log output. In the case of a technical issue, I would need this DEBUG log output to be able to help/fix the issue. There is a short [tutorial/guide 'How to provide DEBUG log' here](https://github.com/marq24/ha-senec-v3/blob/master/docs/HA_DEBUG.md) — please take the time to quickly go through it. 230 | 231 | For this integration, you need to add: 232 | ``` 233 | logger: 234 | default: warning 235 | logs: 236 | custom_components.evcc_intg: debug 237 | ``` 238 | 239 | --- 240 | 241 | ###### Advertisement / Werbung - alternative way to support me 242 | 243 | ### Switch to Tibber! 244 | 245 | Be smart switch to Tibber - that's what I did in october 2023. If you want to join Tibber (become a customer), you might want to use my personal invitation link. When you use this link, Tibber will grant you and me a bonus of 50,-€ for each of us. This bonus then can be used in the Tibber store (not for your power bill) — e.g. to buy a Tibber Bridge. If you are already a Tibber customer and have not used an invitation link yet, you can also enter one afterward in the Tibber App (up to 14 days). [[see official Tibber support article](https://support.tibber.com/en/articles/4601431-tibber-referral-bonus#h_ae8df266c0)] 246 | 247 | Please consider [using my personal Tibber invitation link to join Tibber today](https://invite.tibber.com/6o0kqvzf) or Enter the following code: 6o0kqvzf (six, oscar, zero, kilo, quebec, victor, zulu, foxtrot) afterward in the Tibber App - TIA! 248 | 249 | --- 250 | 251 | ### References 252 | 253 | - https://github.com/evcc-io/evcc 254 | - https://docs.evcc.io/docs/reference/api 255 | 256 | 257 | [hacs]: https://hacs.xyz 258 | [hacsbadge]: https://img.shields.io/badge/HACS-Default-blue?style=for-the-badge&logo=homeassistantcommunitystore&logoColor=ccc 259 | 260 | [ghs]: https://github.com/sponsors/marq24 261 | [ghsbadge]: https://img.shields.io/github/sponsors/marq24?style=for-the-badge&logo=github&logoColor=ccc&link=https%3A%2F%2Fgithub.com%2Fsponsors%2Fmarq24&label=Sponsors 262 | 263 | [buymecoffee]: https://www.buymeacoffee.com/marquardt24 264 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a-coffee-blue.svg?style=for-the-badge&logo=buymeacoffee&logoColor=ccc 265 | 266 | [paypal]: https://paypal.me/marq24 267 | [paypalbadge]: https://img.shields.io/badge/paypal-me-blue.svg?style=for-the-badge&logo=paypal&logoColor=ccc 268 | 269 | [hainstall]: https://my.home-assistant.io/redirect/config_flow_start/?domain=evcc_intg 270 | [hainstallbadge]: https://img.shields.io/badge/dynamic/json?style=for-the-badge&logo=home-assistant&logoColor=ccc&label=usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.evcc_intg.total 271 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Dummy init so that pytest works.""" 2 | -------------------------------------------------------------------------------- /custom_components/evcc_intg/binary_sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from custom_components.evcc_intg.pyevcc_ha.keys import Tag 4 | from homeassistant.components.binary_sensor import BinarySensorEntity 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.const import STATE_OFF 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 9 | from . import EvccDataUpdateCoordinator, EvccBaseEntity 10 | from .const import DOMAIN, BINARY_SENSORS, BINARY_SENSORS_PER_LOADPOINT, ExtBinarySensorEntityDescription 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, add_entity_cb: AddEntitiesCallback): 16 | _LOGGER.debug("BINARY_SENSOR async_setup_entry") 17 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 18 | entities = [] 19 | for description in BINARY_SENSORS: 20 | entity = EvccBinarySensor(coordinator, description) 21 | entities.append(entity) 22 | 23 | multi_loadpoint_config = len(coordinator._loadpoint) > 1 24 | for a_lp_key in coordinator._loadpoint: 25 | load_point_config = coordinator._loadpoint[a_lp_key] 26 | lp_api_index = int(a_lp_key) 27 | lp_id_addon = load_point_config["id"] 28 | lp_name_addon = load_point_config["name"] 29 | lp_has_phase_auto_option = load_point_config["has_phase_auto_option"] 30 | lp_is_heating = load_point_config["is_heating"] 31 | lp_is_integrated = load_point_config["is_integrated"] 32 | 33 | for a_stub in BINARY_SENSORS_PER_LOADPOINT: 34 | if not lp_is_integrated or a_stub.integrated_supported: 35 | description = ExtBinarySensorEntityDescription( 36 | tag=a_stub.tag, 37 | idx=lp_api_index, 38 | key=f"{lp_id_addon}_{a_stub.tag.key}", 39 | translation_key=a_stub.tag.key, 40 | name_addon=lp_name_addon if multi_loadpoint_config else None, 41 | icon=a_stub.icon, 42 | device_class=a_stub.device_class, 43 | unit_of_measurement=a_stub.unit_of_measurement, 44 | entity_category=a_stub.entity_category, 45 | entity_registry_enabled_default=a_stub.entity_registry_enabled_default, 46 | 47 | # the entity type specific values... 48 | icon_off=a_stub.icon_off 49 | ) 50 | 51 | entity = EvccBinarySensor(coordinator, description) 52 | entities.append(entity) 53 | 54 | add_entity_cb(entities) 55 | 56 | 57 | class EvccBinarySensor(EvccBaseEntity, BinarySensorEntity): 58 | def __init__(self, coordinator: EvccDataUpdateCoordinator, description: ExtBinarySensorEntityDescription): 59 | super().__init__(coordinator=coordinator, description=description) 60 | self._attr_icon_off = self.entity_description.icon_off 61 | 62 | @property 63 | def is_on(self) -> bool | None: 64 | try: 65 | if self.tag == Tag.PLANACTIVEALT: 66 | # here we have a special implementation, since the attribute will not be provided via the API (yet) 67 | # so we check here, if the 'effectivePlanTime' is not none... 68 | value = self.coordinator.read_tag(Tag.EFFECTIVEPLANTIME, self.idx) is not None 69 | else: 70 | value = self.coordinator.read_tag(self.tag, self.idx) 71 | 72 | except IndexError: 73 | if self.entity_description.idx is not None: 74 | _LOGGER.debug(f"lc-key: {self.tag.key.lower()} value: {value} idx: {self.idx} -> {self.coordinator.data[self.tag.key]}") 75 | else: 76 | _LOGGER.debug(f"lc-key: {self.tag.key.lower()} caused IndexError") 77 | value = None 78 | except KeyError: 79 | _LOGGER.warning(f"is_on caused KeyError for: {self.tag.key}") 80 | value = None 81 | except TypeError: 82 | return None 83 | 84 | if not isinstance(value, bool): 85 | if isinstance(value, str): 86 | # parse anything else then 'on' to False! 87 | if value.lower() == 'on': 88 | value = True 89 | else: 90 | value = False 91 | else: 92 | value = False 93 | 94 | return value 95 | 96 | @property 97 | def icon(self): 98 | """Return the icon of the sensor.""" 99 | if self._attr_icon_off is not None and self.state == STATE_OFF: 100 | return self._attr_icon_off 101 | else: 102 | return super().icon 103 | -------------------------------------------------------------------------------- /custom_components/evcc_intg/button.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.components.button import ButtonEntity 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 7 | from . import EvccDataUpdateCoordinator, EvccBaseEntity 8 | from .const import DOMAIN, BUTTONS, BUTTONS_PER_LOADPOINT, ExtButtonEntityDescription 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, add_entity_cb: AddEntitiesCallback): 14 | _LOGGER.debug("BUTTON async_setup_entry") 15 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 16 | entities = [] 17 | for description in BUTTONS: 18 | entity = EvccButton(coordinator, description) 19 | entities.append(entity) 20 | 21 | multi_loadpoint_config = len(coordinator._loadpoint) > 1 22 | for a_lp_key in coordinator._loadpoint: 23 | load_point_config = coordinator._loadpoint[a_lp_key] 24 | lp_api_index = int(a_lp_key) 25 | lp_id_addon = load_point_config["id"] 26 | lp_name_addon = load_point_config["name"] 27 | lp_has_phase_auto_option = load_point_config["has_phase_auto_option"] 28 | lp_is_heating = load_point_config["is_heating"] 29 | lp_is_integrated = load_point_config["is_integrated"] 30 | 31 | for a_stub in BUTTONS_PER_LOADPOINT: 32 | if not lp_is_integrated or a_stub.integrated_supported: 33 | description = ExtButtonEntityDescription( 34 | tag=a_stub.tag, 35 | idx=lp_api_index, 36 | key=f"{lp_id_addon}_{a_stub.tag.key}", 37 | translation_key=a_stub.tag.key, 38 | name_addon=lp_name_addon if multi_loadpoint_config else None, 39 | icon=a_stub.icon, 40 | device_class=a_stub.device_class, 41 | unit_of_measurement=a_stub.unit_of_measurement, 42 | entity_category=a_stub.entity_category, 43 | entity_registry_enabled_default=a_stub.entity_registry_enabled_default, 44 | 45 | # the entity type specific values... 46 | payload=a_stub.payload 47 | ) 48 | 49 | entity = EvccButton(coordinator, description) 50 | entities.append(entity) 51 | 52 | add_entity_cb(entities) 53 | 54 | 55 | class EvccButton(EvccBaseEntity, ButtonEntity): 56 | def __init__(self, coordinator: EvccDataUpdateCoordinator, description: ExtButtonEntityDescription): 57 | super().__init__(coordinator=coordinator, description=description) 58 | 59 | async def async_press(self, **kwargs): 60 | try: 61 | await self.coordinator.async_press_tag(self.tag, self.entity_description.payload, self.idx, self) 62 | except ValueError: 63 | return "unavailable" -------------------------------------------------------------------------------- /custom_components/evcc_intg/config_flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | import voluptuous as vol 5 | from homeassistant import config_entries 6 | from homeassistant.config_entries import ConfigFlowResult, SOURCE_RECONFIGURE 7 | from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, ATTR_SW_VERSION, CONF_NAME 8 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 9 | 10 | from custom_components.evcc_intg.pyevcc_ha import EvccApiBridge 11 | from custom_components.evcc_intg.pyevcc_ha.keys import Tag 12 | from .const import ( 13 | DOMAIN, 14 | CONF_INCLUDE_EVCC, 15 | CONF_USE_WS, 16 | CONF_PURGE_ALL, 17 | CONFIG_VERSION, CONFIG_MINOR_VERSION 18 | ) 19 | 20 | _LOGGER: logging.Logger = logging.getLogger(__package__) 21 | 22 | DEFAULT_NAME = "evcc" 23 | DEFAULT_HOST = "http://your-evcc-ip:7070" 24 | DEFAULT_SCAN_INTERVAL = 15 25 | DEFAULT_USE_WS = True 26 | DEFAULT_INCLUDE_EVCC = False 27 | 28 | class EvccFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 29 | """Config flow for evcc_intg.""" 30 | VERSION = CONFIG_VERSION 31 | MINOR_VERSION = CONFIG_MINOR_VERSION 32 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 33 | 34 | def __init__(self): 35 | """Initialize.""" 36 | self._errors = {} 37 | self._version = "" 38 | self._default_name = DEFAULT_NAME 39 | self._default_host = DEFAULT_HOST 40 | self._default_scan_interval = DEFAULT_SCAN_INTERVAL 41 | self._default_use_ws = DEFAULT_USE_WS 42 | self._default_include_evcc = DEFAULT_INCLUDE_EVCC 43 | 44 | async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 45 | entry_data = self._get_reconfigure_entry().data 46 | self._default_name = entry_data.get(CONF_NAME, DEFAULT_NAME) 47 | self._default_host = entry_data.get(CONF_HOST, DEFAULT_HOST) 48 | self._default_scan_interval = entry_data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) 49 | self._default_use_ws = entry_data.get(CONF_USE_WS, DEFAULT_USE_WS) 50 | self._default_include_evcc = entry_data.get(CONF_INCLUDE_EVCC, DEFAULT_INCLUDE_EVCC) 51 | 52 | return await self.async_step_user() 53 | 54 | async def async_step_user(self, user_input=None): 55 | """Handle a flow initialized by the user.""" 56 | self._errors = {} 57 | 58 | if user_input is not None: 59 | if not user_input[CONF_HOST].startswith(("http://", "https://")): 60 | if ":" in user_input[CONF_HOST]: 61 | # we have NO schema but a colon, so assume http 62 | user_input[CONF_HOST] = "http://" + user_input[CONF_HOST] 63 | else: 64 | # https otherwise 65 | user_input[CONF_HOST] = "https://" + user_input[CONF_HOST] 66 | 67 | while user_input[CONF_HOST].endswith(("/", " ")): 68 | user_input[CONF_HOST] = user_input[CONF_HOST][:-1] 69 | 70 | valid = await self._test_host(host=user_input[CONF_HOST]) 71 | if valid: 72 | user_input[ATTR_SW_VERSION] = self._version 73 | user_input[CONF_SCAN_INTERVAL] = max(5, user_input[CONF_SCAN_INTERVAL]) 74 | self._abort_if_unique_id_configured() 75 | if self.source == SOURCE_RECONFIGURE: 76 | # when the hostname has changed, the device_entries must be purged (since they will include 77 | # the hostname) 78 | if self._default_host != user_input[CONF_HOST]: 79 | user_input[CONF_PURGE_ALL] = True 80 | return self.async_update_reload_and_abort(entry=self._get_reconfigure_entry(), data=user_input) 81 | else: 82 | return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) 83 | else: 84 | self._errors[CONF_HOST] = "auth" 85 | else: 86 | user_input = {} 87 | user_input[CONF_NAME] = self._default_name 88 | user_input[CONF_HOST] = self._default_host 89 | user_input[CONF_SCAN_INTERVAL] = self._default_scan_interval 90 | user_input[CONF_USE_WS] = self._default_use_ws 91 | user_input[CONF_INCLUDE_EVCC] = self._default_include_evcc 92 | user_input[CONF_PURGE_ALL] = False 93 | 94 | return self.async_show_form( 95 | step_id="user", 96 | data_schema=vol.Schema({ 97 | vol.Required(CONF_NAME, default=user_input.get(CONF_NAME)): str, 98 | vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): str, 99 | vol.Required(CONF_USE_WS, default=user_input.get(CONF_USE_WS)): bool, 100 | vol.Required(CONF_SCAN_INTERVAL, default=user_input.get(CONF_SCAN_INTERVAL)): int, 101 | vol.Required(CONF_INCLUDE_EVCC, default=user_input.get(CONF_INCLUDE_EVCC)): bool, 102 | vol.Optional(CONF_PURGE_ALL, default=user_input.get(CONF_PURGE_ALL)): bool, 103 | }), 104 | last_step=True, 105 | errors=self._errors 106 | ) 107 | 108 | async def _test_host(self, host): 109 | try: 110 | session = async_create_clientsession(self.hass) 111 | client = EvccApiBridge(host=host, web_session=session, coordinator=None, lang=self.hass.config.language.lower()) 112 | 113 | ret = await client.read_all_data() 114 | if ret is not None and len(ret) > 0: 115 | if Tag.VERSION.key in ret: 116 | self._version = ret[Tag.VERSION.key] 117 | elif Tag.AVAILABLEVERSION.key in ret: 118 | self._version = ret[Tag.AVAILABLEVERSION.key] 119 | else: 120 | _LOGGER.warning("No Version could be detected - ignore for now") 121 | 122 | _LOGGER.info(f"successfully validated host -> result: {ret}") 123 | return True 124 | 125 | except Exception as exc: 126 | _LOGGER.error(f"Exception while test credentials: {exc}") 127 | return False 128 | 129 | # @staticmethod 130 | # @callback 131 | # def async_get_options_flow(config_entry): 132 | # return EvccOptionsFlowHandler(config_entry) 133 | 134 | # class EvccOptionsFlowHandler(config_entries.OptionsFlow): 135 | # def __init__(self, config_entry): 136 | # """Initialize HACS options flow.""" 137 | # self._default_name = DEFAULT_NAME 138 | # self._default_host = DEFAULT_HOST 139 | # self._default_scan_interval = DEFAULT_SCAN_INTERVAL 140 | # self._default_use_ws = DEFAULT_USE_WS 141 | # self._default_include_evcc = DEFAULT_INCLUDE_EVCC 142 | # 143 | # self._title = config_entry.title 144 | # if len(dict(config_entry.options)) == 0: 145 | # self.options = dict(config_entry.data) 146 | # else: 147 | # self.options = dict(config_entry.options) 148 | # # implement fallback... 149 | # if CONF_USE_WS not in self.options: 150 | # self.options[CONF_USE_WS] = True 151 | # 152 | # async def async_step_init(self, user_input=None): # pylint: disable=unused-argument 153 | # """Manage the options.""" 154 | # return await self.async_step_user() 155 | # 156 | # async def async_step_user(self, user_input=None): 157 | # """Handle a flow initialized by the user.""" 158 | # self._errors = {} 159 | # if user_input is not None: 160 | # # check if host has changed... 161 | # if user_input[CONF_HOST] != self.options.get(CONF_HOST): 162 | # if not user_input[CONF_HOST].startswith(("http://", "https://")): 163 | # if ":" in user_input[CONF_HOST]: 164 | # # we have NO schema but a colon, so assume http 165 | # user_input[CONF_HOST] = "http://" + user_input[CONF_HOST] 166 | # else: 167 | # # https otherwise 168 | # user_input[CONF_HOST] = "https://" + user_input[CONF_HOST] 169 | # 170 | # while user_input[CONF_HOST].endswith(("/", " ")): 171 | # user_input[CONF_HOST] = user_input[CONF_HOST][:-1] 172 | # 173 | # valid = await self._test_host(host=user_input[CONF_HOST]) 174 | # else: 175 | # # remove host from the user_input (since it did not change) 176 | # user_input.pop(CONF_HOST) 177 | # valid = True 178 | # 179 | # if valid: 180 | # user_input[CONF_SCAN_INTERVAL] = max(5, user_input[CONF_SCAN_INTERVAL]) 181 | # 182 | # self.options.update(user_input) 183 | # return await self._update_options() 184 | # else: 185 | # self._errors[CONF_HOST] = "auth" 186 | # else: 187 | # user_input = {} 188 | # user_input[CONF_HOST] = self.options.get(CONF_HOST, self.default_host) 189 | # user_input[CONF_SCAN_INTERVAL] = self.options.get(CONF_SCAN_INTERVAL, self.default_scan_interval) 190 | # user_input[CONF_USE_WS] = self.options.get(CONF_USE_WS, self.default_use_ws) 191 | # user_input[CONF_INCLUDE_EVCC] = self.options.get(CONF_INCLUDE_EVCC, self.default_include_evcc) 192 | # 193 | # return self.async_show_form( 194 | # step_id="user", 195 | # data_schema=vol.Schema({ 196 | # vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): str, 197 | # vol.Required(CONF_USE_WS, default=user_input.get(CONF_USE_WS)): bool, 198 | # vol.Required(CONF_SCAN_INTERVAL, default=user_input.get(CONF_SCAN_INTERVAL)): int, 199 | # vol.Required(CONF_INCLUDE_EVCC, default=user_input.get(CONF_INCLUDE_EVCC)): bool, 200 | # }), 201 | # errors=self._errors 202 | # ) 203 | # 204 | # async def _test_host(self, host): 205 | # try: 206 | # session = async_create_clientsession(self.hass) 207 | # client = EvccApiBridge(host=host, web_session=session, coordinator=None, lang=self.hass.config.language.lower()) 208 | # 209 | # ret = await client.read_all_data() 210 | # if ret is not None and len(ret) > 0: 211 | # _LOGGER.info(f"successfully validated host -> result: {ret}") 212 | # return True 213 | # 214 | # except Exception as exc: 215 | # _LOGGER.error(f"Exception while test credentials: {exc}") 216 | # return False 217 | # 218 | # async def _update_options(self): 219 | # return self.async_create_entry(title=self._title, data=self.options) 220 | -------------------------------------------------------------------------------- /custom_components/evcc_intg/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "set_loadpoint_plan": "mdi:airplane-takeoff", 4 | "set_vehicle_plan": "mdi:airplane-takeoff" 5 | } 6 | } -------------------------------------------------------------------------------- /custom_components/evcc_intg/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "evcc_intg", 3 | "name": "evcc☀\uFE0F\uD83D\uDE98- Solar Charging", 4 | "codeowners": [ 5 | "@marq24" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/marq24/ha-evcc", 10 | "iot_class": "local_push", 11 | "issue_tracker": "https://github.com/marq24/ha-evcc/issues", 12 | "requirements": [], 13 | "version": "2025.10.8" 14 | } 15 | -------------------------------------------------------------------------------- /custom_components/evcc_intg/number.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from custom_components.evcc_intg.pyevcc_ha.keys import Tag 4 | from homeassistant.components.number import NumberEntity 5 | from homeassistant.components.sensor import SensorDeviceClass 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.const import UnitOfTemperature 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | from . import EvccDataUpdateCoordinator, EvccBaseEntity 11 | from .const import DOMAIN, NUMBER_SENSORS, ExtNumberEntityDescription, NUMBER_SENSORS_PER_LOADPOINT 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, add_entity_cb: AddEntitiesCallback): 16 | _LOGGER.debug("NUMBER async_setup_entry") 17 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 18 | entities = [] 19 | for description in NUMBER_SENSORS: 20 | # for SEK, NOK, DKK we need to patch the maxvalue (1€ ~ 10 Krone) 21 | if description.tag == Tag.BATTERYGRIDCHARGELIMIT: 22 | if coordinator._currency != "€": 23 | new_val = description.native_max_value * 10 24 | description.native_max_value=new_val 25 | 26 | entity = EvccNumber(coordinator, description) 27 | entities.append(entity) 28 | 29 | multi_loadpoint_config = len(coordinator._loadpoint) > 1 30 | for a_lp_key in coordinator._loadpoint: 31 | load_point_config = coordinator._loadpoint[a_lp_key] 32 | lp_api_index = int(a_lp_key) 33 | lp_id_addon = load_point_config["id"] 34 | lp_name_addon = load_point_config["name"] 35 | lp_has_phase_auto_option = load_point_config["has_phase_auto_option"] 36 | lp_is_heating = load_point_config["is_heating"] 37 | lp_is_integrated = load_point_config["is_integrated"] 38 | 39 | for a_stub in NUMBER_SENSORS_PER_LOADPOINT: 40 | if not lp_is_integrated or a_stub.integrated_supported: 41 | force_celsius = lp_is_heating and a_stub.tag == Tag.LIMITSOC 42 | 43 | description = ExtNumberEntityDescription( 44 | tag=a_stub.tag, 45 | idx=lp_api_index, 46 | key=f"{lp_id_addon}_{a_stub.tag.key}", 47 | translation_key=a_stub.tag.key, 48 | name_addon=lp_name_addon if multi_loadpoint_config else None, 49 | icon=a_stub.icon, 50 | device_class=SensorDeviceClass.TEMPERATURE if force_celsius else a_stub.device_class, 51 | unit_of_measurement=UnitOfTemperature.CELSIUS if force_celsius else a_stub.unit_of_measurement, 52 | entity_category=a_stub.entity_category, 53 | entity_registry_enabled_default=a_stub.entity_registry_enabled_default, 54 | 55 | # the entity type specific values... 56 | max_value=a_stub.max_value, 57 | min_value=a_stub.min_value, 58 | mode=a_stub.mode, 59 | native_max_value=a_stub.native_max_value, 60 | native_min_value=a_stub.native_min_value, 61 | native_step=a_stub.native_step, 62 | native_unit_of_measurement=UnitOfTemperature.CELSIUS if force_celsius else a_stub.native_unit_of_measurement, 63 | step=a_stub.step, 64 | ) 65 | 66 | if a_stub.tag == Tag.SMARTCOSTLIMIT: 67 | if coordinator._cost_type == "co2": 68 | description.translation_key = f"{a_stub.tag.key}_co2" 69 | description.icon = "mdi:molecule-co2" 70 | description.native_max_value=500 71 | description.native_min_value=0 72 | description.native_step=5 73 | description.native_unit_of_measurement="g/kWh" 74 | 75 | # for SEK, NOK, DKK we need to patch the maxvalue (1€ ~ 10 Krone) 76 | elif coordinator._currency != "€": 77 | new_val = a_stub.native_max_value * 10 78 | description.native_max_value=new_val 79 | 80 | entity = EvccNumber(coordinator, description) 81 | entities.append(entity) 82 | 83 | add_entity_cb(entities) 84 | 85 | 86 | class EvccNumber(EvccBaseEntity, NumberEntity): 87 | def __init__(self, coordinator: EvccDataUpdateCoordinator, description: ExtNumberEntityDescription): 88 | super().__init__(coordinator=coordinator, description=description) 89 | 90 | @property 91 | def native_value(self): 92 | try: 93 | value = self.coordinator.read_tag(self.tag, self.idx) 94 | if value is None or value == "": 95 | return "unknown" 96 | else: 97 | if self.tag == Tag.SMARTCOSTLIMIT or self.tag == Tag.BATTERYGRIDCHARGELIMIT: 98 | value = round(float(value), 3) 99 | else: 100 | value = int(value) 101 | 102 | # thanks for nothing evcc - SOC-Limit can be 0, even if the effectiveLimit is 100 - I assume you want 103 | # to tell that the limit is not set... 104 | if self.tag == Tag.LIMITSOC and value == 0: 105 | value = 100 106 | 107 | except KeyError: 108 | return "unknown" 109 | 110 | except TypeError: 111 | return None 112 | 113 | return value 114 | 115 | async def async_set_native_value(self, value) -> None: 116 | try: 117 | if self.tag == Tag.SMARTCOSTLIMIT or self.tag == Tag.BATTERYGRIDCHARGELIMIT: 118 | await self.coordinator.async_write_tag(self.tag, round(float(value), 3), self.idx, self) 119 | else: 120 | await self.coordinator.async_write_tag(self.tag, int(value), self.idx, self) 121 | 122 | except ValueError: 123 | return "unavailable" 124 | -------------------------------------------------------------------------------- /custom_components/evcc_intg/pyevcc_ha/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from datetime import datetime, timezone 4 | from json import JSONDecodeError 5 | from numbers import Number 6 | from typing import Callable 7 | 8 | import aiohttp 9 | from aiohttp import ClientResponseError, ClientConnectorError, ClientError 10 | from dateutil import parser 11 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 12 | 13 | from custom_components.evcc_intg.pyevcc_ha.const import ( 14 | TRANSLATIONS, 15 | JSONKEY_LOADPOINTS, 16 | JSONKEY_VEHICLES, 17 | ADDITIONAL_ENDPOINTS_DATA_TARIFF, 18 | ADDITIONAL_ENDPOINTS_DATA_SESSIONS, 19 | SESSIONS_KEY_RAW, 20 | SESSIONS_KEY_TOTAL, 21 | SESSIONS_KEY_VEHICLES, 22 | SESSIONS_KEY_LOADPOINTS 23 | ) 24 | from custom_components.evcc_intg.pyevcc_ha.keys import EP_TYPE, Tag, IS_TRIGGER 25 | 26 | _LOGGER: logging.Logger = logging.getLogger(__package__) 27 | 28 | 29 | async def _do_request(method: Callable) -> dict: 30 | try: 31 | async with method as res: 32 | try: 33 | if 199 < res.status < 300: 34 | try: 35 | if "application/json" in res.content_type.lower(): 36 | try: 37 | data = await res.json() 38 | # check if the data is a dict with a single key "result" - this 'result' container 39 | # will be removed in the future [https://github.com/evcc-io/evcc/pull/22299] 40 | if isinstance(data, dict) and "result" in data and len(data) == 1: 41 | data = data["result"] 42 | return data 43 | 44 | except JSONDecodeError as json_exc: 45 | _LOGGER.warning(f"APP-API: JSONDecodeError while 'await res.json(): {json_exc} [caused by {res.request_info.method} {res.request_info.url}]") 46 | 47 | except ClientResponseError as io_exc: 48 | _LOGGER.warning(f"APP-API: ClientResponseError while 'await res.json(): {io_exc} [caused by {res.request_info.method} {res.request_info.url}]") 49 | 50 | elif int(res.headers["Content-Length"]) > 0: 51 | try: 52 | content = await res.text() 53 | _LOGGER.warning(f"_do_request() - 'res.status == {res.status} content: {content} [caused by {res.request_info.method} {res.request_info.url}]") 54 | 55 | except ClientResponseError as io_exc: 56 | _LOGGER.warning(f"_do_request() ClientResponseError while 'res.status == {res.status} res.json(): {io_exc} [caused by {res.request_info.method} {res.request_info.url}]") 57 | 58 | else: 59 | _LOGGER.warning(f"_do_request() failed with http-status {res.status} [caused by {res.request_info.method} {res.request_info.url}]") 60 | 61 | except ClientError as io_exc: 62 | _LOGGER.warning(f"_do_request() failed cause: {io_exc} [caused by {res.request_info.method} {res.request_info.url}]") 63 | except Exception as ex: 64 | _LOGGER.warning(f"_do_request() failed cause: {type(ex).__name__} - {ex} [caused by {res.request_info.method} {res.request_info.url}]") 65 | return {} 66 | 67 | except ClientError as exception: 68 | _LOGGER.warning(f"_do_request() cause of ClientConnectorError: {exception}") 69 | except Exception as other: 70 | _LOGGER.warning(f"_do_request() unexpected: {type(other).__name__} - {other}") 71 | 72 | 73 | @staticmethod 74 | def calculate_session_sums(sessions_resp, json_resp: dict): 75 | vehicle_sums = {} 76 | loadpoint_sums = {} 77 | 78 | for a_session_entry in sessions_resp: 79 | try: 80 | a_vehicle = a_session_entry.get("vehicle", "") 81 | a_loadpoint = a_session_entry.get("loadpoint", "") 82 | if a_vehicle is None or len(a_vehicle) == 0 or a_loadpoint is None or len(a_loadpoint) == 0: 83 | _LOGGER.info(f"calculate_session_sums(): missing a key in session entry: {a_session_entry}") 84 | 85 | created = a_session_entry.get("created", None) 86 | finished = a_session_entry.get("finished", None) 87 | if created is not None and finished is not None: 88 | try: 89 | delta = parser.isoparse(finished) - parser.isoparse(created) 90 | charge_duration = delta.total_seconds() 91 | #_LOGGER.debug(f"calculate_session_sums(): {a_session_entry["id"]} {charge_duration}") 92 | 93 | except BaseException as exception: 94 | _LOGGER.info(f"calculate_session_sums(): invalid 'created' or 'finished' in session entry: {a_session_entry} caused: {type(exception).__name__} details: {exception}") 95 | charge_duration = 0 96 | else: 97 | charge_duration = a_session_entry.get("chargeDuration", 0) 98 | if charge_duration is None or not isinstance(charge_duration, Number): 99 | charge_duration = 0 100 | _LOGGER.info(f"calculate_session_sums(): invalid 'charge_duration' in session entry: {a_session_entry}") 101 | 102 | charged_energy = a_session_entry.get("chargedEnergy", 0) 103 | if charged_energy is None or not isinstance(charged_energy, Number): 104 | charged_energy = 0 105 | _LOGGER.info(f"calculate_session_sums(): invalid 'charged_energy' in session entry: {a_session_entry}") 106 | 107 | cost = a_session_entry.get("price", 0) 108 | if cost is None or not isinstance(cost, Number): 109 | cost = 0 110 | _LOGGER.info(f"calculate_session_sums(): invalid 'costs' in session entry: {a_session_entry}") 111 | 112 | _add_to_sums(vehicle_sums, a_vehicle, charge_duration, charged_energy, cost) 113 | _add_to_sums(loadpoint_sums, a_loadpoint, charge_duration, charged_energy, cost) 114 | 115 | except BaseException as exception: 116 | _LOGGER.info(f"calculate_session_sums(): {a_session_entry} caused: {type(exception).__name__} details: {exception}") 117 | 118 | json_resp[ADDITIONAL_ENDPOINTS_DATA_SESSIONS][SESSIONS_KEY_VEHICLES] = vehicle_sums 119 | json_resp[ADDITIONAL_ENDPOINTS_DATA_SESSIONS][SESSIONS_KEY_LOADPOINTS] = loadpoint_sums 120 | 121 | @staticmethod 122 | def _add_to_sums(a_sums_dict: dict, key: str, val_charge_duration, val_charged_energy, val_cost): 123 | if key is not None and len(key) > 0: 124 | if key not in a_sums_dict: 125 | a_sums_dict[key] = {"chargeDuration": 0, "chargedEnergy": 0, "cost": 0} 126 | 127 | a_sums_dict[key]["chargeDuration"] += val_charge_duration 128 | a_sums_dict[key]["chargedEnergy"] += val_charged_energy 129 | a_sums_dict[key]["cost"] += val_cost 130 | 131 | class EvccApiBridge: 132 | def __init__(self, host: str, web_session, coordinator: DataUpdateCoordinator = None, lang: str = "en") -> None: 133 | # make sure we are compliant with old configurations (that does not include the schema in the host variable) 134 | if not host.startswith(("http://", "https://")): 135 | host = f"http://{host}" 136 | 137 | # getting the correct web socket URL... 138 | if host.startswith("https://"): 139 | self.web_socket_url = f"wss://{host[8:]}/ws" 140 | else: 141 | self.web_socket_url = f"ws://{host[7:]}/ws" 142 | 143 | self.ws_connected = False 144 | self.coordinator = coordinator 145 | self._debounced_update_task = None 146 | 147 | self.host = host 148 | self.web_session = web_session 149 | self.lang_map = None 150 | if lang in TRANSLATIONS: 151 | self.lang_map = TRANSLATIONS[lang] 152 | else: 153 | self.lang_map = TRANSLATIONS["en"] 154 | 155 | self._TARIFF_LAST_UPDATE_HOUR = -1 156 | self._SESSIONS_LAST_UPDATE_HOUR = -1 157 | self._data = {} 158 | 159 | # by default, we do not request the tariff endpoints 160 | self.request_tariff_endpoints = False 161 | self.request_tariff_keys = [] 162 | 163 | async def is_evcc_available(self): 164 | _LOGGER.debug(f"is_evcc_available(): '{self.host}' CHECKING...") 165 | req = f"{self.host}/api/state" 166 | try: 167 | async with self.web_session.get(url=req, ssl=False) as res: 168 | res.raise_for_status() 169 | if res.status in [200, 201, 202, 204, 205]: 170 | data = await res.json() 171 | if data is not None and len(data) == 0: 172 | raise BaseException("NO DATA") 173 | 174 | except BaseException as exc: 175 | _LOGGER.debug(f"is_evcc_available(): check caused: {type(exc).__name__} - {exc} - Integration is not ready to be started.") 176 | raise exc 177 | 178 | _LOGGER.debug(f"is_evcc_available(): '{self.host}' is AVAILABLE") 179 | 180 | def enable_tariff_endpoints(self, keys: list): 181 | self._TARIFF_LAST_UPDATE_HOUR = -1 182 | self.request_tariff_endpoints = True 183 | self.request_tariff_keys = keys 184 | _LOGGER.debug(f"enabled tariff endpoints with keys: {keys}") 185 | 186 | def available_fields(self) -> int: 187 | return len(self._data) 188 | 189 | def clear_data(self): 190 | self._TARIFF_LAST_UPDATE_HOUR = -1 191 | self._SESSIONS_LAST_UPDATE_HOUR = -1 192 | self._data = {} 193 | 194 | async def ws_update_tariffs_if_required(self): 195 | """if we are in websocket mode, then we must (at least once each hour) update the tariff-data - we call 196 | this method in the watchdog to make sure that we have the latest data available! 197 | """ 198 | if self.request_tariff_endpoints and self._TARIFF_LAST_UPDATE_HOUR != datetime.now(timezone.utc).hour: 199 | await self.read_all_data(request_all=False, request_tariffs=True) 200 | 201 | async def ws_update_sessions_if_required(self): 202 | """if we are in websocket mode, then we must (at least once each hour) update the sessions-data - we call 203 | this method in the watchdog to make sure that we have the latest data available! 204 | """ 205 | if self._SESSIONS_LAST_UPDATE_HOUR != datetime.now(timezone.utc).hour: 206 | await self.read_all_data(request_all=False, request_sessions=True) 207 | 208 | async def connect_ws(self): 209 | try: 210 | async with self.web_session.ws_connect(self.web_socket_url) as ws: 211 | self.ws_connected = True 212 | _LOGGER.info(f"connected to websocket: {self.web_socket_url}") 213 | async for msg in ws: 214 | if msg.type == aiohttp.WSMsgType.TEXT: 215 | try: 216 | if self._data is None or len(self._data) == 0: 217 | self._TARIFF_LAST_UPDATE_HOUR = -1 218 | self._SESSIONS_LAST_UPDATE_HOUR = -1 219 | await self.read_all_data() 220 | except: 221 | _LOGGER.info(f"could not read initial data from evcc@{self.host} - ignoring") 222 | self._data = {} 223 | 224 | try: 225 | ws_data = msg.json() 226 | for key, value in ws_data.items(): 227 | if "." in key: 228 | key_parts = key.split(".") 229 | if len(key_parts) > 2: 230 | domain = key_parts[0] 231 | idx = int(key_parts[1]) 232 | sub_key = key_parts[2] 233 | if domain in self._data: 234 | if len(self._data[domain]) > idx: 235 | if not sub_key in self._data[domain][idx]: 236 | _LOGGER.debug(f"adding '{sub_key}' to {domain}[{idx}]") 237 | self._data[domain][idx][sub_key] = value 238 | else: 239 | # we need to add a new entry to the list... - well 240 | # if we get index 4 but length is only 2 we must add multiple 241 | # empty entries to the list... 242 | while len(self._data[domain]) <= idx: 243 | self._data[domain].append({}) 244 | 245 | self._data[domain][idx] = {sub_key: value} 246 | _LOGGER.debug(f"adding index {idx} to '{domain}' -> {self._data[domain][idx]}") 247 | else: 248 | _LOGGER.info(f"unhandled [{domain} not in data] 3part: {key} - ignoring: {value} data: {self._data}") 249 | # if domain == "loadpoints": 250 | # pass 251 | # elif domain == "vehicles": 252 | # pass 253 | elif len(key_parts) == 2: 254 | # currently only 'forcast.solar' 255 | domain = key_parts[0] 256 | sub_key = key_parts[1] 257 | if domain in self._data: 258 | if not sub_key in self._data[domain]: 259 | _LOGGER.debug(f"adding '{sub_key}' to {domain}") 260 | self._data[domain][sub_key] = value 261 | else: 262 | _LOGGER.info(f"unhandled [{domain} not in data] 2part: {key} - domain {domain} not in self.data - ignoring: {value}") 263 | else: 264 | _LOGGER.info(f"unhandled [not parsable key] {key} - ignoring: {value}") 265 | else: 266 | if key in self._data: 267 | self._data[key] = value 268 | else: 269 | if key != "releaseNotes": 270 | self._data[key] = value 271 | _LOGGER.info(f"'added {key}' to self._data and assign: {value}") 272 | 273 | # END of for loop 274 | # _LOGGER.debug(f"key: {key} value: {value}") 275 | if self._debounced_update_task is not None: 276 | self._debounced_update_task.cancel() 277 | self._debounced_update_task = asyncio.create_task(self._debounce_coordinator_update()) 278 | 279 | except Exception as e: 280 | _LOGGER.info(f"Could not read JSON from: {msg} - caused {e}") 281 | 282 | elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): 283 | _LOGGER.debug(f"received: {msg}") 284 | break 285 | else: 286 | _LOGGER.info(f"Other Websocket Message: {msg}") 287 | 288 | except asyncio.exceptions.CancelledError as cancel: 289 | _LOGGER.info(f"CancelledError@websocket cause by: {cancel}") 290 | except ClientConnectorError as con: 291 | _LOGGER.error(f"Could not connect to websocket: {con}") 292 | except BaseException as ex: 293 | _LOGGER.error(f"BaseException@websocket: {type(ex).__name__} - {ex}") 294 | 295 | self.ws_connected = False 296 | 297 | async def _debounce_coordinator_update(self): 298 | await asyncio.sleep(0.3) 299 | if self.coordinator is not None: 300 | self.coordinator.async_set_updated_data(self._data) 301 | 302 | async def read_all_data(self, request_all:bool=True, request_tariffs:bool=False, request_sessions:bool=False) -> dict: 303 | if request_all: 304 | _LOGGER.debug(f"going to read all data from evcc@{self.host}") 305 | req = f"{self.host}/api/state" 306 | _LOGGER.debug(f"GET request: {req}") 307 | json_resp = await _do_request(method=self.web_session.get(url=req, ssl=False)) 308 | 309 | if json_resp is not None and len(json_resp) == 0: 310 | _LOGGER.info(f"could not read data from evcc@{self.host} - using empty data") 311 | else: 312 | if self._data is None: 313 | self._data = {} 314 | json_resp = self._data 315 | 316 | current_hour = datetime.now(timezone.utc).hour 317 | if request_all or request_tariffs: 318 | if self.request_tariff_endpoints: 319 | _LOGGER.debug(f"going to request tariff data from evcc@{self.host}") 320 | # we only update the tariff data once per hour... 321 | if self._TARIFF_LAST_UPDATE_HOUR != current_hour: 322 | json_resp = await self.read_tariff_data(json_resp) 323 | self._TARIFF_LAST_UPDATE_HOUR = current_hour 324 | else: 325 | # we must copy the previous existing data to the new json_resp! 326 | if ADDITIONAL_ENDPOINTS_DATA_TARIFF in self._data: 327 | json_resp[ADDITIONAL_ENDPOINTS_DATA_TARIFF] = self._data[ADDITIONAL_ENDPOINTS_DATA_TARIFF] 328 | 329 | if request_all or request_sessions: 330 | _LOGGER.debug(f"going to request sessions data from evcc@{self.host}") 331 | # we only update the sessions data once per hour... 332 | if self._SESSIONS_LAST_UPDATE_HOUR != current_hour: 333 | json_resp = await self.read_sessions_data(json_resp) 334 | self._SESSIONS_LAST_UPDATE_HOUR = current_hour 335 | else: 336 | # we must copy the previous existing data to the new json_resp! 337 | if ADDITIONAL_ENDPOINTS_DATA_SESSIONS in self._data: 338 | json_resp[ADDITIONAL_ENDPOINTS_DATA_SESSIONS] = self._data[ADDITIONAL_ENDPOINTS_DATA_SESSIONS] 339 | 340 | self._data = json_resp 341 | return json_resp 342 | 343 | async def read_tariff_data(self, json_resp: dict) -> dict: 344 | # _LOGGER.info(f"going to request additional tariff data from evcc@{self.host}") 345 | if ADDITIONAL_ENDPOINTS_DATA_TARIFF not in json_resp: 346 | json_resp[ADDITIONAL_ENDPOINTS_DATA_TARIFF] = {} 347 | 348 | for a_key in self.request_tariff_keys: 349 | try: 350 | req = f"{self.host}/api/{EP_TYPE.TARIFF.value}/{a_key}" 351 | _LOGGER.debug(f"GET request: {req}") 352 | tariff_resp = await _do_request(method=self.web_session.get(url=req, ssl=False)) 353 | if tariff_resp is not None and len(tariff_resp) > 0: 354 | json_resp[ADDITIONAL_ENDPOINTS_DATA_TARIFF][a_key] = tariff_resp 355 | 356 | except Exception as err: 357 | _LOGGER.info(f"could not read tariff data for '{a_key}' -> '{err}'") 358 | 359 | return json_resp 360 | 361 | async def read_sessions_data(self, json_resp: dict) -> dict: 362 | # _LOGGER.info(f"going to request additional sessions data from evcc@{self.host}") 363 | if ADDITIONAL_ENDPOINTS_DATA_SESSIONS not in json_resp: 364 | json_resp[ADDITIONAL_ENDPOINTS_DATA_SESSIONS] = {} 365 | 366 | try: 367 | req = f"{self.host}/api/{EP_TYPE.SESSIONS.value}" 368 | _LOGGER.debug(f"GET request: {req}") 369 | sessions_resp = await _do_request(method=self.web_session.get(url=req, ssl=False)) 370 | if sessions_resp is not None and len(sessions_resp) > 0: 371 | json_resp[ADDITIONAL_ENDPOINTS_DATA_SESSIONS][SESSIONS_KEY_TOTAL] = len(sessions_resp) 372 | # raw data will exceed maximum size of 16384 bytes - so we can't store this 373 | # json_resp[ADDITIONAL_ENDPOINTS_DATA_SESSIONS][SESSIONS_KEY_RAW] = sessions_resp 374 | 375 | # do the math stuff... 376 | calculate_session_sums(sessions_resp, json_resp) 377 | 378 | except BaseException as err: 379 | _LOGGER.info(f"could not read sessions data '{type(err).__name__}' -> {err}") 380 | 381 | return json_resp 382 | 383 | async def press_tag(self, tag: Tag, value, idx: str = None) -> dict: 384 | ret = {} 385 | if hasattr(tag, "write_type") and tag.write_type is not None: 386 | final_type = tag.write_type 387 | else: 388 | final_type = tag.type 389 | 390 | if final_type == EP_TYPE.LOADPOINTS and idx is not None: 391 | ret[tag.key] = await self.press_loadpoint_key(idx, tag.write_key, value) 392 | 393 | elif final_type == EP_TYPE.VEHICLES: 394 | # before we can write something to the vehicle endpoints, we must know the vehicle_id! 395 | # -> so we have to grab from the loadpoint the current vehicle! 396 | if len(self._data) > 0 and JSONKEY_LOADPOINTS in self._data: 397 | try: 398 | int_idx = int(idx) - 1 399 | vehicle_id = self._data[JSONKEY_LOADPOINTS][int_idx][Tag.VEHICLENAME.key] 400 | if vehicle_id is not None: 401 | ret[tag.key] = await self.press_vehicle_key(vehicle_id, tag.write_key, value) 402 | 403 | except Exception as err: 404 | _LOGGER.info(f"could not find a connected vehicle at loadpoint: {idx}") 405 | 406 | return ret 407 | 408 | async def press_loadpoint_key(self, lp_idx, write_key, value) -> dict: 409 | # idx will start with 1! 410 | if isinstance(value, (bool, int, float)): 411 | value = str(value).lower() 412 | elif value is not None: 413 | value = str(value) 414 | 415 | _LOGGER.info(f"going to press a button with payload '{value}' for key '{write_key}' to evcc-loadpoint{lp_idx}@{self.host}") 416 | if value is None: 417 | if write_key == Tag.DETECTVEHICLE.write_key: 418 | req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/vehicle" 419 | _LOGGER.debug(f"PATCH request: {req}") 420 | r_json = await _do_request(method=self.web_session.patch(url=req, ssl=False)) 421 | else: 422 | req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/{write_key}" 423 | _LOGGER.debug(f"DELETE request: {req}") 424 | r_json = await _do_request(method=self.web_session.delete(url=req, ssl=False)) 425 | else: 426 | req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx}/{write_key}/{value}" 427 | _LOGGER.debug(f"POST request: {req}") 428 | r_json = await _do_request(method=self.web_session.post(url=req, ssl=False)) 429 | 430 | if r_json is not None and ((hasattr(r_json, "len") and len(r_json) > 0) or isinstance(r_json, (Number, str))): 431 | return r_json 432 | else: 433 | return {"err": "no response from evcc"} 434 | 435 | async def press_vehicle_key(self, vehicle_id: str, write_key, value) -> dict: 436 | if isinstance(value, (bool, int, float)): 437 | value = str(value).lower() 438 | elif value is not None: 439 | value = str(value) 440 | 441 | _LOGGER.info(f"going to press a button with payload '{value}' for key '{write_key}' to evcc-vehicle{vehicle_id}@{self.host}") 442 | r_json = None 443 | if value is None: 444 | if write_key == Tag.VEHICLEPLANSDELETE.write_key: 445 | req = f"{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/{write_key}" 446 | _LOGGER.debug(f"DELETE request: {req}") 447 | r_json = await _do_request(method=self.web_session.delete(url=req, ssl=False)) 448 | else: 449 | pass 450 | else: 451 | req = f"{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/{write_key}/{value}" 452 | _LOGGER.debug(f"POST request: {req}") 453 | r_json = await _do_request(method=self.web_session.post(url=req, ssl=False)) 454 | 455 | if r_json is not None: 456 | if (hasattr(r_json, "len") and len(r_json) > 0) or isinstance(r_json, (Number, str)): 457 | r_json[write_key] = "OK" 458 | return r_json 459 | else: 460 | return {"err": "no response from evcc"} 461 | 462 | async def write_tag(self, tag: Tag, value, idx_str: str = None) -> dict: 463 | ret = {} 464 | if hasattr(tag, "write_type") and tag.write_type is not None: 465 | final_type = tag.write_type 466 | else: 467 | final_type = tag.type 468 | 469 | if final_type == EP_TYPE.SITE: 470 | ret[tag.key] = await self.write_site_key(tag.write_key, value) 471 | 472 | elif final_type == EP_TYPE.LOADPOINTS and idx_str is not None: 473 | ret[tag.key] = await self.write_loadpoint_key(idx_str, tag.write_key, value) 474 | 475 | elif final_type == EP_TYPE.VEHICLES: 476 | # before we can write something to the vehicle endpoints, we must know the vehicle_id! 477 | # -> so we have to grab from the loadpoint the current vehicle! 478 | if len(self._data) > 0 and JSONKEY_LOADPOINTS in self._data: 479 | try: 480 | int_idx = int(idx_str) - 1 481 | vehicle_id = self._data[JSONKEY_LOADPOINTS][int_idx][Tag.VEHICLENAME.key] 482 | if vehicle_id is not None: 483 | ret[tag.key] = await self.write_vehicle_key(vehicle_id, tag.write_key, value) 484 | 485 | except Exception as err: 486 | _LOGGER.info(f"could not find a connected vehicle at loadpoint: {idx_str}") 487 | 488 | return ret 489 | 490 | async def write_site_key(self, write_key, value) -> dict: 491 | if isinstance(value, (bool, int, float)): 492 | value = str(value).lower() 493 | elif value is not None: 494 | value = str(value) 495 | 496 | _LOGGER.info(f"going to write '{value}' for key '{write_key}' to evcc-site@{self.host}") 497 | r_json = None 498 | if value is None: 499 | req = f"{self.host}/api/{write_key}" 500 | _LOGGER.debug(f"DELETE request: {req}") 501 | r_json = await _do_request(method=self.web_session.delete(url=req, ssl=False)) 502 | else: 503 | req = f"{self.host}/api/{write_key}/{value}" 504 | _LOGGER.debug(f"POST request: {req}") 505 | r_json = await _do_request(method=self.web_session.post(url=req, ssl=False)) 506 | 507 | if r_json is not None and ((hasattr(r_json, "len") and len(r_json) > 0) or isinstance(r_json, (Number, str))): 508 | return r_json 509 | else: 510 | return {"err": "no response from evcc"} 511 | 512 | async def write_loadpoint_key(self, lp_idx_str, write_key, value) -> dict: 513 | # idx will start with 1! 514 | if isinstance(value, (bool, int, float)): 515 | value = str(value).lower() 516 | elif value is not None: 517 | value = str(value) 518 | 519 | _LOGGER.info(f"going to write '{value}' for key '{write_key}' to evcc-loadpoint{lp_idx_str}@{self.host}") 520 | r_json = None 521 | if value is None: 522 | req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx_str}/{write_key}" 523 | _LOGGER.debug(f"DELETE request: {req}") 524 | r_json = await _do_request(method=self.web_session.delete(url=req, ssl=False)) 525 | else: 526 | req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{lp_idx_str}/{write_key}/{value}" 527 | _LOGGER.debug(f"POST request: {req}") 528 | r_json = await _do_request(method=self.web_session.post(url=req, ssl=False)) 529 | 530 | if r_json is not None and ((hasattr(r_json, "len") and len(r_json) > 0) or isinstance(r_json, (Number, str))): 531 | return r_json 532 | else: 533 | return {"err": "no response from evcc"} 534 | 535 | async def write_vehicle_key(self, vehicle_id: str, write_key, value) -> dict: 536 | if isinstance(value, (bool, int, float)): 537 | value = str(value).lower() 538 | else: 539 | value = str(value) 540 | 541 | _LOGGER.info(f"going to write '{value}' for key '{write_key}' to evcc-vehicle{vehicle_id}@{self.host}") 542 | req = f"{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/{write_key}/{value}" 543 | _LOGGER.debug(f"POST request: {req}") 544 | r_json = await _do_request(method=self.web_session.post(url=req, ssl=False)) 545 | 546 | if r_json is not None and ((hasattr(r_json, "len") and len(r_json) > 0) or isinstance(r_json, (Number, str))): 547 | return r_json 548 | else: 549 | return {"err": "no response from evcc"} 550 | 551 | async def write_loadpoint_plan(self, idx: str, energy: str, rfc_date: str): 552 | try: 553 | r_json = None 554 | if energy is not None and rfc_date is not None: 555 | # WRITE PLAN... 556 | req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{idx}/plan/energy/{energy}/{rfc_date}" 557 | _LOGGER.debug(f"POST request: {req}") 558 | r_json = await _do_request(method=self.web_session.post(url=req, ssl=False)) 559 | else: 560 | # DELETE PLAN... 561 | req = f"{self.host}/api/{EP_TYPE.LOADPOINTS.value}/{idx}/plan/energy" 562 | _LOGGER.debug(f"DELETE request: {req}") 563 | r_json = await _do_request(method=self.web_session.delete(url=req, ssl=False)) 564 | 565 | if r_json is not None and ((hasattr(r_json, "len") and len(r_json) > 0) or isinstance(r_json, (Number, str))): 566 | return r_json 567 | else: 568 | return {"err": "no response from evcc"} 569 | 570 | except Exception as err: 571 | _LOGGER.info(f"could not write to loadpoint: {idx}") 572 | 573 | async def write_vehicle_plan(self, vehicle_id:str, soc:str, rfc_date:str, precondition: int | None = None): 574 | if vehicle_id is not None: 575 | try: 576 | r_json = None 577 | if soc is not None and rfc_date is not None: 578 | # WRITE PLAN... 579 | req = f"{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/plan/soc/{soc}/{rfc_date}" 580 | if precondition is not None and precondition > 0: 581 | req += f"?precondition={precondition}" 582 | _LOGGER.debug(f"POST request: {req}") 583 | r_json = await _do_request(method=self.web_session.post(url=req, ssl=False)) 584 | else: 585 | # DELETE PLAN... 586 | req = f"{self.host}/api/{EP_TYPE.VEHICLES.value}/{vehicle_id}/plan/soc" 587 | _LOGGER.debug(f"DELETE request: {req}") 588 | r_json = await _do_request(method=self.web_session.delete(url=req, ssl=False)) 589 | 590 | if r_json is not None and ((hasattr(r_json, "len") and len(r_json) > 0) or isinstance(r_json, (Number, str))): 591 | return r_json 592 | else: 593 | return {"err": "no response from evcc"} 594 | 595 | except Exception as err: 596 | _LOGGER.error(f"could not write vehicle plan for vehicle: {vehicle_id}, error: {err}") 597 | return {"err": f"could not write vehicle plan: {err}"} -------------------------------------------------------------------------------- /custom_components/evcc_intg/pyevcc_ha/const.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | JSONKEY_PLANS: Final = "plans" 4 | JSONKEY_PLAN: Final = "plan" 5 | JSONKEY_PLANS_SOC: Final = "soc" 6 | JSONKEY_PLANS_TIME: Final = "time" 7 | 8 | JSONKEY_LOADPOINTS: Final = "loadpoints" 9 | JSONKEY_VEHICLES: Final = "vehicles" 10 | JSONKEY_AUXPOWER: Final = "auxPower" 11 | JSONKEY_BATTERYMODE: Final = "batteryMode" 12 | JSONKEY_BATTERYPOWER: Final = "batteryPower" 13 | JSONKEY_BATTERYSOC: Final = "batterySoc" 14 | JSONKEY_HOMEPOWER: Final = "homePower" 15 | JSONKEY_PVENERGY: Final = "pvEnergy" 16 | JSONKEY_PVPOWER: Final = "pvPower" 17 | JSONKEY_PV: Final = "pv" 18 | JSONKEY_STATISTICS: Final = "statistics" 19 | JSONKEY_STATISTICS_TOTAL: Final = "total" 20 | JSONKEY_STATISTICS_THISYEAR: Final = "thisYear" 21 | JSONKEY_STATISTICS_365D: Final = "365d" 22 | JSONKEY_STATISTICS_30D: Final = "30d" 23 | 24 | JSONKEY_GRIDCURRENTS: Final = "gridCurrents" 25 | JSONKEY_GRIDPOWER: Final = "gridPower" 26 | JSONKEY_GRID: Final = "grid" 27 | 28 | ADDITIONAL_ENDPOINTS_DATA_TARIFF: Final = "@@@tariff-data" 29 | ADDITIONAL_ENDPOINTS_DATA_SESSIONS: Final = "@@@session-data" 30 | SESSIONS_KEY_RAW: Final = "raw" 31 | SESSIONS_KEY_TOTAL: Final = "total" 32 | SESSIONS_KEY_VEHICLES: Final = "vehicles" 33 | SESSIONS_KEY_LOADPOINTS: Final = "loadpoints" 34 | 35 | # STATES: Final = [JSONKEY_LOADPOINTS, JSONKEY_AUXPOWER, JSONKEY_BATTERYMODE, JSONKEY_BATTERYPOWER, JSONKEY_BATTERYSOC, 36 | # JSONKEY_GRID, JSONKEY_GRIDCURRENTS, JSONKEY_GRIDPOWER, JSONKEY_HOMEPOWER, JSONKEY_PVENERGY, 37 | # JSONKEY_PVPOWER, JSONKEY_PV, JSONKEY_VEHICLES, JSONKEY_STATISTICS] 38 | 39 | # FILTER_LOADPOINTS: Final = f"?jq=.{JSONKEY_LOADPOINTS}" 40 | 41 | # STATE_QUERY = ( 42 | # f"?jq={{{JSONKEY_LOADPOINTS}:.{JSONKEY_LOADPOINTS},{JSONKEY_AUXPOWER}:.{JSONKEY_AUXPOWER},{JSONKEY_BATTERYMODE}:.{JSONKEY_BATTERYMODE},{JSONKEY_BATTERYPOWER}:.{JSONKEY_BATTERYPOWER},{JSONKEY_BATTERYSOC}:.{JSONKEY_BATTERYSOC},{JSONKEY_GRID}:.{JSONKEY_GRID},{JSONKEY_GRIDCURRENTS}:.{JSONKEY_GRIDCURRENTS},{JSONKEY_GRIDPOWER}:.{JSONKEY_GRIDPOWER},{JSONKEY_HOMEPOWER}:.{JSONKEY_HOMEPOWER},{JSONKEY_HOMEPOWER}:.{JSONKEY_HOMEPOWER},{JSONKEY_PVENERGY}:.{JSONKEY_PVENERGY},{JSONKEY_PVPOWER}:.{JSONKEY_PVPOWER},{JSONKEY_PV}:.{JSONKEY_PV},{JSONKEY_VEHICLES}:.{JSONKEY_VEHICLES},{JSONKEY_STATISTICS}:.{JSONKEY_STATISTICS}}}" 43 | # ) 44 | 45 | MIN_CURRENT_LIST: Final = ["0.125", "0.25", "0.5", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", 46 | "14", "15", "16"]#, "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32"] 47 | 48 | MAX_CURRENT_LIST: Final = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", 49 | "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", 50 | "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", 51 | "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", 52 | "63", "64"] 53 | 54 | BATTERY_LIST: Final = ["0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", 55 | "80", "85", "90", "95", "100"] 56 | 57 | TRANSLATIONS: Final = { 58 | "de": { 59 | "batterymode": { 60 | "unknown": "Unbekannt", 61 | "normal": "normal", 62 | "hold": "angehalten/gesperrt", 63 | "charge": "laden...", 64 | }, 65 | "phaseaction":{ 66 | "scale1p": "Reduziere auf einphasig", 67 | "scale3p": "Erhöhe auf dreiphasig", 68 | "inactive": "-keine-" 69 | }, 70 | "pvaction":{ 71 | "enable": "Ausreichend PV-Leistung vorhanden", 72 | "disable": "Unzureichende PV-Leistung, Aktivierung des Timeouts", 73 | "inactive": "Auch nach dem Timeout ist keine PV-Leistung verfügbar" 74 | } 75 | }, 76 | "en": { 77 | "batterymode": { 78 | "unknown": "unknown", 79 | "normal": "normal", 80 | "hold": "on-hold", 81 | "charge": "charging...", 82 | }, 83 | "phaseaction":{ 84 | "scale1p": "Reducing to 1-phase charging", 85 | "scale3p": "Increasing to 3-phase charging", 86 | "inactive": "-none-" 87 | }, 88 | "pvaction":{ 89 | "enable": "Sufficient PV power available", 90 | "disable": "Insufficient PV power, activating the timeout", 91 | "inactive": "No PV power available even after the timeout" 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /custom_components/evcc_intg/pyevcc_ha/keys.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from enum import Enum 4 | from typing import ( 5 | NamedTuple, Final 6 | ) 7 | 8 | from custom_components.evcc_intg.pyevcc_ha.const import ( 9 | MIN_CURRENT_LIST, 10 | MAX_CURRENT_LIST, 11 | JSONKEY_LOADPOINTS, 12 | JSONKEY_VEHICLES, 13 | JSONKEY_STATISTICS, 14 | JSONKEY_STATISTICS_TOTAL, 15 | JSONKEY_STATISTICS_THISYEAR, 16 | JSONKEY_STATISTICS_365D, 17 | JSONKEY_STATISTICS_30D, 18 | BATTERY_LIST, 19 | SESSIONS_KEY_VEHICLES, 20 | SESSIONS_KEY_LOADPOINTS 21 | ) 22 | 23 | # from aenum import Enum, extend_enum 24 | 25 | _LOGGER: logging.Logger = logging.getLogger(__package__) 26 | 27 | IS_TRIGGER: Final = "TRIGGER" 28 | 29 | CC_P1: Final = re.compile(r"(.)([A-Z][a-z]+)") 30 | CC_P2: Final = re.compile(r"([a-z0-9])([A-Z])") 31 | 32 | def camel_to_snake(a_key: str): 33 | if a_key.lower().endswith("kwh"): 34 | a_key = a_key[:-3] + "_kwh" 35 | a_key = re.sub(CC_P1, r'\1_\2', a_key) 36 | return re.sub(CC_P2, r'\1_\2', a_key).lower() 37 | 38 | class EP_TYPE(Enum): 39 | LOADPOINTS = JSONKEY_LOADPOINTS 40 | VEHICLES = JSONKEY_VEHICLES 41 | STATISTICS = JSONKEY_STATISTICS 42 | SITE = "site" 43 | TARIFF = "tariff" 44 | SESSIONS = "sessions" 45 | 46 | class BATTERY_CONTENT(Enum): 47 | SOC = "soc" 48 | POWER = "power" 49 | 50 | class GRID_CONTENT(Enum): 51 | CURRENTS = "currents" 52 | POWER = "power" 53 | 54 | class PV_CONTENT(Enum): 55 | ENERGY = "energy" 56 | POWER = "power" 57 | 58 | class FORECAST_CONTENT(Enum): 59 | GRID = "grid" 60 | SOLAR = "solar" 61 | 62 | class ApiKey(NamedTuple): 63 | key: str 64 | type: str 65 | key_alias: str = None 66 | subtype: str = None 67 | entity_key: str = None 68 | write_key: str = None 69 | write_type: str = None 70 | options: list[str] = None 71 | writeable: bool = False 72 | 73 | @property 74 | def snake_case(self) -> str: 75 | return camel_to_snake(self.key) 76 | 77 | # see https://docs.evcc.io/docs/reference/api for details 78 | class Tag(ApiKey, Enum): 79 | 80 | def __hash__(self) -> int: 81 | if self.entity_key is not None: 82 | return hash(f"{self.key}.{self.entity_key}") 83 | else: 84 | return hash(self.key) 85 | 86 | def __str__(self): 87 | if self.entity_key is not None: 88 | return f"{self.key}.{self.entity_key}" 89 | else: 90 | return self.key 91 | 92 | ################################### 93 | # SITE STUFF 94 | ################################### 95 | 96 | # "auxPower": 1116.8, 97 | AUXPOWER = ApiKey(key="auxPower", type=EP_TYPE.SITE) 98 | 99 | # "batteryMode": unknown|normal|hold|charge 100 | BATTERYMODE = ApiKey(key="batteryMode", type=EP_TYPE.SITE) 101 | 102 | # "batteryPower": 3.21, 103 | BATTERYPOWER = ApiKey(key="batteryPower", type=EP_TYPE.SITE) 104 | 105 | # "batterySoc": 70, 106 | BATTERYSOC = ApiKey(key="batterySoc", type=EP_TYPE.SITE) 107 | 108 | # "batteryCapacity": 7.5, 109 | BATTERYCAPACITY = ApiKey(key="batteryCapacity", type=EP_TYPE.SITE) 110 | 111 | # "battery":[{"power":0,"capacity":12,"soc":81,"controllable":false}], -> we must access this attribute via tuple_idx 112 | BATTERY = ApiKey(key="battery", type=EP_TYPE.SITE) 113 | 114 | # "pvPower": 8871.22, 115 | PVPOWER = ApiKey(key="pvPower", type=EP_TYPE.SITE) 116 | 117 | # "pvEnergy": 4235.825, 118 | PVENERGY = ApiKey(key="pvEnergy", type=EP_TYPE.SITE) 119 | 120 | # "pv": [{"power": 8871.22}], -> we must access this attribute via tuple_idx 121 | PV = ApiKey(key="pv", type=EP_TYPE.SITE) 122 | 123 | # "gridCurrents": [17.95, 7.71, 1.99], 124 | GRIDCURRENTS = ApiKey(key="gridCurrents", type=EP_TYPE.SITE) 125 | 126 | # "gridPower": -6280.24, 127 | GRIDPOWER = ApiKey(key="gridPower", type=EP_TYPE.SITE) 128 | 129 | # "grid": { "currents": [17.95, 7.71, 1.99], 130 | # "power": -6280.24, 131 | # ...} 132 | GRID = ApiKey(key="grid", type=EP_TYPE.SITE) 133 | 134 | # "homePower": 2594.19, 135 | HOMEPOWER = ApiKey(key="homePower", type=EP_TYPE.SITE) 136 | 137 | # -> NONE FREQUENT 138 | # POST /api/batterydischargecontrol/: enable/disable battery discharge control (true/false) 139 | BATTERYDISCHARGECONTROL = ApiKey( 140 | key="batteryDischargeControl", type=EP_TYPE.SITE, writeable=True, write_key="batterydischargecontrol" 141 | ) 142 | 143 | # POST /api/residualpower/: grid residual power in W 144 | RESIDUALPOWER = ApiKey(key="residualPower", type=EP_TYPE.SITE, writeable=True, write_key="residualpower") 145 | 146 | # POST /api/prioritysoc/: battery priority soc in % 147 | PRIORITYSOC = ApiKey( 148 | key="prioritySoc", type=EP_TYPE.SITE, writeable=True, write_key="prioritysoc", options=BATTERY_LIST 149 | ) 150 | 151 | # POST /api/buffersoc/: battery buffer soc in % 152 | BUFFERSOC = ApiKey( 153 | key="bufferSoc", type=EP_TYPE.SITE, writeable=True, write_key="buffersoc", options=BATTERY_LIST[1:] 154 | ) 155 | 156 | # POST /api/bufferstartsoc/: battery buffer start soc in % 157 | BUFFERSTARTSOC = ApiKey( 158 | key="bufferStartSoc", type=EP_TYPE.SITE, writeable=True, write_key="bufferstartsoc", 159 | options=BATTERY_LIST[1:]+BATTERY_LIST[0:1] 160 | ) 161 | 162 | # when 'POST /api/smartcostlimit/:' smart charging cost limit (previously known as "cheap" tariff) 163 | # ALL smartCostLimit of all loadpoints will be set 164 | # SMARTCOSTLIMIT = ApiKey(key="smartCostLimit", type=EP_TYPE.SITE, writeable=True, write_key="smartcostlimit") 165 | 166 | AVAILABLEVERSION = ApiKey(key="availableVersion", type=EP_TYPE.SITE) 167 | 168 | VERSION = ApiKey(key="version", type=EP_TYPE.SITE) 169 | 170 | # "tariffGrid": 0.233835, 171 | TARIFFGRID = ApiKey(key="tariffGrid", type=EP_TYPE.SITE) 172 | 173 | # "tariffPriceHome": 0, 174 | TARIFFPRICEHOME = ApiKey(key="tariffPriceHome", type=EP_TYPE.SITE) 175 | 176 | # -> NONE FREQUENT 177 | # POST /api/batterydischargecontrol/: enable/disable battery discharge control (true/false) 178 | # batteryGridChargeActive: false, 179 | BATTERYGRIDCHARGEACTIVE = ApiKey(key="batteryGridChargeActive", type=EP_TYPE.SITE, write_key="batterygridchargeactive") 180 | 181 | # batteryGridChargeLimit: ?? 182 | BATTERYGRIDCHARGELIMIT = ApiKey(key="batteryGridChargeLimit", type=EP_TYPE.SITE, write_key="batterygridchargelimit") 183 | 184 | FORECAST_GRID = ApiKey(entity_key="forecast_grid", key="forecast", type=EP_TYPE.SITE) 185 | FORECAST_SOLAR = ApiKey(entity_key="forecast_solar", key="forecast", type=EP_TYPE.SITE) 186 | 187 | ################################### 188 | # LOADPOINT-DATA 189 | ################################### 190 | 191 | # "chargeCurrent": 0, 192 | # key_alias is a new property of a tag, that allows to specify a second json key 193 | # -> that is useful, when an attribute will be renamed in the source API 194 | CHARGECURRENT = ApiKey(key="chargeCurrent", key_alias="offeredCurrent", type=EP_TYPE.LOADPOINTS) 195 | 196 | # "chargeCurrents": [0, 0, 0], 197 | CHARGECURRENTS = ApiKey(key="chargeCurrents", type=EP_TYPE.LOADPOINTS) 198 | 199 | # "chargeDuration": 0, -> (in millis) ?! 840000000000 = 14min -> / 1000000 200 | CHARGEDURATION = ApiKey(key="chargeDuration", type=EP_TYPE.LOADPOINTS) 201 | CHARGEREMAININGDURATION = ApiKey(key="chargeRemainingDuration", type=EP_TYPE.LOADPOINTS) 202 | 203 | # "chargePower": 0, 204 | CHARGEPOWER = ApiKey(key="chargePower", type=EP_TYPE.LOADPOINTS) 205 | 206 | # "chargeTotalImport": 0.004, 207 | CHARGETOTALIMPORT = ApiKey(key="chargeTotalImport", type=EP_TYPE.LOADPOINTS) 208 | 209 | # "chargedEnergy": 0, 210 | CHARGEDENERGY = ApiKey(key="chargedEnergy", type=EP_TYPE.LOADPOINTS) 211 | CHARGEREMAININGENERGY = ApiKey(key="chargeRemainingEnergy", type=EP_TYPE.LOADPOINTS) 212 | 213 | # "chargerFeatureHeating": false, 214 | # "chargerFeatureIntegratedDevice": false, 215 | # "chargerIcon": null, 216 | # "chargerPhases1p3p": true, 217 | # "chargerPhysicalPhases": null, 218 | 219 | # "charging": false, 220 | CHARGING = ApiKey(key="charging", type=EP_TYPE.LOADPOINTS) 221 | 222 | # "connected": false, 223 | CONNECTED = ApiKey(key="connected", type=EP_TYPE.LOADPOINTS) 224 | 225 | # "connectedDuration": 9.223372036854776e+18, 226 | CONNECTEDDURATION = ApiKey(key="connectedDuration", type=EP_TYPE.LOADPOINTS) 227 | 228 | # "effectiveLimitSoc": 100, 229 | EFFECTIVELIMITSOC = ApiKey(key="effectiveLimitSoc", type=EP_TYPE.LOADPOINTS) 230 | 231 | # "effectiveMaxCurrent": 16, 232 | # "effectiveMinCurrent": 6, 233 | # "effectivePriority": 0, 234 | 235 | # "planActive": false, 236 | PLANACTIVE = ApiKey(key="planActive", type=EP_TYPE.LOADPOINTS) 237 | # this value is NOT present in the data - and must be calculated internally 238 | PLANACTIVEALT = ApiKey(key="planActiveAlt", type=EP_TYPE.LOADPOINTS) 239 | # "effectivePlanSoc": 0, 240 | EFFECTIVEPLANSOC = ApiKey(key="effectivePlanSoc", type=EP_TYPE.LOADPOINTS) 241 | # "effectivePlanTime": "0001-01-01T00:00:00Z", 242 | EFFECTIVEPLANTIME = ApiKey(key="effectivePlanTime", type=EP_TYPE.LOADPOINTS) 243 | # "planProjectedEnd": "2025-02-20T13:34:32+01:00", 244 | PLANPROJECTEDEND = ApiKey(key="planProjectedEnd", type=EP_TYPE.LOADPOINTS) 245 | # "planProjectedStart": "2025-02-20T13:00:00+01:00", 246 | PLANPROJECTEDSTART = ApiKey(key="planProjectedStart", type=EP_TYPE.LOADPOINTS) 247 | 248 | # "enabled": false, 249 | ENABLED = ApiKey(key="enabled", type=EP_TYPE.LOADPOINTS) 250 | 251 | # "phaseAction": "inactive", 252 | PHASEACTION = ApiKey(key="phaseAction", type=EP_TYPE.LOADPOINTS) 253 | 254 | # "phaseRemaining": 0, 255 | PHASEREMAINING = ApiKey(key="phaseRemaining", type=EP_TYPE.LOADPOINTS) 256 | 257 | # "phasesActive": 3, 258 | PHASESACTIVE = ApiKey(key="phasesActive", type=EP_TYPE.LOADPOINTS) 259 | 260 | # "phasesEnabled": 0, 261 | PHASESENABLED = ApiKey(key="phasesEnabled", type=EP_TYPE.LOADPOINTS) 262 | 263 | # "planOverrun": 0, 264 | PLANOVERRUN = ApiKey(key="planOverrun", type=EP_TYPE.LOADPOINTS) 265 | 266 | # "priority": 0, 267 | LPPRIORIRY = ApiKey(key="priority", write_key="priority", type=EP_TYPE.LOADPOINTS) 268 | 269 | # "pvAction": "inactive", "activ", "disable" 270 | PVACTION = ApiKey(key="pvAction", type=EP_TYPE.LOADPOINTS) 271 | # "pvRemaining": 0, 272 | PVREMAINING = ApiKey(key="pvRemaining", type=EP_TYPE.LOADPOINTS) 273 | # "enableDelay": 60, 274 | ENABLEDELAY = ApiKey(key="enableDelay", write_key="enable/delay", type=EP_TYPE.LOADPOINTS) 275 | # "disableDelay": 180, 276 | DISABLEDELAY = ApiKey(key="disableDelay", write_key="disable/delay", type=EP_TYPE.LOADPOINTS) 277 | 278 | # "sessionCo2PerKWh": null, 279 | SESSIONCO2PERKWH = ApiKey(key="sessionCo2PerKWh", type=EP_TYPE.LOADPOINTS) 280 | # "sessionEnergy": 0, 281 | SESSIONENERGY = ApiKey(key="sessionEnergy", type=EP_TYPE.LOADPOINTS) 282 | # "sessionPrice": null, 283 | SESSIONPRICE = ApiKey(key="sessionPrice", type=EP_TYPE.LOADPOINTS) 284 | # "sessionPricePerKWh": null, 285 | SESSIONPRICEPERKWH = ApiKey(key="sessionPricePerKWh", type=EP_TYPE.LOADPOINTS) 286 | # "sessionSolarPercentage": 0, 287 | SESSIONSOLARPERCENTAGE = ApiKey(key="sessionSolarPercentage", type=EP_TYPE.LOADPOINTS) 288 | 289 | # "smartCostActive": true, 290 | SMARTCOSTACTIVE = ApiKey(key="smartCostActive", type=EP_TYPE.LOADPOINTS) 291 | 292 | # "smartCostLimit": 0.22, 293 | SMARTCOSTLIMIT = ApiKey(key="smartCostLimit", type=EP_TYPE.LOADPOINTS, writeable=True, write_key="smartcostlimit") 294 | 295 | # "title": "HH-7", 296 | # -> USED during startup phase 297 | 298 | # "vehicleClimaterActive": null, 299 | # ??? 300 | 301 | # start Vehicle Detection Button 302 | DETECTVEHICLE = ApiKey(key="detectvehicle", type=EP_TYPE.LOADPOINTS, writeable=True, write_key="detectvehicle") 303 | 304 | # "vehicleDetectionActive": false, 305 | VEHICLEDETECTIONACTIVE = ApiKey(key="vehicleDetectionActive", type=EP_TYPE.LOADPOINTS) 306 | 307 | # "vehicleName": "", 308 | VEHICLENAME = ApiKey(key="vehicleName", type=EP_TYPE.LOADPOINTS, writeable=True, write_key = "vehicle") 309 | 310 | # "vehicleOdometer": 0, 311 | VEHICLEODOMETER = ApiKey(key="vehicleOdometer", type=EP_TYPE.LOADPOINTS) 312 | 313 | # "vehicleRange": 0, 314 | VEHICLERANGE = ApiKey(key="vehicleRange", type=EP_TYPE.LOADPOINTS) 315 | 316 | # "vehicleSoc": 0 317 | VEHICLESOC = ApiKey(key="vehicleSoc", type=EP_TYPE.LOADPOINTS) 318 | 319 | # "vehicleClimaterActive": null, 320 | VEHICLECLIMATERACTIVE = ApiKey(key="vehicleClimaterActive", type=EP_TYPE.LOADPOINTS) 321 | 322 | #"vehicleWelcomeActive": false 323 | VEHICLEWELCOMEACTIVE = ApiKey(key="vehicleWelcomeActive", type=EP_TYPE.LOADPOINTS) 324 | 325 | # "mode": "off", -> (off/pv/minpv/now) 326 | MODE = ApiKey( 327 | key="mode", type=EP_TYPE.LOADPOINTS, 328 | writeable=True, write_key="mode", options=["off", "pv", "minpv", "now"] 329 | ) 330 | # "limitSoc": 0, -> write 'limitsoc' in % 331 | LIMITSOC = ApiKey(key="limitSoc", type=EP_TYPE.LOADPOINTS, writeable=True, write_key="limitsoc") 332 | 333 | # "limitEnergy": 0, -> write 'limitenergy' limit energy in kWh 334 | LIMITENERGY = ApiKey(key="limitEnergy", type=EP_TYPE.LOADPOINTS, writeable=True, write_key="limitenergy") 335 | 336 | # "phasesConfigured": 0, -> write 'phases' -> allowed phases (0=auto/1=1p/3=3p) 337 | PHASES = ApiKey( 338 | key="phasesConfigured", type=EP_TYPE.LOADPOINTS, writeable=True, write_key="phases", options=["0", "1", "3"] 339 | ) 340 | 341 | # "minCurrent": 6, -> write 'mincurrent' current minCurrent value in A 342 | MINCURRENT = ApiKey( 343 | key="minCurrent", type=EP_TYPE.LOADPOINTS, writeable=True, write_key="mincurrent", options=MIN_CURRENT_LIST 344 | ) 345 | 346 | # "maxCurrent": 16, -> write 'maxcurrent' current maxCurrent value in A 347 | MAXCURRENT = ApiKey( 348 | key="maxCurrent", type=EP_TYPE.LOADPOINTS, writeable=True, write_key="maxcurrent", options=MAX_CURRENT_LIST 349 | ) 350 | 351 | # enable/disable BatteryBoost (per Loadpoint) 352 | BATTERYBOOST = ApiKey(key="batteryBoost", type=EP_TYPE.LOADPOINTS, writeable=True, write_key="batteryboost") 353 | 354 | # "disableThreshold": 0, -> write 'disable/threshold' (in W) 355 | DISABLETHRESHOLD = ApiKey( 356 | key="disableThreshold", type=EP_TYPE.LOADPOINTS, writeable=True, write_key="disable/threshold" 357 | ) 358 | 359 | # "enableThreshold": 0, -> write 'enable/threshold' (in W) 360 | ENABLETHRESHOLD = ApiKey( 361 | key="enableThreshold", type=EP_TYPE.LOADPOINTS, writeable=True, write_key="enable/threshold" 362 | ) 363 | 364 | # values can be written via SERVICE 365 | # "planEnergy": 0, 366 | PLANENERGY = ApiKey(key="planEnergy", type=EP_TYPE.LOADPOINTS) 367 | 368 | # "planTime": "0001-01-01T00:00:00Z", 369 | PLANTIME = ApiKey(key="planTime", type=EP_TYPE.LOADPOINTS) 370 | 371 | # delete plan button 372 | PLANDELETE = ApiKey(key="planDelete", type=EP_TYPE.LOADPOINTS, writeable=True, write_key="plan/energy") 373 | 374 | ################################### 375 | # VEHICLE 376 | ################################### 377 | 378 | # "vehicleLimitSoc": 0, -> write to vehicle EP! 379 | # even if we write to 'limitsoc' at the vehicle endpoint, the loadpoint[n]:vehicleLimitSoc values does not change ?! 380 | VEHICLELIMITSOC = ApiKey( 381 | key="limitSoc", type=EP_TYPE.VEHICLES, writeable=True, write_key="limitsoc", options=BATTERY_LIST 382 | ) 383 | VEHICLEMINSOC = ApiKey( 384 | key="minSoc", type=EP_TYPE.VEHICLES, writeable=True, write_key="minsoc", options=BATTERY_LIST[:-1] 385 | ) 386 | 387 | # values can be written via SERVICE 388 | VEHICLEPLANSSOC = ApiKey(key="vehiclePlansSoc", type=EP_TYPE.VEHICLES) 389 | VEHICLEPLANSTIME = ApiKey(key="vehiclePlansTime", type=EP_TYPE.VEHICLES) 390 | # delete plan button 391 | VEHICLEPLANSDELETE= ApiKey(key="vehiclePlansDelete", type=EP_TYPE.VEHICLES, writeable=True, write_key="plan/soc") 392 | 393 | ################################### 394 | # STATISTICS 395 | ################################### 396 | 397 | STATTOTALAVGCO2 = ApiKey(entity_key="statTotalAvgCo2", key="avgCo2", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_TOTAL) 398 | STATTOTALAVGPRICE = ApiKey(entity_key="statTotalAvgPrice", key="avgPrice", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_TOTAL) 399 | STATTOTALCHARGEDKWH = ApiKey(entity_key="statTotalChargedKWh", key="chargedKWh", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_TOTAL) 400 | STATTOTALSOLARPERCENTAGE = ApiKey(entity_key="statTotalSolarPercentage", key="solarPercentage", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_TOTAL) 401 | 402 | STATTHISYEARAVGCO2 = ApiKey(entity_key="statThisYearAvgCo2", key="avgCo2", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_THISYEAR) 403 | STATTHISYEARAVGPRICE = ApiKey(entity_key="statThisYearAvgPrice", key="avgPrice", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_THISYEAR) 404 | STATTHISYEARCHARGEDKWH = ApiKey(entity_key="statThisYearChargedKWh", key="chargedKWh", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_THISYEAR) 405 | STATTHISYEARSOLARPERCENTAGE = ApiKey(entity_key="statThisYearSolarPercentage", key="solarPercentage", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_THISYEAR) 406 | 407 | STAT365AVGCO2 = ApiKey(entity_key="stat365AvgCo2", key="avgCo2", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_365D) 408 | STAT365AVGPRICE = ApiKey(entity_key="stat365AvgPrice", key="avgPrice", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_365D) 409 | STAT365CHARGEDKWH = ApiKey(entity_key="stat365ChargedKWh", key="chargedKWh", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_365D) 410 | STAT365SOLARPERCENTAGE = ApiKey(entity_key="stat365SolarPercentage", key="solarPercentage", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_365D) 411 | 412 | STAT30AVGCO2 = ApiKey(entity_key="stat30AvgCo2", key="avgCo2", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_30D) 413 | STAT30AVGPRICE = ApiKey(entity_key="stat30AvgPrice", key="avgPrice", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_30D) 414 | STAT30CHARGEDKWH = ApiKey(entity_key="stat30ChargedKWh", key="chargedKWh", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_30D) 415 | STAT30SOLARPERCENTAGE = ApiKey(entity_key="stat30SolarPercentage", key="solarPercentage", type=EP_TYPE.STATISTICS, subtype=JSONKEY_STATISTICS_30D) 416 | 417 | TARIF_GRID = ApiKey(entity_key="tariff_api_grid", key="grid", type=EP_TYPE.TARIFF) 418 | TARIF_SOLAR = ApiKey(entity_key="tariff_api_solar", key="solar", type=EP_TYPE.TARIFF) 419 | 420 | CHARGING_SESSIONS = ApiKey(key="charging_sessions", type=EP_TYPE.SESSIONS) 421 | CHARGING_SESSIONS_VEHICLES = ApiKey(key="charging_sessions_vehicles", type=EP_TYPE.SESSIONS) 422 | CHARGING_SESSIONS_VEHICLE_COST = ApiKey(entity_key="charging_sessions_vehicle_cost", key="cost", type=EP_TYPE.SESSIONS, subtype=SESSIONS_KEY_VEHICLES) 423 | CHARGING_SESSIONS_VEHICLE_ENERGY = ApiKey(entity_key="charging_sessions_vehicle_chargedenergy", key="chargedEnergy", type=EP_TYPE.SESSIONS, subtype=SESSIONS_KEY_VEHICLES) 424 | CHARGING_SESSIONS_VEHICLE_DURATION = ApiKey(entity_key="charging_sessions_vehicle_chargeduration", key="chargeDuration", type=EP_TYPE.SESSIONS, subtype=SESSIONS_KEY_VEHICLES) 425 | 426 | CHARGING_SESSIONS_LOADPOINTS = ApiKey(key="charging_sessions_loadpoints", type=EP_TYPE.SESSIONS) 427 | CHARGING_SESSIONS_LOADPOINT_COST = ApiKey(entity_key="charging_sessions_loadpoint_cost", key="cost", type=EP_TYPE.SESSIONS, subtype=SESSIONS_KEY_LOADPOINTS) 428 | CHARGING_SESSIONS_LOADPOINT_ENERGY = ApiKey(entity_key="charging_sessions_loadpoint_chargedenergy", key="chargedEnergy", type=EP_TYPE.SESSIONS, subtype=SESSIONS_KEY_LOADPOINTS) 429 | CHARGING_SESSIONS_LOADPOINT_DURATION = ApiKey(entity_key="charging_sessions_loadpoint_chargeduration", key="chargeDuration", type=EP_TYPE.SESSIONS, subtype=SESSIONS_KEY_LOADPOINTS) 430 | -------------------------------------------------------------------------------- /custom_components/evcc_intg/select.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from homeassistant.components.select import SelectEntity 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 8 | 9 | from custom_components.evcc_intg.pyevcc_ha.const import MIN_CURRENT_LIST, MAX_CURRENT_LIST 10 | from custom_components.evcc_intg.pyevcc_ha.keys import Tag 11 | from . import EvccDataUpdateCoordinator, EvccBaseEntity 12 | from .const import DOMAIN, SELECT_SENSORS, SELECT_SENSORS_PER_LOADPOINT, ExtSelectEntityDescription 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | entities_min_max_dict = {} 16 | SOCS_TAG_LIST = [Tag.PRIORITYSOC, Tag.BUFFERSOC, Tag.BUFFERSTARTSOC] 17 | 18 | async def check_min_max(): 19 | _LOGGER.debug("SELECT scheduled min_max check") 20 | try: 21 | await asyncio.sleep(15) 22 | if entities_min_max_dict is not None: 23 | size = len(entities_min_max_dict) 24 | count = 1 25 | for a_entity in entities_min_max_dict.values(): 26 | a_entity.check_tag(size == count) 27 | count += 1 28 | 29 | _LOGGER.debug("SELECT init is COMPLETED") 30 | except BaseException as err: 31 | _LOGGER.warning(f"SELECT Error in check_min_max: {type(err)} {err}") 32 | 33 | 34 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, add_entity_cb: AddEntitiesCallback): 35 | _LOGGER.debug("SELECT async_setup_entry") 36 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 37 | 38 | global entities_min_max_dict 39 | entities_min_max_dict = {} 40 | 41 | entities = [] 42 | for description in SELECT_SENSORS: 43 | entity = EvccSelect(coordinator, description) 44 | entities.append(entity) 45 | if description.tag in SOCS_TAG_LIST: 46 | entities_min_max_dict[entity.entity_id.split('.')[1].lower()] = entity 47 | 48 | multi_loadpoint_config = len(coordinator._loadpoint) > 1 49 | for a_lp_key in coordinator._loadpoint: 50 | load_point_config = coordinator._loadpoint[a_lp_key] 51 | lp_api_index = int(a_lp_key) 52 | lp_id_addon = load_point_config["id"] 53 | lp_name_addon = load_point_config["name"] 54 | lp_has_phase_auto_option = load_point_config["has_phase_auto_option"] 55 | lp_is_heating = load_point_config["is_heating"] 56 | lp_is_integrated = load_point_config["is_integrated"] 57 | lp_is_single_phase_only = load_point_config["only_single_phase"] 58 | 59 | for a_stub in SELECT_SENSORS_PER_LOADPOINT: 60 | if (not lp_is_single_phase_only or a_stub.tag != Tag.PHASES) and (not lp_is_integrated or a_stub.integrated_supported): 61 | description = ExtSelectEntityDescription( 62 | tag=a_stub.tag, 63 | idx=lp_api_index, 64 | key=f"{lp_id_addon}_{a_stub.tag.key}", 65 | translation_key=a_stub.tag.key, 66 | name_addon=lp_name_addon if multi_loadpoint_config else None, 67 | icon=a_stub.icon, 68 | device_class=a_stub.device_class, 69 | unit_of_measurement=a_stub.unit_of_measurement, 70 | entity_category=a_stub.entity_category, 71 | entity_registry_enabled_default=a_stub.entity_registry_enabled_default, 72 | 73 | # the entity type specific values... 74 | options=["null"] + list(coordinator._vehicle.keys()) if a_stub.tag == Tag.VEHICLENAME else a_stub.tag.options, 75 | ) 76 | 77 | # we might need to patch(remove) the 'auto-mode' from the phases selector 78 | if a_stub.tag == Tag.PHASES and not lp_has_phase_auto_option: 79 | description.options = description.options[1:] 80 | description.translation_key = f"{description.translation_key}_fixed" 81 | 82 | entity = EvccSelect(coordinator, description) 83 | 84 | if entity.tag == Tag.MINCURRENT or entity.tag == Tag.MAXCURRENT: 85 | entities_min_max_dict[entity.entity_id.split('.')[1].lower()] = entity 86 | 87 | entities.append(entity) 88 | 89 | add_entity_cb(entities) 90 | asyncio.create_task(check_min_max()) 91 | 92 | 93 | class EvccSelect(EvccBaseEntity, SelectEntity): 94 | def __init__(self, coordinator: EvccDataUpdateCoordinator, description: ExtSelectEntityDescription): 95 | super().__init__(coordinator=coordinator, description=description) 96 | 97 | async def add_to_platform_finish(self) -> None: 98 | if self.tag == Tag.VEHICLENAME: 99 | 100 | has_pf_data = hasattr(self.platform, "platform_data") 101 | has_pf_trans = hasattr(self.platform.platform_data, "platform_translations") if has_pf_data else hasattr(self.platform, "platform_translations") 102 | has_pf_default_lang_trans = hasattr(self.platform.platform_data, "default_language_platform_translations") if has_pf_data else hasattr(self.platform, "default_language_platform_translations") 103 | 104 | # ok we're going to patch the display strings for the vehicle names... this is quite a HACK! 105 | for a_key in self.coordinator._vehicle.keys(): 106 | a_trans_key = f"component.{DOMAIN}.entity.select.{Tag.VEHICLENAME.key.lower()}.state.{a_key.lower()}" 107 | a_value = self.coordinator._vehicle[a_key]["name"] 108 | if has_pf_data: 109 | if has_pf_trans: 110 | self.platform.platform_data.platform_translations[a_trans_key] = a_value 111 | if has_pf_default_lang_trans: 112 | self.platform.platform_data.default_language_platform_translations[a_trans_key] = a_value 113 | else: 114 | # old HA compatible version... 115 | if has_pf_trans: 116 | self.platform.platform_translations[a_trans_key] = a_value 117 | if has_pf_default_lang_trans: 118 | self.platform.default_language_platform_translations[a_trans_key] = a_value 119 | 120 | #_LOGGER.debug(f"added vehicle-translation-key: evcc: '{a_key}' name: '{a_value}' key: {a_trans_key}") 121 | #_LOGGER.info(f"-> {self.platform.platform_data.platform_translations}") 122 | #_LOGGER.info("----------------") 123 | #_LOGGER.info(f"-> {self.platform.platform_data.default_language_platform_translations}") 124 | 125 | elif self.tag == Tag.VEHICLEMINSOC: 126 | #_LOGGER.error(f"{self.platform.platform_data.platform_translations}") 127 | pass 128 | 129 | await super().add_to_platform_finish() 130 | 131 | def check_tag(self, is_last: bool = False): 132 | try: 133 | if self.tag == Tag.MAXCURRENT: 134 | self._check_min_options(self.current_option) 135 | elif self.tag == Tag.MINCURRENT: 136 | self._check_max_options(self.current_option) 137 | elif self.tag in SOCS_TAG_LIST: 138 | self._check_socs(self.current_option) 139 | 140 | if is_last: 141 | if self.hass is not None: 142 | self.async_schedule_update_ha_state(force_refresh=True) 143 | else: 144 | _LOGGER.info("SELECT Skipping async_schedule_update_ha_state, since hass object is None?!") 145 | except BaseException as err: 146 | _LOGGER.debug(f"SELECT Error in check_tag for '{self.tag}' {self.entity_id} {type(err)} {err}") 147 | 148 | def _check_min_options(self, new_max_option: str): 149 | try: 150 | min_key = self.entity_id.split('.')[1].replace(Tag.MAXCURRENT.snake_case, Tag.MINCURRENT.snake_case) 151 | #_LOGGER.warning(f"CHECK_MIN {min_key} {entities_min_max_dict} {MIN_CURRENT_LIST} {entities_min_max_dict[min_key]}") 152 | if min_key in entities_min_max_dict: 153 | if new_max_option in MIN_CURRENT_LIST: 154 | entities_min_max_dict[min_key].options = MIN_CURRENT_LIST[:MIN_CURRENT_LIST.index(new_max_option) + 1] 155 | else: 156 | entities_min_max_dict[min_key].options = MIN_CURRENT_LIST 157 | 158 | except BaseException as err: 159 | _LOGGER.debug(f"SELECT Error _check_min_options for '{new_max_option}' {self.entity_id} {self.tag} {err}") 160 | 161 | def _check_max_options(self, new_min_option: str): 162 | try: 163 | max_key = self.entity_id.split('.')[1].replace(Tag.MINCURRENT.snake_case, Tag.MAXCURRENT.snake_case) 164 | #_LOGGER.warning(f"CHECK_MAX {max_key} {entities_min_max_dict} {MAX_CURRENT_LIST} {entities_min_max_dict[max_key]}") 165 | if max_key in entities_min_max_dict: 166 | if new_min_option in MAX_CURRENT_LIST: 167 | entities_min_max_dict[max_key].options = MAX_CURRENT_LIST[MAX_CURRENT_LIST.index(new_min_option):] 168 | else: 169 | entities_min_max_dict[max_key].options = MAX_CURRENT_LIST 170 | 171 | except BaseException as err: 172 | _LOGGER.debug(f"SELECT Error _check_max_options for '{new_min_option}' {self.entity_id} {self.tag} {err}") 173 | 174 | def _check_socs(self, option: str): 175 | try: 176 | changed_option = self.entity_id.split('.')[1].split('_') 177 | system_id = changed_option[0] 178 | changed_option_key = '_'.join(changed_option[1:]) 179 | 180 | #_LOGGER.warning(f"SOC CHECK: {system_id} {changed_option_key} {entities_min_max_dict}") 181 | 182 | # is 'Vehicle first' (BUFFERSOC) 183 | if changed_option_key == Tag.BUFFERSOC.snake_case: 184 | # we need to adjust the 'Support vehicle charging' (BUFFERSTARTSOC) options 185 | select = entities_min_max_dict[f"{system_id}_{Tag.BUFFERSTARTSOC.snake_case}"] 186 | if option in Tag.BUFFERSTARTSOC.options: 187 | select.options = Tag.BUFFERSTARTSOC.options[Tag.BUFFERSTARTSOC.options.index(option):] 188 | else: 189 | select.options = Tag.BUFFERSTARTSOC.options 190 | 191 | # we need to adjust the 'Home has priority' (PRIORITYSOC) options 192 | select = entities_min_max_dict[f"{system_id}_{Tag.PRIORITYSOC.snake_case}"] 193 | if int(option) > 0 and option in Tag.PRIORITYSOC.options: 194 | select.options = Tag.PRIORITYSOC.options[:Tag.PRIORITYSOC.options.index(option)+1] 195 | else: 196 | select.options = Tag.PRIORITYSOC.options 197 | 198 | # is 'Home has priority' (PRIORITYSOC) 199 | elif changed_option_key == Tag.PRIORITYSOC.snake_case: 200 | # we need to adjust the 'Vehicle first' (BUFFERSOC) options 201 | select = entities_min_max_dict[f"{system_id}_{Tag.BUFFERSOC.snake_case}"] 202 | if option in Tag.BUFFERSOC.options: 203 | select.options = Tag.BUFFERSOC.options[Tag.BUFFERSOC.options.index(option):] 204 | else: 205 | select.options = Tag.BUFFERSOC.options 206 | 207 | # is 'Support vehicle charging' (BUFFERSTARTSOC) 208 | elif changed_option_key == Tag.BUFFERSTARTSOC.snake_case: 209 | # we need to adjust the 'Vehicle first' (BUFFERSOC) options 210 | low_option = entities_min_max_dict[f"{system_id}_{Tag.PRIORITYSOC.snake_case}"].current_option 211 | select = entities_min_max_dict[f"{system_id}_{Tag.BUFFERSOC.snake_case}"] 212 | if int(option) > 0 and option in Tag.BUFFERSOC.options and low_option in Tag.BUFFERSOC.options: 213 | select.options = Tag.BUFFERSOC.options[Tag.BUFFERSOC.options.index(low_option):Tag.BUFFERSOC.options.index(option)+1] 214 | elif int(option) > 0 and option in Tag.BUFFERSOC.options: 215 | select.options = Tag.BUFFERSOC.options[:Tag.BUFFERSOC.options.index(option)+1] 216 | else: 217 | if low_option in Tag.BUFFERSOC.options: 218 | select.options = Tag.BUFFERSOC.options[Tag.BUFFERSOC.options.index(low_option):] 219 | else: 220 | select.options = Tag.BUFFERSOC.options 221 | 222 | except BaseException as err: 223 | _LOGGER.debug(f"SELECT Error _check_socs for '{option}' {self.entity_id} {self.tag} {err}") 224 | 225 | # def _on_vehicle_change(self, sel_vehicle_id: str): 226 | # if JSONKEY_VEHICLES in self.coordinator.data and sel_vehicle_id in self.coordinator.data[JSONKEY_VEHICLES]: 227 | # veh_dict = self.coordinator.data[JSONKEY_VEHICLES][sel_vehicle_id] 228 | # if Tag.VEHICLEMINSOC.key in veh_dict: 229 | # val_minsoc = veh_dict[Tag.VEHICLEMINSOC.key] 230 | # else: 231 | # val_minsoc = "0" 232 | # if Tag.VEHICLELIMITSOC.key in veh_dict: 233 | # val_limitsoc = veh_dict[Tag.VEHICLELIMITSOC.key] 234 | # else: 235 | # val_limitsoc = "0" 236 | 237 | @property 238 | def extra_state_attributes(self): 239 | """Return select attributes""" 240 | if Tag.VEHICLENAME == self.tag: 241 | a_key = self.current_option 242 | if isinstance(a_key, str) and a_key in self.coordinator._vehicle: 243 | return {"vehicle": self.coordinator._vehicle[a_key]} 244 | return None 245 | 246 | @property 247 | def current_option(self) -> str | None: 248 | try: 249 | value = self.coordinator.read_tag(self.tag, self.idx) 250 | 251 | # _LOGGER.error(f"{self.tag.key} {self.idx} {value}") 252 | 253 | if value is None or value == "": 254 | # we must patch an empty vehicle_id to 'null' to avoid the select option being set to 'unknown' 255 | if Tag.VEHICLENAME.key == self.tag.key: 256 | value = "null" 257 | else: 258 | value = 'unknown' 259 | if isinstance(value, (int, float)): 260 | value = str(value) 261 | 262 | #if self.tag == Tag.VEHICLENAME and isinstance(value, str): 263 | # # when we read from the API a value like 'db:12' we MUST convert it 264 | # # to our local format 'db_12' ... since HA can't handle the ':' 265 | # value = value.replace(':', '_') 266 | 267 | except KeyError as kerr: 268 | _LOGGER.debug(f"SELECT KeyError: '{self.tag}' '{self.idx}' {kerr}") 269 | value = "unknown" 270 | except TypeError as terr: 271 | _LOGGER.debug(f"SELECT TypeError: '{self.tag}' '{self.idx}' {terr}") 272 | value = None 273 | return value 274 | 275 | async def async_select_option(self, option: str) -> None: 276 | try: 277 | if "null" == str(option): 278 | await self.coordinator.async_write_tag(self.tag, None, self.idx, self) 279 | else: 280 | #if Tag.VEHICLENAME == self.tag: 281 | # # me must map the value selected in the select.options to the final value 282 | # # that is used in EVCC as identifier (can be a value like 'db:12') - but 283 | # # HA can't deal correctly with the ':' 284 | # if option in self.coordinator._vehicle: 285 | # option = self.coordinator._vehicle[option][EVCC_JSON_VEH_NAME] 286 | 287 | await self.coordinator.async_write_tag(self.tag, option, self.idx, self) 288 | 289 | if Tag.MAXCURRENT == self.tag: 290 | self._check_min_options(option) 291 | elif Tag.MINCURRENT == self.tag: 292 | self._check_max_options(option) 293 | elif SOCS_TAG_LIST == self.tag: 294 | self._check_socs(option) 295 | 296 | except ValueError: 297 | return "unavailable" 298 | -------------------------------------------------------------------------------- /custom_components/evcc_intg/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import replace 3 | from datetime import datetime, timezone 4 | from numbers import Number 5 | 6 | from homeassistant.components.sensor import SensorEntity, SensorDeviceClass 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import UnitOfTemperature 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | from homeassistant.helpers.restore_state import RestoreEntity 12 | 13 | from custom_components.evcc_intg.pyevcc_ha.keys import Tag, EP_TYPE, FORECAST_CONTENT 14 | from . import EvccDataUpdateCoordinator, EvccBaseEntity 15 | from .const import ( 16 | DOMAIN, 17 | SENSOR_SENSORS, 18 | SENSOR_SENSORS_GRID_AS_PREFIX, 19 | SENSOR_SENSORS_GRID_AS_OBJECT, 20 | SENSOR_SENSORS_PER_LOADPOINT, 21 | SENSOR_SENSORS_PER_VEHICLE, 22 | ExtSensorEntityDescription 23 | ) 24 | from .pyevcc_ha import SESSIONS_KEY_TOTAL 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, add_entity_cb: AddEntitiesCallback): 30 | _LOGGER.debug("SENSOR async_setup_entry") 31 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 32 | entities = [] 33 | for description in SENSOR_SENSORS: 34 | entity = EvccSensor(coordinator, description) 35 | entities.append(entity) 36 | 37 | # we need to check if the grid data (power & currents) is available as separate object... 38 | # or if it's still part of the main/site object (as gridPower, gridCurrents) 39 | if coordinator.grid_data_as_object: 40 | _LOGGER.debug("evcc 'grid' data is available as separate object") 41 | for description in SENSOR_SENSORS_GRID_AS_OBJECT: 42 | entity = EvccSensor(coordinator, description) 43 | entities.append(entity) 44 | else: 45 | _LOGGER.debug("evcc 'grid' as prefix") 46 | for description in SENSOR_SENSORS_GRID_AS_PREFIX: 47 | entity = EvccSensor(coordinator, description) 48 | entities.append(entity) 49 | 50 | multi_loadpoint_config = len(coordinator._loadpoint) > 1 #or len(coordinator._vehicle) > 1 51 | 52 | # loadpoint sensors... 53 | for a_lp_key in coordinator._loadpoint: 54 | load_point_config = coordinator._loadpoint[a_lp_key] 55 | lp_api_index = int(a_lp_key) 56 | lp_id_addon = load_point_config["id"] 57 | lp_name_addon = load_point_config["name"] 58 | lp_has_phase_auto_option = load_point_config["has_phase_auto_option"] 59 | lp_is_heating = load_point_config["is_heating"] 60 | lp_is_integrated = load_point_config["is_integrated"] 61 | 62 | for a_stub in SENSOR_SENSORS_PER_LOADPOINT: 63 | if not lp_is_integrated or a_stub.integrated_supported: 64 | # well - a hack to show any heating related loadpoints with temperature units... 65 | # note: this will not change the label (that still show 'SOC') 66 | force_celsius = lp_is_heating and ( 67 | a_stub.tag == Tag.EFFECTIVEPLANSOC or 68 | a_stub.tag == Tag.EFFECTIVELIMITSOC or 69 | a_stub.tag == Tag.VEHICLESOC or 70 | a_stub.tag == Tag.VEHICLEMINSOC or 71 | a_stub.tag == Tag.VEHICLELIMITSOC or 72 | a_stub.tag == Tag.VEHICLEPLANSSOC) 73 | 74 | description = ExtSensorEntityDescription( 75 | tag=a_stub.tag, 76 | idx=lp_api_index, 77 | key=f"{lp_id_addon}_{a_stub.tag.key}" if a_stub.array_idx is None else f"{lp_id_addon}_{a_stub.tag.key}_{a_stub.array_idx}", 78 | translation_key=a_stub.tag.key if a_stub.array_idx is None else f"{a_stub.tag.key}_{a_stub.array_idx}", 79 | name_addon=lp_name_addon if multi_loadpoint_config else None, 80 | icon=a_stub.icon, 81 | device_class=SensorDeviceClass.TEMPERATURE if force_celsius else a_stub.device_class, 82 | unit_of_measurement=UnitOfTemperature.CELSIUS if force_celsius else a_stub.unit_of_measurement, 83 | entity_category=a_stub.entity_category, 84 | entity_registry_enabled_default=a_stub.entity_registry_enabled_default, 85 | 86 | # the entity type specific values... 87 | state_class=a_stub.state_class, 88 | native_unit_of_measurement=UnitOfTemperature.CELSIUS if force_celsius else a_stub.native_unit_of_measurement, 89 | suggested_display_precision=a_stub.suggested_display_precision, 90 | array_idx=a_stub.array_idx, 91 | tuple_idx=a_stub.tuple_idx, 92 | factor=a_stub.factor, 93 | lookup=a_stub.lookup, 94 | ignore_zero=a_stub.ignore_zero 95 | ) 96 | 97 | # if it's a lookup value, we just patch the translation key... 98 | if a_stub.lookup is not None: 99 | description = replace( 100 | description, 101 | key = f"{description.key}_value", 102 | translation_key = f"{description.translation_key}_value" 103 | ) 104 | 105 | # for charging session sensor we must patch some additional stuff... 106 | if a_stub.tag.type == EP_TYPE.SESSIONS: 107 | if a_stub.tag.entity_key is not None: 108 | description = replace( 109 | description, 110 | key = f"cstotal_{description.key}", 111 | translation_key = a_stub.tag.entity_key, 112 | name_addon = lp_name_addon if multi_loadpoint_config else None, 113 | ) 114 | 115 | entity = EvccSensor(coordinator, description) 116 | entities.append(entity) 117 | 118 | # vehicle sensors... 119 | for a_vehicle_key in coordinator._vehicle: 120 | a_vehicle_obj = coordinator._vehicle[a_vehicle_key] 121 | veh_id_addon = a_vehicle_obj["id"] 122 | veh_name_addon = a_vehicle_obj["name"] 123 | 124 | for a_stub in SENSOR_SENSORS_PER_VEHICLE: 125 | description = ExtSensorEntityDescription( 126 | tag=a_stub.tag, 127 | key=f"{veh_id_addon}_{a_stub.tag.key}" if a_stub.array_idx is None else f"{veh_id_addon}_{a_stub.tag.key}_{a_stub.array_idx}", 128 | translation_key=a_stub.tag.key if a_stub.array_idx is None else f"{a_stub.tag.key}_{a_stub.array_idx}", 129 | name_addon=veh_name_addon if multi_loadpoint_config else None, 130 | icon=a_stub.icon, 131 | device_class=a_stub.device_class, 132 | unit_of_measurement=a_stub.unit_of_measurement, 133 | entity_category=a_stub.entity_category, 134 | entity_registry_enabled_default=a_stub.entity_registry_enabled_default, 135 | 136 | # the entity type specific values... 137 | state_class=a_stub.state_class, 138 | native_unit_of_measurement=a_stub.native_unit_of_measurement, 139 | suggested_display_precision=a_stub.suggested_display_precision, 140 | array_idx=a_stub.array_idx, 141 | tuple_idx=a_stub.tuple_idx, 142 | factor=a_stub.factor, 143 | lookup=a_stub.lookup, 144 | ignore_zero=a_stub.ignore_zero 145 | ) 146 | 147 | # if it's a lookup value, we just patch the translation key... 148 | if a_stub.lookup is not None: 149 | description = replace( 150 | description, 151 | key = f"{description.key}_value", 152 | translation_key = f"{description.translation_key}_value" 153 | ) 154 | 155 | # for charging session sensor we must patch some additional stuff... 156 | if a_stub.tag.type == EP_TYPE.SESSIONS: 157 | if a_stub.tag.entity_key is not None: 158 | description = replace( 159 | description, 160 | key = f"cstotal_{description.key}", 161 | translation_key = a_stub.tag.entity_key, 162 | name_addon = veh_name_addon 163 | ) 164 | 165 | entity = EvccSensor(coordinator, description) 166 | entities.append(entity) 167 | 168 | add_entity_cb(entities) 169 | 170 | 171 | class EvccSensor(EvccBaseEntity, SensorEntity, RestoreEntity): 172 | def __init__(self, coordinator: EvccDataUpdateCoordinator, description: ExtSensorEntityDescription): 173 | super().__init__(coordinator=coordinator, description=description) 174 | self._previous_float_value: float | None = None 175 | if self.tag.type == EP_TYPE.TARIFF or self.tag == Tag.FORECAST_GRID or self.tag == Tag.FORECAST_SOLAR: 176 | self._last_calculated_key = None 177 | self._last_calculated_value = None 178 | 179 | @property 180 | def extra_state_attributes(self): 181 | """Return sensor attributes""" 182 | if self.tag.type == EP_TYPE.SESSIONS: 183 | if self.tag.subtype is None: 184 | return self.coordinator.read_tag_sessions(self.tag) 185 | 186 | elif self.tag.type == EP_TYPE.TARIFF: 187 | a_dict = self.coordinator.read_tag_tariff(self.tag) 188 | if a_dict is not None and "rates" in a_dict: 189 | a_array = a_dict["rates"] 190 | if a_array is not None: 191 | a_array_without_end_values = [ 192 | { 193 | "start_utc": int(datetime.fromisoformat(entry["start"]).timestamp()), 194 | "value": round(entry["value"], 4) if not float(entry["value"]).is_integer() else entry["value"], 195 | } 196 | for entry in a_array 197 | ] 198 | return {"rates": a_array_without_end_values} 199 | else: 200 | return a_dict 201 | else: 202 | return a_dict 203 | 204 | elif self.tag == Tag.FORECAST_GRID or self.tag == Tag.FORECAST_SOLAR: 205 | data = self.coordinator.read_tag(self.tag) 206 | if data is not None: 207 | if self.tag == Tag.FORECAST_GRID and FORECAST_CONTENT.GRID.value in data: 208 | # thanks - with the extension to 1/4 h forcast data the content of evcc 209 | # is no longer storable in HA database (exceed maximum size of 16384 bytes) 210 | # so as workaround we throw away all 'end' values... 211 | a_array = data[FORECAST_CONTENT.GRID.value] 212 | if a_array is not None: 213 | a_array_without_end_values = [ 214 | { 215 | "start_utc": int(datetime.fromisoformat(entry["start"]).timestamp()), 216 | "value": round(entry["value"], 4) if not float(entry["value"]).is_integer() else entry["value"], 217 | } 218 | for entry in a_array 219 | ] 220 | return {"rates": a_array_without_end_values} 221 | else: 222 | return {"rates": a_array} 223 | 224 | elif self.tag == Tag.FORECAST_SOLAR and FORECAST_CONTENT.SOLAR.value in data: 225 | # wow - these are real vales from the evcc API: 226 | # "val": 102.91332846080002 227 | # how fucking useless this can be? We need even more precision... 228 | # let's round this to 4 digit 229 | 230 | a_object = data[FORECAST_CONTENT.SOLAR.value] 231 | if "timeseries" in a_object: 232 | a_array = a_object["timeseries"] 233 | if a_array is not None and "ts" in a_array[0]: 234 | rounded_array = [ 235 | { 236 | "ts_utc": int(datetime.fromisoformat(entry["ts"]).timestamp()), 237 | "val": round(entry["val"], 4) if not float(entry["val"]).is_integer() else entry["val"], 238 | } 239 | for entry in a_array 240 | ] 241 | a_object["timeseries"] = rounded_array 242 | else: 243 | # we have already rounded the data ?! 244 | pass 245 | 246 | return a_object 247 | 248 | #if self.tag == Tag.FORCAST_SOLAR and "timeseries" in data: 249 | # data = data["timeseries"] 250 | #_LOGGER.error(f"ATTR: {self.tag} - {data}") 251 | #return data 252 | return None 253 | 254 | def get_current_value_from_timeseries(self, data_list): 255 | if data_list is not None: 256 | current_time = datetime.now(timezone.utc) 257 | a_key = f"{current_time.hour}_{int(current_time.minute/15) if current_time.minute > 0 else 0}" 258 | if a_key != self._last_calculated_key: 259 | self._last_calculated_key = a_key 260 | for a_entry in data_list: 261 | if "start" in a_entry and "end" in a_entry: 262 | start_dt = datetime.fromisoformat(a_entry["start"]).astimezone(timezone.utc) 263 | end_dt = datetime.fromisoformat(a_entry["end"]).astimezone(timezone.utc) 264 | if start_dt < current_time < end_dt: 265 | if "value" in a_entry: 266 | self._last_calculated_value = a_entry["value"] 267 | break 268 | elif "price" in a_entry: 269 | self._last_calculated_value = a_entry["price"] 270 | break 271 | 272 | elif "ts" in a_entry or "ts_utc" in a_entry: 273 | if "ts_utc" in a_entry: 274 | timestamp_dt = datetime.fromtimestamp(a_entry["ts_utc"], tz=timezone.utc) 275 | else: 276 | timestamp_dt = datetime.fromisoformat(a_entry["ts"]).astimezone(timezone.utc) 277 | 278 | if (timestamp_dt.day == current_time.day and 279 | timestamp_dt.hour == current_time.hour and 280 | int(timestamp_dt.minute / 15) == int(current_time.minute / 15) 281 | ): 282 | if "val" in a_entry: 283 | self._last_calculated_value = a_entry["val"] 284 | break 285 | elif "value" in a_entry: 286 | self._last_calculated_value = a_entry["value"] 287 | break 288 | elif "price" in a_entry: 289 | self._last_calculated_value = a_entry["price"] 290 | return self._last_calculated_value 291 | return None 292 | 293 | @property 294 | def native_value(self): 295 | """Return the state of the sensor.""" 296 | if self.tag.type == EP_TYPE.SESSIONS: 297 | attr_data = self.coordinator.read_tag_sessions(self.tag, self._attr_name_addon) 298 | if attr_data is not None: 299 | if isinstance(attr_data, (dict, list)): 300 | if SESSIONS_KEY_TOTAL in attr_data: 301 | return attr_data[SESSIONS_KEY_TOTAL] 302 | 303 | return len(attr_data) 304 | 305 | if isinstance(attr_data, (Number, str)): 306 | return attr_data 307 | 308 | return None 309 | 310 | if self.tag.type == EP_TYPE.TARIFF: 311 | attr_data = self.coordinator.read_tag_tariff(self.tag) 312 | if attr_data is not None and "rates" in attr_data: 313 | data_list = attr_data["rates"] 314 | if data_list is not None: 315 | return self.get_current_value_from_timeseries(data_list) 316 | else: 317 | return None 318 | else: 319 | _LOGGER.debug(f"no tariff data found for {self.tag}") 320 | return None 321 | try: 322 | value = self.coordinator.read_tag(self.tag, self.idx) 323 | if hasattr(self.entity_description, "tuple_idx") and self.entity_description.tuple_idx is not None and len(self.entity_description.tuple_idx) > 1: 324 | array_idx1 = self.entity_description.tuple_idx[0] 325 | array_idx2 = self.entity_description.tuple_idx[1] 326 | try: 327 | value = value[array_idx1][array_idx2] 328 | except (IndexError, KeyError): 329 | _LOGGER.debug(f"index {array_idx1} or {array_idx2} not found in {value}") 330 | value = None 331 | 332 | elif hasattr(self.entity_description, "array_idx") and self.entity_description.array_idx is not None: 333 | array_idx = self.entity_description.array_idx 334 | try: 335 | value = value[array_idx] 336 | except (IndexError, KeyError): 337 | _LOGGER.debug(f"index {array_idx} not found in {value}") 338 | value = None 339 | 340 | if isinstance(value, (dict, list)): 341 | if self.tag == Tag.FORECAST_GRID: 342 | value = self.get_current_value_from_timeseries(value) 343 | elif self.tag == Tag.FORECAST_SOLAR: 344 | if "timeseries" in value: 345 | value = self.get_current_value_from_timeseries(value["timeseries"]) 346 | elif "today" in value and "energy" in value["today"]: 347 | value = value["today"]["energy"] 348 | else: 349 | value = None 350 | else: 351 | # if the value is a list (or dict), but could not be extracted (cause of none matching indices) we need 352 | # to purge the value to None! 353 | value = None 354 | 355 | if value is None or len(str(value)) == 0: 356 | value = None 357 | else: 358 | if self.entity_description.lookup is not None: 359 | if self.tag.key.lower() in self.coordinator.lang_map: 360 | value = self.coordinator.lang_map[self.tag.key.lower()][value] 361 | else: 362 | _LOGGER.warning(f"{self.tag.key} not found in translations") 363 | elif isinstance(value, bool): 364 | if value is True: 365 | value = "on" 366 | elif value is False: 367 | value = "off" 368 | else: 369 | # self.entity_description.lookup values are always 'strings' - so there we should not 370 | # have an additional 'factor' 371 | if self.entity_description.factor is not None: 372 | value = float(value)/self.entity_description.factor 373 | 374 | except (IndexError, ValueError, TypeError, KeyError) as err: 375 | _LOGGER.debug(f"tag: {self.tag} (idx: '{self.idx}') (value: '{value}') caused {err}") 376 | value = None 377 | 378 | # make sure that we do not return unknown or smaller values 379 | # [see https://github.com/marq24/ha-evcc/discussions/7] 380 | if self.tag == Tag.CHARGETOTALIMPORT: 381 | if value is None or value == "unknown": 382 | if self._previous_float_value is not None: 383 | return self._previous_float_value 384 | else: 385 | a_float_value = float(value) 386 | if self._previous_float_value is not None and a_float_value < self._previous_float_value: 387 | _LOGGER.debug(f"prev>new for key {self._attr_translation_key} [prev: '{self._previous_float_value}' new: '{a_float_value}']") 388 | return self._previous_float_value 389 | else: 390 | self._previous_float_value = a_float_value 391 | 392 | # make sure that we only return values > 0 393 | if self.entity_description.ignore_zero: 394 | isZeroVal = value is None or value == "unknown" or value <= 0.1 395 | 396 | if isZeroVal and self._previous_float_value is not None and self._previous_float_value > 0: 397 | value = self._previous_float_value 398 | elif value > 0: 399 | self._previous_float_value = value 400 | 401 | # final return statement... 402 | return value -------------------------------------------------------------------------------- /custom_components/evcc_intg/service.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | from homeassistant.core import ServiceCall 5 | 6 | _LOGGER: logging.Logger = logging.getLogger(__package__) 7 | 8 | 9 | class EvccService: 10 | def __init__(self, hass, config, coordinator): # pylint: disable=unused-argument 11 | """Initialize the sensor.""" 12 | self._hass = hass 13 | self._config = config 14 | self._coordinator = coordinator 15 | 16 | async def set_loadpoint_plan(self, call: ServiceCall): 17 | return await self.set_plan(call) 18 | 19 | async def set_vehicle_plan(self, call: ServiceCall): 20 | """Set vehicle plan directly by vehicle name/id.""" 21 | return await self.set_plan(call) 22 | 23 | async def set_plan(self, call: ServiceCall): 24 | # common for both... 25 | input_date_str = call.data.get("startdate", None) 26 | 27 | # vehicle plan data 28 | vehicle_name = call.data.get("vehicle", None) 29 | soc = call.data.get("soc", None) 30 | precondition = call.data.get("precondition", None) 31 | 32 | # loadpoint plan data 33 | loadpoint = call.data.get("loadpoint", None) 34 | energy = call.data.get("energy", None) 35 | 36 | if vehicle_name: 37 | # Get available vehicles... 38 | available_vehicles = list(self._coordinator._vehicle.keys()) 39 | _LOGGER.debug(f"Available vehicles: {available_vehicles}") 40 | else: 41 | available_vehicles = [] 42 | 43 | # Validate input 44 | if input_date_str is not None: 45 | try: 46 | # date is YYYY-MM-DD HH:MM.SSS -> need to convert it to a UTC based RFC3339 47 | start = datetime.datetime.strptime(input_date_str, "%Y-%m-%d %H:%M:%S") 48 | start = start.replace(second=0) 49 | start = start.astimezone(datetime.timezone.utc) 50 | rfc_date = start.isoformat(timespec="milliseconds").replace("+00:00", "Z") 51 | 52 | # Vehicle plan 53 | if vehicle_name is not None and vehicle_name in available_vehicles and isinstance(soc, int) and soc > 0: 54 | resp = await self._coordinator.async_write_plan(vehicle_name, None, str(int(soc)), rfc_date, precondition) 55 | 56 | # Loadpoint plan 57 | elif loadpoint is not None and isinstance(loadpoint, int) and isinstance(energy, int) and energy > 0: 58 | resp = await self._coordinator.async_write_plan(None, str(int(loadpoint)), str(int(energy)), rfc_date, None) 59 | 60 | else: 61 | resp = None 62 | 63 | if resp is not None and len(resp) > 0: 64 | if call.return_response: 65 | return { 66 | "success": "true", 67 | "date": str(datetime.datetime.now().time()), 68 | "response": resp 69 | } 70 | else: 71 | if call.return_response: 72 | return { 73 | "error": "NO or EMPTY response", 74 | "date": str(datetime.datetime.now().time()) 75 | } 76 | except ValueError as exc: 77 | if call.return_response: 78 | return { 79 | "error": str(exc), 80 | "date": str(datetime.datetime.now().time()) 81 | } 82 | else: 83 | if call.return_response: 84 | return { 85 | "error": "No date or false data provided", 86 | "date": str(datetime.datetime.now().time()) 87 | } 88 | 89 | 90 | async def del_loadpoint_plan(self, call: ServiceCall): 91 | return await self.del_plan(call) 92 | 93 | async def del_vehicle_plan(self, call: ServiceCall): 94 | return await self.del_plan(call) 95 | 96 | async def del_plan(self, call: ServiceCall): 97 | # vehicle plan data 98 | vehicle_name = call.data.get("vehicle", None) 99 | 100 | # loadpoint plan data 101 | loadpoint = call.data.get("loadpoint", None) 102 | 103 | if vehicle_name: 104 | # Get available vehicles... 105 | available_vehicles = list(self._coordinator._vehicle.keys()) 106 | _LOGGER.debug(f"Available vehicles: {available_vehicles}") 107 | else: 108 | available_vehicles = [] 109 | 110 | # Validate input 111 | if vehicle_name is not None or loadpoint is not None: 112 | try: 113 | 114 | # Vehicle plan 115 | if vehicle_name is not None and vehicle_name in available_vehicles: 116 | resp = await self._coordinator.async_delete_plan(vehicle_name, None) 117 | 118 | # Loadpoint plan 119 | elif loadpoint is not None and isinstance(loadpoint, int): 120 | resp = await self._coordinator.async_delete_plan(None, str(int(loadpoint))) 121 | 122 | else: 123 | resp = None 124 | 125 | if resp is not None and len(resp) > 0: 126 | if call.return_response: 127 | return { 128 | "success": "true", 129 | "date": str(datetime.datetime.now().time()), 130 | "response": resp 131 | } 132 | else: 133 | if call.return_response: 134 | return { 135 | "error": "NO or EMPTY response", 136 | "date": str(datetime.datetime.now().time()) 137 | } 138 | except ValueError as exc: 139 | if call.return_response: 140 | return { 141 | "error": str(exc), 142 | "date": str(datetime.datetime.now().time()) 143 | } 144 | else: 145 | if call.return_response: 146 | return { 147 | "error": "No date or false data provided", 148 | "date": str(datetime.datetime.now().time()) 149 | } 150 | -------------------------------------------------------------------------------- /custom_components/evcc_intg/services.yaml: -------------------------------------------------------------------------------- 1 | set_vehicle_plan: 2 | name: Set a departure plan for a vehicle [SOC (%)] 3 | description: Charging plan only works in solar mode. The configured CO₂ limit of NaN g will be ignored during this period. 4 | fields: 5 | # Key of the field 6 | startdate: 7 | name: Departure 8 | description: Please select a date in the future (seconds will be ignored) 9 | required: true 10 | selector: 11 | datetime: 12 | # Selector (https://www.home-assistant.io/docs/blueprint/selectors/) to control the input UI for this field 13 | vehicle: 14 | name: Vehicle Name/ID 15 | description: Select or enter vehicle name from your evcc configuration (vehicle_1 or db:7) 16 | required: true 17 | example: "db:7" 18 | selector: 19 | text: 20 | soc: 21 | name: Charging goal 22 | description: Target state of charge 23 | required: true 24 | default: 100 25 | selector: 26 | number: 27 | min: 5 28 | max: 100 29 | step: 5 30 | unit_of_measurement: "%" 31 | mode: slider 32 | precondition: 33 | name: Preconditioning in Seconds 34 | description: Duration for preconditioning in seconds (900s = 15min, 1200s = 20min, etc.) 35 | required: false 36 | default: 900 37 | selector: 38 | number: 39 | min: 900 40 | max: 14400 41 | step: 300 42 | unit_of_measurement: "s" 43 | mode: slider 44 | 45 | set_loadpoint_plan: 46 | name: Set a departure plan for a loadpoint [Energy (kWh)] 47 | description: Charging plan only works in solar mode. The configured CO₂ limit of NaN g will be ignored during this period. 48 | fields: 49 | # Key of the field 50 | startdate: 51 | name: Departure 52 | description: Please select a date in the future (seconds will be ignored) 53 | required: true 54 | selector: 55 | datetime: 56 | # Selector (https://www.home-assistant.io/docs/blueprint/selectors/) to control the input UI for this field 57 | loadpoint: 58 | name: Loadpoint ID 59 | description: A number starting from 1...n (where 1'st your first configured loadpoint) 60 | required: true 61 | default: 1 62 | selector: 63 | number: 64 | min: 1 65 | max: 10 66 | mode: box 67 | energy: 68 | name: Charging goal 69 | description: Target energy 70 | required: true 71 | default: 25 72 | selector: 73 | number: 74 | min: 1 75 | max: 200 76 | step: 1 77 | unit_of_measurement: "kWh" 78 | mode: box 79 | 80 | del_vehicle_plan: 81 | name: Delete a departure plan for a vehicle [SOC (%)] 82 | description: Delete a existing Charging plan for a Vehicle 83 | fields: 84 | vehicle: 85 | name: Vehicle Name/ID 86 | description: Select or enter vehicle name from your evcc configuration (vehicle_1 or db:7) 87 | required: true 88 | example: "db:7" 89 | selector: 90 | text: 91 | 92 | del_loadpoint_plan: 93 | name: Delete a departure plan for a loadpoint [Energy (kWh)] 94 | description: Delete a existing Charging plan for a Loadpoint 95 | fields: 96 | loadpoint: 97 | name: Loadpoint ID 98 | description: A number starting from 1...n (where 1'st your first configured loadpoint) 99 | required: true 100 | default: 1 101 | selector: 102 | number: 103 | min: 1 104 | max: 10 105 | mode: box -------------------------------------------------------------------------------- /custom_components/evcc_intg/switch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Literal 3 | 4 | from homeassistant.components.switch import SwitchEntity 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.const import STATE_ON, STATE_OFF 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 9 | from . import EvccDataUpdateCoordinator, EvccBaseEntity 10 | from .const import DOMAIN, SWITCH_SENSORS, SWITCH_SENSORS_PER_LOADPOINT, ExtSwitchEntityDescription 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, add_entity_cb: AddEntitiesCallback): 16 | _LOGGER.debug("SWITCH async_setup_entry") 17 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 18 | entities = [] 19 | for description in SWITCH_SENSORS: 20 | entity = EvccSwitch(coordinator, description) 21 | entities.append(entity) 22 | 23 | multi_loadpoint_config = len(coordinator._loadpoint) > 1 24 | for a_lp_key in coordinator._loadpoint: 25 | load_point_config = coordinator._loadpoint[a_lp_key] 26 | lp_api_index = int(a_lp_key) 27 | lp_id_addon = load_point_config["id"] 28 | lp_name_addon = load_point_config["name"] 29 | lp_has_phase_auto_option = load_point_config["has_phase_auto_option"] 30 | lp_is_heating = load_point_config["is_heating"] 31 | lp_is_integrated = load_point_config["is_integrated"] 32 | 33 | for a_stub in SWITCH_SENSORS_PER_LOADPOINT: 34 | if not lp_is_integrated or a_stub.integrated_supported: 35 | description = ExtSwitchEntityDescription( 36 | tag=a_stub.tag, 37 | idx=lp_api_index, 38 | key=f"{lp_id_addon}_{a_stub.tag.key}", 39 | translation_key=a_stub.tag.key, 40 | name_addon=lp_name_addon if multi_loadpoint_config else None, 41 | icon=a_stub.icon, 42 | device_class=a_stub.device_class, 43 | unit_of_measurement=a_stub.unit_of_measurement, 44 | entity_category=a_stub.entity_category, 45 | entity_registry_enabled_default=a_stub.entity_registry_enabled_default, 46 | 47 | # the entity type specific values... 48 | icon_off=a_stub.icon_off 49 | ) 50 | 51 | entity = EvccSwitch(coordinator, description) 52 | entities.append(entity) 53 | 54 | add_entity_cb(entities) 55 | 56 | 57 | class EvccSwitch(EvccBaseEntity, SwitchEntity): 58 | def __init__(self, coordinator: EvccDataUpdateCoordinator, description: ExtSwitchEntityDescription): 59 | super().__init__(coordinator=coordinator, description=description) 60 | self._attr_icon_off = self.entity_description.icon_off 61 | 62 | async def async_turn_on(self, **kwargs): 63 | """Turn on the switch.""" 64 | try: 65 | # cause of a minor bug in evcc, we need to write 1 instead of True 66 | await self.coordinator.async_write_tag(self.tag, 1, self.idx, self) 67 | except ValueError: 68 | return "unavailable" 69 | 70 | async def async_turn_off(self, **kwargs): 71 | """Turn off the switch.""" 72 | try: 73 | # cause of a minor bug in evcc, we need to write 0 instead of False 74 | await self.coordinator.async_write_tag(self.tag, 0, self.idx, self) 75 | except ValueError: 76 | return "unavailable" 77 | 78 | @property 79 | def is_on(self) -> bool | None: 80 | try: 81 | value = self.coordinator.read_tag(self.tag, self.idx) 82 | 83 | except KeyError: 84 | _LOGGER.info(f"is_on caused KeyError for: {self.tag.key}") 85 | value = None 86 | except TypeError: 87 | return None 88 | 89 | if not isinstance(value, bool): 90 | if isinstance(value, int): 91 | if value > 0: 92 | value = True 93 | else: 94 | value = False 95 | elif isinstance(value, str): 96 | # parse anything else then 'on' to False! 97 | if value.lower() == 'on': 98 | value = True 99 | else: 100 | value = False 101 | else: 102 | value = False 103 | 104 | return value 105 | 106 | @property 107 | def state(self) -> Literal["on", "off"] | None: 108 | """Return the state.""" 109 | if (is_on := self.is_on) is None: 110 | return None 111 | return STATE_ON if is_on else STATE_OFF 112 | 113 | @property 114 | def icon(self): 115 | """Return the icon of the sensor.""" 116 | if self._attr_icon_off is not None and self.state == STATE_OFF: 117 | return self._attr_icon_off 118 | else: 119 | return super().icon 120 | -------------------------------------------------------------------------------- /custom_components/evcc_intg/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Ger\u00e4t ist bereits konfiguriert", 5 | "reauth_successful": "Die erneute Authentifizierung war erfolgreich", 6 | "reconfigure_successful": "Die Neukonfiguration war erfolgreich" 7 | }, 8 | "step": { 9 | "user": { 10 | "description": "Wenn Du Hilfe bei der Einrichtung benötigst, findest du sie hier: https://github.com/marq24/ha-evcc.\n-- --\n### Du musst die Addresse Deines EVCC-Servers angeben und nicht die von Deinem Home-Assistent Server!\n-- --\nJeder der seine Home-Assistent Addresse einträgt (weil er das vielleicht so auf YouTube gesehen hat) und sich dann bei mir wegen dem *Fehler* meldet, möge bitte wahlweise in die USA oder nach Russland auswandern (Nordkorea wäre auch noch 'ne Option)!\n-- --\nAn alle anderen: bitte entschuldigt diesen Rant \uD83E\uDD2E - Seit **Monaten** bekomme ich immer wieder die gleiche Support-Anfrage von der Generation TikTok.", 11 | "data": { 12 | "host": "Deine lokale EVCC-Server Addresse (einschließlich des Ports)", 13 | "use_websocket": "Websocket verwenden - Aktualisierungsintervall wird ignoriert", 14 | "scan_interval": "Aktualisierungsintervall in Sekunden [min: 5sek]", 15 | "include_evcc": "Allen Namen der Sensoren den Präfix '[evcc]' voranstellen", 16 | "purge_all_devices": "Alle Geräte (Devices) Löschen und neu Erstellen" 17 | }, 18 | "data_description": { 19 | "purge_all_devices": "Dies kann notwendig werden, wenn Du verwaiste Geräte (Einträge) bei Dir in HA hast. Diese Einstellung wird automatisch zurückgesetzt." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth": "Unter dieser URL konnte Deine HA Instanz keinen EVCC-Server erreichen. **Bitte ließ doch noch einmal die Anleitung oben** Das Schwarze ist die Schrift!" 25 | } 26 | }, 27 | "services": { 28 | "set_loadpoint_plan": { 29 | "name": "Abfahrt Ladeplanung für einen Ladepunkt erstellen [Energie (kWh)]", 30 | "description": "Ladeplanung ist nur im PV-Modus aktiv. Die eingestellte CO₂-Grenze von NaN g wird in diesem Zeitraum ignoriert.", 31 | "fields": { 32 | "startdate": {"name": "Abfahrt", "description": "Bitte gib das Datum und die Uhrzeit Deiner geplanten Abreise an (die Sekunden werden ignoriert)"}, 33 | "loadpoint": {"name": "Ladepunkt ID", "description": "Eine Zahl beginnend mit 1...n (wobei 1 Dein erster konfigurierter Ladepunkt ist)"}, 34 | "energy": {"name": "Ladeziel", "description": "Ziel Energiemenge"} 35 | } 36 | }, 37 | "set_vehicle_plan": { 38 | "name": "Abfahrt Ladeplanung für ein Fahrzeug erstellen [SOC (%)]", 39 | "description": "Ladeplanung ist nur im PV-Modus aktiv. Die eingestellte CO₂-Grenze von NaN g wird in diesem Zeitraum ignoriert.", 40 | "fields": { 41 | "startdate": {"name": "Abfahrt", "description": "Bitte gib das Datum und die Uhrzeit Deiner geplanten Abreise an (die Sekunden werden ignoriert)"}, 42 | "vehicle": {"name": "Fahrzeugname/ID", "description": "Gib den Fahrzeugnamen aus Deiner evcc Konfiguration ein (z.B.: vehicle_1 oder db:7)"}, 43 | "soc": {"name": "Ladeziel", "description": "Ziel Ladezustand (SOC)"}, 44 | "precondition": {"name": "Batterie Vorkonditionierung", "description": "Dauer der Batterie Vorkonditionierung in Sekunden (900s = 15min, 1200s = 20min, etc.)"} 45 | } 46 | }, 47 | "del_loadpoint_plan": { 48 | "name": "Abfahrt Ladeplanung für einen Ladepunkt löschen [Energie (kWh)]", 49 | "description": "Löschen der Energie-basierten Ladeplanung für ein Ladepunkt", 50 | "fields": { 51 | "loadpoint": {"name": "Ladepunkt ID", "description": "Eine Zahl beginnend mit 1...n (wobei 1 Dein erster konfigurierter Ladepunkt ist)"} 52 | } 53 | }, 54 | "del_vehicle_plan": { 55 | "name": "Abfahrt Ladeplanung für ein Fahrzeug löschen [SOC (%)]", 56 | "description": "Löschen der SOC-basierten Ladeplanung für eine Fahrzeug", 57 | "fields": { 58 | "vehicle": {"name": "Fahrzeugname/ID", "description": "Gib den Fahrzeugnamen aus Deiner evcc Konfiguration ein (z.B.: vehicle_1 oder db:7)"} 59 | } 60 | } 61 | }, 62 | "entity": { 63 | "binary_sensor": { 64 | "charging": {"name": "Lädt"}, 65 | "connected": {"name": "Verbunden"}, 66 | "enabled": {"name": "Aktiviert"}, 67 | "smartcostactive": {"name": "Smartes Netzladen"}, 68 | "vehicledetectionactive": {"name": "Fahrzeugerkennung"}, 69 | "batterygridchargeactive": {"name": "Hausbatterie: Netzladen"}, 70 | "vehicleclimateractive": {"name": "Fahrzeug Klimatisierung"}, 71 | "vehiclewelcomeactive": {"name": "Fahrzeug Willkommensfunktion"}, 72 | "planactive": {"name": "Plan aktiviert"}, 73 | "planactivealt": {"name": "Plan aktiviert (alt)"} 74 | }, 75 | "button": { 76 | "vehicleplansdelete": {"name": "Ladeplanung: Abfahrt löschen (Fahrzeug/SOC)"}, 77 | "plandelete": {"name": "Ladeplanung: Abfahrt löschen (Ladepunkt/Energie)"}, 78 | "detectvehicle": {"name": "Fahrzeugerkennung starten"}, 79 | "smartcostlimit": {"name": "@@@ Limit entfernen"} 80 | }, 81 | "number": { 82 | "limitsoc": {"name": "Standard Ladelimit (SOC)"}, 83 | "limitenergy": {"name": "Standard Ladelimit (Energie)"}, 84 | "enablethreshold": {"name": "Schwellwert Aktivierung"}, 85 | "disablethreshold": {"name": "Schwellwert Deaktivierung"}, 86 | "residualpower": {"name": "Restleistung"}, 87 | "smartcostlimit_co2": {"name": "CO₂ Limit ≤"}, 88 | "smartcostlimit": {"name": "@@@ Limit ≤"}, 89 | "batterygridchargelimit": {"name": "Hausbatterie: Netzladen @@@ Limit ≤"}, 90 | "enabledelay": {"name": "Verzögerung Aktivierung"}, 91 | "disabledelay": {"name": "Verzögerung Deaktivierung"}, 92 | "priority": {"name": "Priorität"} 93 | }, 94 | "select": { 95 | "prioritysoc": { 96 | "name": "Hausbatterie: Haus hat Priorität", 97 | "state": { 98 | "0": "---", 99 | "5": "wenn unter 5 %", 100 | "10": "wenn unter 10 %", 101 | "15": "wenn unter 15 %", 102 | "20": "wenn unter 20 %", 103 | "25": "wenn unter 25 %", 104 | "30": "wenn unter 30 %", 105 | "35": "wenn unter 35 %", 106 | "40": "wenn unter 40 %", 107 | "45": "wenn unter 45 %", 108 | "50": "wenn unter 50 %", 109 | "55": "wenn unter 55 %", 110 | "60": "wenn unter 60 %", 111 | "65": "wenn unter 65 %", 112 | "70": "wenn unter 70 %", 113 | "75": "wenn unter 75 %", 114 | "80": "wenn unter 80 %", 115 | "85": "wenn unter 85 %", 116 | "90": "wenn unter 90 %", 117 | "95": "wenn unter 95 %", 118 | "100": "wenn unter 100 %" 119 | } 120 | }, 121 | "buffersoc": { 122 | "name": "Hausbatterie: Fahrzeug zuerst", 123 | "state": { 124 | "5": "wenn über 5 %", 125 | "10": "wenn über 10 %", 126 | "15": "wenn über 15 %", 127 | "20": "wenn über 20 %", 128 | "25": "wenn über 25 %", 129 | "30": "wenn über 30 %", 130 | "35": "wenn über 35 %", 131 | "40": "wenn über 40 %", 132 | "45": "wenn über 45 %", 133 | "50": "wenn über 50 %", 134 | "55": "wenn über 55 %", 135 | "60": "wenn über 60 %", 136 | "65": "wenn über 65 %", 137 | "70": "wenn über 70 %", 138 | "75": "wenn über 75 %", 139 | "80": "wenn über 80 %", 140 | "85": "wenn über 85 %", 141 | "90": "wenn über 90 %", 142 | "95": "wenn über 95 %", 143 | "100": "wenn auf 100 %" 144 | } 145 | }, 146 | "bufferstartsoc": { 147 | "name": "Hausbatterie: Fahrzeug Laden unterstützen", 148 | "state": { 149 | "5": "wenn über 5 %", 150 | "10": "wenn über 10 %", 151 | "15": "wenn über 15 %", 152 | "20": "wenn über 20 %", 153 | "25": "wenn über 25 %", 154 | "30": "wenn über 30 %", 155 | "35": "wenn über 35 %", 156 | "40": "wenn über 40 %", 157 | "45": "wenn über 45 %", 158 | "50": "wenn über 50 %", 159 | "55": "wenn über 55 %", 160 | "60": "wenn über 60 %", 161 | "65": "wenn über 65 %", 162 | "70": "wenn über 70 %", 163 | "75": "wenn über 75 %", 164 | "80": "wenn über 80 %", 165 | "85": "wenn über 85 %", 166 | "90": "wenn über 90 %", 167 | "95": "wenn über 95 %", 168 | "100": "wenn auf 100 %", 169 | "0": "nur mit genug PV-Überschuss laden" 170 | } 171 | }, 172 | "vehiclename": { 173 | "name": "Fahrzeug", 174 | "state": { 175 | "null": "-keins-" 176 | } 177 | }, 178 | "limitsoc": { 179 | "name": "Ladeplanung: Ankunft Standard Ladelimit" 180 | }, 181 | "minsoc": { 182 | "name": "Ladeplanung: Ankunft Min. Ladung %" 183 | }, 184 | "mode": { 185 | "name": "Modus", 186 | "state": { 187 | "off": "Aus", 188 | "pv": "PV", 189 | "minpv": "Min+PV", 190 | "now": "Schnell" 191 | } 192 | }, 193 | "phasesconfigured": { 194 | "name": "Ladestrom Phasen", 195 | "state": { 196 | "0": "automatischer Wechsel", 197 | "1": "1-phasig", 198 | "3": "3-phasig" 199 | } 200 | }, 201 | "phasesconfigured_fixed": { 202 | "name": "Ladestrom Phasen (Wie ist deine Wallbox angeschlossen?)", 203 | "state": { 204 | "1": "1-phasig", 205 | "3": "3-phasig" 206 | } 207 | }, 208 | "mincurrent": {"name": "Min. Ladestrom"}, 209 | "maxcurrent": {"name": "Max. Ladestrom"} 210 | }, 211 | "sensor": { 212 | "chargecurrent": {"name": "Ladestrom"}, 213 | "chargecurrents_0": {"name": "Ladestrom P1"}, 214 | "chargecurrents_1": {"name": "Ladestrom P2"}, 215 | "chargecurrents_2": {"name": "Ladestrom P3"}, 216 | "chargeduration": {"name": "Ladedauer"}, 217 | "chargeremainingduration": {"name": "Laderestzeit"}, 218 | "chargepower": {"name": "Ladeleistung"}, 219 | "chargetotalimport": {"name": "Netzbezug"}, 220 | "chargedenergy": {"name": "Ladeenergie"}, 221 | "chargeremainingenergy": {"name": "Ladeenergie verbleibend"}, 222 | 223 | "connectedduration": {"name": "Angeschlossen seit"}, 224 | 225 | "effectivelimitsoc": {"name": "Effektives Ladelimit (SOC)"}, 226 | 227 | "phaseaction": {"name": "Phasen Aktivität [CODE]"}, 228 | "phaseaction_value": {"name": "Phasen Aktivität"}, 229 | "phaseremaining": {"name": "Phasen verbleibend"}, 230 | "phasesactive": {"name": "Phasen in Verwendung"}, 231 | "phasesenabled": {"name": "Phasen aktiviert"}, 232 | 233 | "sessionco2perkwh": {"name": "Ladevorgang CO₂/kWh"}, 234 | "sessionenergy": {"name": "Ladevorgang Energie"}, 235 | "sessionprice": {"name": "Ladevorgang Preis"}, 236 | "sessionpriceperkwh": {"name": "Ladevorgang @@@/kWh"}, 237 | "sessionsolarpercentage": {"name": "Ladevorgang PV Verwendung"}, 238 | 239 | "vehicleodometer": {"name": "Fahrzeug Kilometerstand"}, 240 | "vehiclerange": {"name": "Fahrzeug Reichweite"}, 241 | "vehiclesoc": {"name": "Fahrzeug Ladestand"}, 242 | 243 | "vehicleplanssoc": {"name": "Ladeplanung: Abfahrt Ladeziel (SOC)"}, 244 | "vehicleplanstime": {"name": "Ladeplanung: Abfahrt Zeitpunkt (SOC)"}, 245 | "planenergy": {"name": "Ladeplanung: Abfahrt Ladeziel (Energie)"}, 246 | "plantime": {"name": "Ladeplanung: Abfahrt Zeitpunkt (Energie)"}, 247 | 248 | "effectiveplansoc": {"name": "Effektiver Plan: Abfahrt Ladeziel (SOC)"}, 249 | "effectiveplantime": {"name": "Effektiver Plan: Abfahrt Zeitpunkt (SOC)"}, 250 | "planprojectedstart": {"name": "Plan: Start (erwartet)"}, 251 | "planprojectedend": {"name": "Plan: Ende (erwartet)"}, 252 | 253 | "auxpower": {"name": "Leistung AUX"}, 254 | "batterycapacity": {"name": "Batterie Kapazität"}, 255 | "batterymode": {"name": "Batterie Modus [CODE]"}, 256 | "batterymode_value": {"name": "Batterie Modus"}, 257 | "batterypower": {"name": "Leistung Batterie"}, 258 | "battery_0_power": {"name": "Leistung Batterie 1"}, 259 | "battery_1_power": {"name": "Leistung Batterie 2"}, 260 | "battery_2_power": {"name": "Leistung Batterie 3"}, 261 | "battery_3_power": {"name": "Leistung Batterie 4"}, 262 | "batterysoc": {"name": "Batterie Ladezustand"}, 263 | "battery_0_soc": {"name": "Batterie Ladezustand 1"}, 264 | "battery_1_soc": {"name": "Batterie Ladezustand 2"}, 265 | "battery_2_soc": {"name": "Batterie Ladezustand 3"}, 266 | "battery_3_soc": {"name": "Batterie Ladezustand 4"}, 267 | "gridcurrents_0": {"name": "Netz Phase 1"}, 268 | "gridcurrents_1": {"name": "Netz Phase 2"}, 269 | "gridcurrents_2": {"name": "Netz Phase 3"}, 270 | "gridpower": {"name": "Leistung Netz"}, 271 | "homepower": {"name": "Leistung Heim"}, 272 | "pvpower": {"name": "Leistung PV"}, 273 | "pv_0_power": {"name": "Leistung PV 1"}, 274 | "pv_1_power": {"name": "Leistung PV 2"}, 275 | "pv_2_power": {"name": "Leistung PV 3"}, 276 | "pv_3_power": {"name": "Leistung PV 4"}, 277 | "pvenergy": {"name": "Energie PV"}, 278 | "pv_0_energy": {"name": "Energie PV 1"}, 279 | "pv_1_energy": {"name": "Energie PV 2"}, 280 | "pv_2_energy": {"name": "Energie PV 3"}, 281 | "pv_3_energy": {"name": "Energie PV 4"}, 282 | 283 | "tariffgrid": {"name": "Kosten Netz"}, 284 | "tariffpricehome": {"name": "Kosten Verbrauch"}, 285 | 286 | "stattotalsolarpercentage": {"name": "Statistik: gesamt Sonnenenergie"}, 287 | "stattotalchargedkwh": {"name": "Statistik: gesamt Ladeenergie"}, 288 | "stattotalavgprice": {"name": "Statistik: gesamt Ø Preis"}, 289 | "stattotalavgco2": {"name": "Statistik: gesamt Ø CO₂"}, 290 | "statthisyearsolarpercentage": {"name": "Statistik: dieses Jahr Sonnenenergie"}, 291 | "statthisyearchargedkwh": {"name": "Statistik: dieses Jahr Ladeenergie"}, 292 | "statthisyearavgprice": {"name": "Statistik: dieses Jahr Ø Preis"}, 293 | "statthisyearavgco2": {"name": "Statistik: dieses Jahr Ø CO₂"}, 294 | "stat365solarpercentage": {"name": "Statistik: letzten 365 Tage Sonnenenergie"}, 295 | "stat365chargedkwh": {"name": "Statistik: letzten 365 Tage Ladeenergie"}, 296 | "stat365avgprice": {"name": "Statistik: letzten 365 Tage Ø Preis"}, 297 | "stat365avgco2": {"name": "Statistik: letzten 365 Tage Ø CO₂"}, 298 | "stat30solarpercentage": {"name": "Statistik: letzten 30 Tage Sonnenenergie"}, 299 | "stat30chargedkwh": {"name": "Statistik: letzten 30 Tage Ladeenergie"}, 300 | "stat30avgprice": {"name": "Statistik: letzten 30 Tage Ø Preis"}, 301 | "stat30avgco2": {"name": "Statistik: letzten 30 Tage Ø CO₂"}, 302 | 303 | "tariff_api_solar": {"name": "Solar Prognose [Tariff-API]"}, 304 | "tariff_api_grid": {"name": "Strom Tarife [Tariff-API]"}, 305 | "forecast_solar": {"name": "Solar Prognose"}, 306 | "forecast_grid": {"name": "Strom Tarife"}, 307 | 308 | "pvaction": {"name": "PV Aktivität [CODE]"}, 309 | "pvaction_value": {"name": "PV Aktivität"}, 310 | "pvremaining": {"name": "PV verbleibend"}, 311 | 312 | "charging_sessions": {"name": "Ladevorgänge"}, 313 | "charging_sessions_vehicles": {"name": "Ladevorgänge: Anzahl Fahrzeuge"}, 314 | "charging_sessions_vehicle_cost": {"name": "Ladevorgänge: Kosten [Fzg.]"}, 315 | "charging_sessions_vehicle_chargedenergy": {"name": "Ladevorgänge: Energie [Fzg.]"}, 316 | "charging_sessions_vehicle_chargeduration": {"name": "Ladevorgänge: Dauer [Fzg.]"}, 317 | "charging_sessions_loadpoints": {"name": "Ladevorgänge: Anzahl Ladepunkte"}, 318 | "charging_sessions_loadpoint_cost": {"name": "Ladevorgänge: Kosten [LP]"}, 319 | "charging_sessions_loadpoint_chargedenergy": {"name": "Ladevorgänge: Energie [LP]"}, 320 | "charging_sessions_loadpoint_chargeduration": {"name": "Ladevorgänge: Dauer [LP]"} 321 | }, 322 | "switch": { 323 | "batterydischargecontrol": {"name": "Hausbatterie: Entladesperre"}, 324 | "batteryboost": {"name": "Schnell aus Hausbatterie laden."} 325 | } 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /custom_components/evcc_intg/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured", 5 | "reauth_successful": "Re-authentication was successful", 6 | "reconfigure_successful": "Re-configuration was successful" 7 | }, 8 | "step": { 9 | "user": { 10 | "description": "If you need help setting it up, you can find it here: https://github.com/marq24/ha-evcc.", 11 | "data": { 12 | "host": "Your local EVCC-Server URL (including the port)", 13 | "use_websocket": "Use Websocket - The Polling Interval will be ignored", 14 | "scan_interval": "Polling Interval in seconds [min: 5sec]", 15 | "include_evcc": "Include the prefix '[evcc]' in all sensor 'friendly names'", 16 | "purge_all_devices": "Remove an recreate all Devices" 17 | }, 18 | "data_description": { 19 | "purge_all_devices": "This may be necessary if you have orphaned device entries in your HA. This setting (checkbox) will be reset automatically." 20 | } 21 | } 22 | }, 23 | "error": { 24 | "auth": "You HA instance could not connect to an EVCC-Server under the specified URL." 25 | } 26 | }, 27 | "services": { 28 | "set_loadpoint_plan": { 29 | "name": "Set a departure plan for a loadpoint [Energy (kWh)]", 30 | "description": "Charging plan only works in solar mode. The configured CO₂ limit of NaN g will be ignored during this period.", 31 | "fields": { 32 | "startdate": {"name": "Departure", "description": "Please specify the date and time you want to depart (seconds will be ignored)"}, 33 | "loadpoint": {"name": "Loadpoint ID", "description": "A number starting from 1...n (where 1'st your first configured loadpoint)"}, 34 | "energy": {"name": "Charging goal", "description": "Target energy"} 35 | } 36 | }, 37 | "set_vehicle_plan": { 38 | "name": "Set a departure plan for a vehicle [SOC (%)]", 39 | "description": "Charging plan only works in solar mode. The configured CO₂ limit of NaN g will be ignored during this period.", 40 | "fields": { 41 | "startdate": {"name": "Departure", "description": "Please specify the date and time you want to depart (seconds will be ignored)"}, 42 | "vehicle": {"name": "Vehicle name/ID", "description": "Enter the vehicle name from your evcc configuration (e.g.: vehicle_1 or db:7)"}, 43 | "soc": {"name": "Charging goal", "description": "Target state of charge"}, 44 | "precondition": {"name": "Preconditioning in Seconds", "description": "Duration for preconditioning in seconds (900s = 15min, 1200s = 20min, etc.)"} 45 | } 46 | }, 47 | "del_loadpoint_plan": { 48 | "name": "Delete a departure plan for a loadpoint [Energy (kWh)]", 49 | "description": "Delete a existing Charging plan for a Loadpoint", 50 | "fields": { 51 | "loadpoint": {"name": "Loadpoint ID", "description": "A number starting from 1...n (where 1'st your first configured loadpoint)"} 52 | } 53 | }, 54 | "del_vehicle_plan": { 55 | "name": "Delete a departure plan for a vehicle [SOC (%)]", 56 | "description": "Delete a existing Charging plan for a Vehicle", 57 | "fields": { 58 | "vehicle": {"name": "Vehicle name/ID", "description": "Enter the vehicle name from your evcc configuration (e.g.: vehicle_1 or db:7)"} 59 | } 60 | } 61 | 62 | }, 63 | "entity": { 64 | "binary_sensor": { 65 | "charging": {"name": "Charging"}, 66 | "connected": {"name": "Connected"}, 67 | "enabled": {"name": "Enabled"}, 68 | "smartcostactive": {"name": "Smart Grid Charging"}, 69 | "vehicledetectionactive": {"name": "Vehicle detection"}, 70 | "batterygridchargeactive": {"name": "Home-Battery: grid charging"}, 71 | "vehicleclimateractive": {"name": "Vehicle Air Conditioning"}, 72 | "vehiclewelcomeactive": {"name": "Vehicle Welcome function"}, 73 | "planactive": {"name": "Plan activated"}, 74 | "planactivealt": {"name": "Plan activated (alt)"} 75 | }, 76 | "button": { 77 | "vehicleplansdelete": {"name": "Charging plan: Delete Departure (Vehicle/SOC)"}, 78 | "plandelete": {"name": "Charging plan: Delete Departure (Loadpoint/Energy)"}, 79 | "detectvehicle": {"name": "Start vehicle detection"}, 80 | "smartcostlimit": {"name": "@@@ Limit remove"} 81 | }, 82 | "number": { 83 | "limitsoc": {"name": "Default Charging limit (SOC)"}, 84 | "limitenergy": {"name": "Default Charging limit (Energy)"}, 85 | "enablethreshold": {"name": "Enable threshold"}, 86 | "disablethreshold": {"name": "Disable threshold"}, 87 | "residualpower": {"name": "Residual power"}, 88 | "smartcostlimit_co2": {"name": "CO₂ limit ≤"}, 89 | "smartcostlimit": {"name": "@@@ limit ≤"}, 90 | "batterygridchargelimit": {"name": "Home-Battery: grid charging @@@ limit ≤"}, 91 | "enabledelay": {"name": "Delay Enable"}, 92 | "disabledelay": {"name": "Delay Disable"}, 93 | "priority": {"name": "Priority"} 94 | }, 95 | "select": { 96 | "prioritysoc": { 97 | "name": "Home-Battery: Home has priority", 98 | "state": { 99 | "0": "---", 100 | "5": "if below 5 %", 101 | "10": "if below 10 %", 102 | "15": "if below 15 %", 103 | "20": "if below 20 %", 104 | "25": "if below 25 %", 105 | "30": "if below 30 %", 106 | "35": "if below 35 %", 107 | "40": "if below 40 %", 108 | "45": "if below 45 %", 109 | "50": "if below 50 %", 110 | "55": "if below 55 %", 111 | "60": "if below 60 %", 112 | "65": "if below 65 %", 113 | "70": "if below 70 %", 114 | "75": "if below 75 %", 115 | "80": "if below 80 %", 116 | "85": "if below 85 %", 117 | "90": "if below 90 %", 118 | "95": "if below 95 %", 119 | "100": "if below 100 %" 120 | } 121 | }, 122 | "buffersoc": { 123 | "name": "Home-Battery: Vehicle first", 124 | "state": { 125 | "5": "when above 5 %", 126 | "10": "when above 10 %", 127 | "15": "when above 15 %", 128 | "20": "when above 20 %", 129 | "25": "when above 25 %", 130 | "30": "when above 30 %", 131 | "35": "when above 35 %", 132 | "40": "when above 40 %", 133 | "45": "when above 45 %", 134 | "50": "when above 50 %", 135 | "55": "when above 55 %", 136 | "60": "when above 60 %", 137 | "65": "when above 65 %", 138 | "70": "when above 70 %", 139 | "75": "when above 75 %", 140 | "80": "when above 80 %", 141 | "85": "when above 85 %", 142 | "90": "when above 90 %", 143 | "95": "when above 95 %", 144 | "100": "when at 100 %" 145 | } 146 | }, 147 | "bufferstartsoc": { 148 | "name": "Home-Battery: Support vehicle charging", 149 | "state": { 150 | "5": "when above 5 %", 151 | "10": "when above 10 %", 152 | "15": "when above 15 %", 153 | "20": "when above 20 %", 154 | "25": "when above 25 %", 155 | "30": "when above 30 %", 156 | "35": "when above 35 %", 157 | "40": "when above 40 %", 158 | "45": "when above 45 %", 159 | "50": "when above 50 %", 160 | "55": "when above 55 %", 161 | "60": "when above 60 %", 162 | "65": "when above 65 %", 163 | "70": "when above 70 %", 164 | "75": "when above 75 %", 165 | "80": "when above 80 %", 166 | "85": "when above 85 %", 167 | "90": "when above 90 %", 168 | "95": "when above 95 %", 169 | "100": "when at 100 %", 170 | "0": "charge only with enough surplus" 171 | } 172 | }, 173 | "vehiclename": { 174 | "name": "Vehicle", 175 | "state": { 176 | "null": "-None-", 177 | "vehicle_1": "Vehicle 1", 178 | "vehicle_2": "Vehicle 2", 179 | "vehicle_3": "Vehicle 3", 180 | "vehicle_4": "Vehicle 4", 181 | "vehicle_5": "Vehicle 5", 182 | "vehicle_6": "Vehicle 6", 183 | "vehicle_7": "Vehicle 7", 184 | "vehicle_8": "Vehicle 8", 185 | "vehicle_9": "Vehicle 9", 186 | "vehicle_10": "Vehicle 10", 187 | "vehicle_11": "Vehicle 11", 188 | "vehicle_12": "Vehicle 12", 189 | "vehicle_13": "Vehicle 13", 190 | "vehicle_14": "Vehicle 14", 191 | "vehicle_15": "Vehicle 15", 192 | "vehicle_16": "Vehicle 16", 193 | "vehicle_17": "Vehicle 17", 194 | "vehicle_18": "Vehicle 18", 195 | "vehicle_19": "Vehicle 19", 196 | "vehicle_20": "Vehicle 20" 197 | } 198 | }, 199 | "limitsoc": { 200 | "name": "Charging plan: Arrival default limit", 201 | "state": { 202 | "0": "---", 203 | "5": "5 %", 204 | "10": "10 %", 205 | "15": "15 %", 206 | "20": "20 %", 207 | "25": "25 %", 208 | "30": "30 %", 209 | "35": "35 %", 210 | "40": "40 %", 211 | "45": "45 %", 212 | "50": "50 %", 213 | "55": "55 %", 214 | "60": "60 %", 215 | "65": "65 %", 216 | "70": "70 %", 217 | "75": "75 %", 218 | "80": "80 %", 219 | "85": "85 %", 220 | "90": "90 %", 221 | "95": "95 %", 222 | "100": "100 %" 223 | } 224 | }, 225 | "minsoc": { 226 | "name": "Charging plan: Arrival Min. charge %", 227 | "state": { 228 | "0": "---", 229 | "5": "5 %", 230 | "10": "10 %", 231 | "15": "15 %", 232 | "20": "20 %", 233 | "25": "25 %", 234 | "30": "30 %", 235 | "35": "35 %", 236 | "40": "40 %", 237 | "45": "45 %", 238 | "50": "50 %", 239 | "55": "55 %", 240 | "60": "60 %", 241 | "65": "65 %", 242 | "70": "70 %", 243 | "75": "75 %", 244 | "80": "80 %", 245 | "85": "85 %", 246 | "90": "90 %", 247 | "95": "95 %", 248 | "100": "100 %" 249 | } 250 | }, 251 | "mode": { 252 | "name": "Modus", 253 | "state": { 254 | "off": "Off", 255 | "pv": "Solar", 256 | "minpv": "Min+Solar", 257 | "now": "Fast" 258 | } 259 | }, 260 | "phasesconfigured": { 261 | "name": "Charging Current Phases", 262 | "state": { 263 | "0": "auto-switching", 264 | "1": "1 phase", 265 | "3": "3 phase" 266 | } 267 | }, 268 | "phasesconfigured_fixed": { 269 | "name": "Charging Current Phases (How is your charger connected?)", 270 | "state": { 271 | "1": "1 phase", 272 | "3": "3 phase" 273 | } 274 | }, 275 | "mincurrent": { 276 | "name": "Charging Current Min." 277 | }, 278 | "maxcurrent": { 279 | "name": "Charging Current Max." 280 | } 281 | }, 282 | "sensor": { 283 | "chargecurrent": {"name": "Charge current"}, 284 | "chargecurrents_0": {"name": "Charge current P1"}, 285 | "chargecurrents_1": {"name": "Charge current P2"}, 286 | "chargecurrents_2": {"name": "Charge current P3"}, 287 | "chargeduration": {"name": "Charge duration"}, 288 | "chargeremainingduration": {"name": "Charge remaining time"}, 289 | "chargepower": {"name": "Charge power"}, 290 | "chargetotalimport": {"name": "Grid imported"}, 291 | "chargedenergy": {"name": "Charge energy"}, 292 | "chargeremainingenergy": {"name": "Charge energy remaining"}, 293 | 294 | "connectedduration": {"name": "Connected duration"}, 295 | 296 | "effectivelimitsoc": {"name": "Effective Charging limit (SOC)"}, 297 | 298 | "phaseaction": {"name": "Phases activity [CODE]"}, 299 | "phaseaction_value": {"name": "Phases activity"}, 300 | "phaseremaining": {"name": "Phases remaining"}, 301 | "phasesactive": {"name": "Phases in use"}, 302 | "phasesenabled": {"name": "Phases activated"}, 303 | 304 | "sessionco2perkwh": {"name": "Session CO₂/kWh"}, 305 | "sessionenergy": {"name": "Session energy"}, 306 | "sessionprice": {"name": "Session price"}, 307 | "sessionpriceperkwh": {"name": "Session @@@/kWh"}, 308 | "sessionsolarpercentage": {"name": "Session Solar usage"}, 309 | 310 | "vehicleodometer": {"name": "Vehicle odometer"}, 311 | "vehiclerange": {"name": "Vehicle range"}, 312 | "vehiclesoc": {"name": "Vehicle charge"}, 313 | 314 | "vehicleplanssoc": {"name": "Charging plan: Departure charging goal (SOC)"}, 315 | "vehicleplanstime": {"name": "Charging plan: Departure time (SOC)"}, 316 | "planenergy": {"name": "Charging plan: Departure charging goal (Energy)"}, 317 | "plantime": {"name": "Charging plan: Departure time (Energy)"}, 318 | 319 | "effectiveplansoc": {"name": "Effective Plan: Departure charging goal (SOC)"}, 320 | "effectiveplantime": {"name": "Effective Plan: Departure time (SOC)"}, 321 | "planprojectedstart": {"name": "Plan: Start (projected)"}, 322 | "planprojectedend": {"name": "Plan: End (projected)"}, 323 | 324 | "auxpower": {"name": "Power AUX"}, 325 | "batterycapacity": {"name": "Battery capacity"}, 326 | "batterymode": {"name": "Battery mode [CODE]"}, 327 | "batterymode_value": {"name": "Battery mode"}, 328 | "batterypower": {"name": "Power battery"}, 329 | "battery_0_power": {"name": "Power Battery 1"}, 330 | "battery_1_power": {"name": "Power Battery 2"}, 331 | "battery_2_power": {"name": "Power Battery 3"}, 332 | "battery_3_power": {"name": "Power Battery 4"}, 333 | "batterysoc": {"name": "Battery SOC"}, 334 | "battery_0_soc": {"name": "Battery SOC 1"}, 335 | "battery_1_soc": {"name": "Battery SOC 2"}, 336 | "battery_2_soc": {"name": "Battery SOC 3"}, 337 | "battery_3_soc": {"name": "Battery SOC 4"}, 338 | "gridcurrents_0": {"name": "Grid Phase 1"}, 339 | "gridcurrents_1": {"name": "Grid Phase 2"}, 340 | "gridcurrents_2": {"name": "Grid Phase 3"}, 341 | "gridpower": {"name": "Power Grid"}, 342 | "homepower": {"name": "Power Home"}, 343 | "pvpower": {"name": "Power Solar"}, 344 | "pv_0_power": {"name": "Power Solar 1"}, 345 | "pv_1_power": {"name": "Power Solar 2"}, 346 | "pv_2_power": {"name": "Power Solar 3"}, 347 | "pv_3_power": {"name": "Power Solar 4"}, 348 | "pvenergy": {"name": "Energy Solar"}, 349 | "pv_0_energy": {"name": "Energy Solar 1"}, 350 | "pv_1_energy": {"name": "Energy Solar 2"}, 351 | "pv_2_energy": {"name": "Energy Solar 3"}, 352 | "pv_3_energy": {"name": "Energy Solar 4"}, 353 | 354 | "tariffgrid": {"name": "Costs Grid"}, 355 | "tariffpricehome": {"name": "Costs Usage"}, 356 | 357 | "stattotalsolarpercentage": {"name": "Statistics: Total Solar"}, 358 | "stattotalchargedkwh": {"name": "Statistics: Total Charge energy"}, 359 | "stattotalavgprice": {"name": "Statistics: Total Ø Price"}, 360 | "stattotalavgco2": {"name": "Statistics: Total Ø CO₂"}, 361 | "statthisyearsolarpercentage": {"name": "Statistics: This year Solar"}, 362 | "statthisyearchargedkwh": {"name": "Statistics: This year Charge energy"}, 363 | "statthisyearavgprice": {"name": "Statistics: This year Ø Price"}, 364 | "statthisyearavgco2": {"name": "Statistics: This year Ø CO₂"}, 365 | "stat365solarpercentage": {"name": "Statistics: Last 365 days Solar"}, 366 | "stat365chargedkwh": {"name": "Statistics: Last 365 days Charge energy"}, 367 | "stat365avgprice": {"name": "Statistics: Last 365 days Ø Price"}, 368 | "stat365avgco2": {"name": "Statistics: Last 365 days Ø CO₂"}, 369 | "stat30solarpercentage": {"name": "Statistics: Last 30 days Solar"}, 370 | "stat30chargedkwh": {"name": "Statistics: Last 30 days Charge energy"}, 371 | "stat30avgprice": {"name": "Statistics: Last 30 days Ø Price"}, 372 | "stat30avgco2": {"name": "Statistics: Last 30 days Ø CO₂"}, 373 | 374 | "tariff_api_solar": {"name": "Solar Prognoses [Tariff-API]"}, 375 | "tariff_api_grid": {"name": "Grid Tariffs [Tariff-API]"}, 376 | "forecast_solar": {"name": "Solar Prognoses"}, 377 | "forecast_grid": {"name": "Grid Tariffs"}, 378 | 379 | "pvaction": {"name": "PV Action [CODE]"}, 380 | "pvaction_value": {"name": "PV Action"}, 381 | "pvremaining": {"name": "PV Remaining"}, 382 | 383 | "charging_sessions": {"name": "Charging Sessions"}, 384 | "charging_sessions_vehicles": {"name": "Charging Sessions: #Vehicles"}, 385 | "charging_sessions_vehicle_cost": {"name": "Charging Sessions: Costs [Veh]"}, 386 | "charging_sessions_vehicle_chargedenergy": {"name": "Charging Sessions: Energy [Veh]"}, 387 | "charging_sessions_vehicle_chargeduration": {"name": "Charging Sessions: Duration [Veh]"}, 388 | "charging_sessions_loadpoints": {"name": "Charging Sessions: #Loadpoints"}, 389 | "charging_sessions_loadpoint_cost": {"name": "Charging Sessions: Costs [LP]"}, 390 | "charging_sessions_loadpoint_chargedenergy": {"name": "Charging Sessions: Energy [LP]"}, 391 | "charging_sessions_loadpoint_chargeduration": {"name": "Charging Sessions: Duration [LP]"} 392 | }, 393 | "switch": { 394 | "batterydischargecontrol": {"name": "Home-Battery: discharge lock"}, 395 | "batteryboost": {"name": "Boost from Home-Battery"} 396 | } 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /evcc-with-integrated-devices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marq24/ha-evcc/caa3ba30739446b4069e6b735fae96d9f06e6464/evcc-with-integrated-devices.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "evcc☀\uFE0F\uD83D\uDE98- Solar Charging", 3 | "homeassistant": "2023.8.0", 4 | "hacs": "1.18.0", 5 | "render_readme": true 6 | } 7 | -------------------------------------------------------------------------------- /logo-ha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marq24/ha-evcc/caa3ba30739446b4069e6b735fae96d9f06e6464/logo-ha.png -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marq24/ha-evcc/caa3ba30739446b4069e6b735fae96d9f06e6464/logo.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | homeassistant>=2024.8.2 2 | aiohttp~=3.11.11 3 | voluptuous~=0.15.2 4 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pytest-homeassistant-custom-component>=0.13.154 2 | -------------------------------------------------------------------------------- /sample-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marq24/ha-evcc/caa3ba30739446b4069e6b735fae96d9f06e6464/sample-dashboard.png --------------------------------------------------------------------------------