├── PV_Excess_Control ├── README.md ├── blueprints │ └── automation │ │ └── pv_excess_control.yaml └── pyscript │ └── pv_excess_control.py └── README.md /PV_Excess_Control/README.md: -------------------------------------------------------------------------------- 1 | # PV Excess Control 2 | Automatically control your appliances (wallbox, heatpump, washing machine, ...) based on excess solar power. 3 | 4 | If you like my work, you can support me here:\ 5 | [Buy me a coffee](https://buymeacoffee.com/henrikIC) 6 | 7 | ## Features 8 | :white_check_mark: Works with hybrid and standard inverters\ 9 | :white_check_mark: Configurable priority handling between multiple appliances\ 10 | :white_check_mark: Include solar forecasts from **Solcast** to ensure your home battery is charged to a specific level at the end of the day\ 11 | :white_check_mark: Define an *On/Off switch interval* / solar power averaging interval\ 12 | :white_check_mark: Supports dynamic current control (e.g. for wallboxes)\ 13 | :white_check_mark: Define min. and max. current for appliances supporting dynamic current control\ 14 | :white_check_mark: Supports one- and three-phase appliances\ 15 | :white_check_mark: Supports *Only-Switch-On* devices like washing machines or dishwashers 16 | 17 | 18 | ## Prerequisites 19 | - A working installation of [pyscript](https://github.com/custom-components/pyscript) (can be installed via [HACS](https://hacs.xyz/)) 20 | - (*Optional:* A working installation of solcast (can be installed via [HACS custom repository](https://github.com/oziee/ha-solcast-solar)) 21 | - Home Assistant v2023.1 or greater 22 | - Access to the following values from your hybrid PV inverter: 23 | - Export power 24 | - PV Power 25 | - Load Power 26 | - Home battery level 27 | - OR: Access to the following values from your standard inverter: 28 | - Combined import/export power 29 | - PV Power 30 | - Pyscript must be configured to allow all imports. This can be done 31 | - either via UI: 32 | - Configuration -> Integrations page -> “+” -> Pyscript Python scripting 33 | - After that, you can change the settings anytime by selecting Options under Pyscript in the Configuration page 34 | - or via *`configuration.yaml`*: 35 | ``` 36 | pyscript: 37 | allow_all_imports: true 38 | ``` 39 | 40 | ## Installation 41 | - Download (or clone) this GitHub repository 42 | - Copy both folders (*blueprints* and *pyscript*) to your HA config directory, or manually place the automation blueprint **`pv_excess_control.yaml`** and the python module **`pv_excess_control.py`** into their respective folders. 43 | - Configure the desired logging level in your *`configuration.yaml`*: 44 | ``` 45 | logger: 46 | logs: 47 | custom_components.pyscript.file.pv_excess_control: debug 48 | ``` 49 | 50 | ## Configuration & Usage 51 | ### Initial Configuration 52 | - For each appliance which should be controlled, create a new automation based on the *PV Excess Control* blueprint 53 | - After creating the automation, manually execute it once. This will send the chosen configuration parameters and sensors to the python module and start the optimizer in the background 54 | - The python module stays active in background, even if HA or the complete system is restarted 55 | 56 | ### Update 57 | - To update the configuration, simply update the chosen parameters and values in your automation, which was created based on the blueprint. 58 | - After that, manually execute the automation once to send the changes to the python module 59 | 60 | ### Deactivation 61 | - To deactivate the auto-control of a single appliance, simply deactivate the related automation. 62 | 63 | ### Deletion 64 | - To remove the auto-control of a single appliance, simply delete the related automation. -------------------------------------------------------------------------------- /PV_Excess_Control/blueprints/automation/pv_excess_control.yaml: -------------------------------------------------------------------------------- 1 | blueprint: 2 | name: INVENTOCASA - PV Excess Optimizer 3 | description: > 4 | ### **PV EXCESS OPTIMIZER** 5 | 6 | Automatically control your appliances based on excess power from your solar system

7 | → Remember to read the **[README](https://github.com/InventoCasa/ha-advanced-blueprints/blob/main/PV_Excess_Control/README.md)** for prerequisites and installation instructions 8 | 9 | → If you need help, head over to the [thread in the HA community forum](https://community.home-assistant.io/t/pv-solar-excess-optimizer-auto-control-appliances-wallbox-dish-washer-heatpump-based-on-excess-solar-power/552677) 10 | 11 | → Bugs and feature requests can be created directly on the [GitHub repository](https://github.com/InventoCasa/ha-advanced-blueprints) 12 | 13 | 14 | → If you want to say *thank you* for this blueprint, you can do so by buying me a [*virtual coffee*](https://www.buymeacoffee.com/henrikIC) 15 | 16 |

17 | 18 | domain: automation 19 | input: 20 | automation_id: 21 | name: "Automation Entity ID" 22 | description: > 23 | **[IMPORTANT]** 24 | 25 | **This field must contain the *entity_id* you defined for this automation. 26 | 27 | The entity_id must be unique! 28 | 29 | You can check your automation entity_id by clicking 30 | on the three dots in the top right corner of your automation editor and selecting *info* --> *settings*** 31 | selector: 32 | text: 33 | appliance_priority: 34 | name: "Appliance priority" 35 | description: "Appliances with a higher number are prioritized compared to appliances with a lower number.\n\n 36 | If the priority is greater than 1000 this appliance will be switched on, even if the excess power is not 37 | sufficient for 100% of the needed power" 38 | default: 1 39 | selector: 40 | number: 41 | min: 1 42 | max: 2000 43 | mode: box 44 | unit_of_measurement: "Priority level" 45 | 46 | 47 | grid_voltage: 48 | name: "Mains Voltage" 49 | description: > 50 | The voltage of your household electrical grid (**not** your appliance!). 51 | 52 | Typically 230V for most of EU, 110V for US. 53 | 54 | 55 | 56 | **[WARNING]** 57 | 58 | - **This value must be the same for all your created automations based on this blueprint!** 59 | default: 230 60 | selector: 61 | number: 62 | min: 110 63 | max: 240 64 | mode: box 65 | unit_of_measurement: "V" 66 | 67 | pv_power: 68 | name: "PV Power" 69 | description: > 70 | Sensor which contains your current **PV generated power** in watts. 71 | 72 | Must not be negative! For best results, this sensor should be updated at least every minute. 73 | 74 | 75 | **[WARNING]** 76 | 77 | - **This sensor must be the same for all your created automations based on this blueprint!** 78 | selector: 79 | entity: 80 | domain: sensor 81 | multiple: false 82 | export_power: 83 | name: "Export Power" 84 | description: > 85 | Sensor which contains your current **export power to the grid** in watts. 86 | Must not be negative! For best results, this sensor should be updated at least every minute. 87 | 88 | 89 | **[WARNING]** 90 | 91 | - **This sensor must be the same for all your created automations based on this blueprint!** 92 | 93 | - **This sensor must always be provided together with the *load power* sensor.** 94 | 95 | 96 | **[NOTE]** 97 | 98 | - If you can't measure this value directly, leave this field empty and provide a combined import/export power sensor. 99 | default: 100 | selector: 101 | entity: 102 | domain: sensor 103 | multiple: false 104 | 105 | load_power: 106 | name: "Load Power" 107 | description: > 108 | Sensor which contains your current **load power** (*combined household appliance consumption without home battery charging consumption*) in watts. Must not be negative! 109 | 110 | For best results, this sensor should be updated at least every minute. 111 | 112 | 113 | **[WARNING]** 114 | 115 | - **This sensor must be the same for all your created automations based on this blueprint!** 116 | 117 | - **This sensor must always be provided together with the *export power* sensor.** 118 | 119 | 120 | **[NOTE]** 121 | 122 | - If you can't measure this value directly, leave this field empty and provide a combined import/export power sensor. 123 | 124 | - The combined household appliance consumption will always be prioritized, meaning that if you e.g. turn on your electric oven, and as a result the excess power is near zero, 125 | your appliance with the lowest priority will be switched off (according to its On/Off switch interval). 126 | default: 127 | selector: 128 | entity: 129 | domain: sensor 130 | multiple: false 131 | 132 | import_export_power: 133 | name: "Combined Import/Export Power" 134 | description: > 135 | Sensor which contains **both**, your current **import power from the grid** (*positive* values) and your current **export power to the grid** in watts (*negative* values). 136 | 137 | For best results, this sensor should be updated at least every minute. 138 | 139 | 140 | **[WARNING]** 141 | 142 | - **This sensor must be the same for all your created automations based on this blueprint!** 143 | 144 | - **This sensor may only be specified when you cannot provide both the *Export Power* and *Load Power* sensor! This is normally the case when you have a standard inverter without battery.** 145 | 146 | - **Do not use this sensor when you have a hybrid inverter with battery. Otherwise the script cannot detect when your battery is discharging to compensate for a load!** 147 | 148 | 149 | **[NOTE]** 150 | 151 | - This is typically the value your household energy meter shows. 152 | default: 153 | selector: 154 | entity: 155 | domain: sensor 156 | multiple: false 157 | 158 | 159 | home_battery_level: 160 | name: "Home battery level" 161 | description: > 162 | Sensor which represents the **charge level of your home battery** (in percent) 163 | 164 | 165 | **[WARNING]** 166 | 167 | - **This sensor must be the same for all your created automations based on this blueprint!** 168 | 169 | 170 | **[NOTE]** 171 | 172 | - If your solar system is not coupled with a battery, leave this field empty 173 | default: 174 | selector: 175 | entity: 176 | domain: sensor 177 | multiple: false 178 | 179 | min_home_battery_level: 180 | name: "Minimum home battery level (end of day)" 181 | description: > 182 | Minimum desired power level (in percent) of your home battery (end of day) 183 | 184 | 185 | **[WARNING]** 186 | 187 | - **This sensor must be the same for all your created automations based on this blueprint!** 188 | 189 | 190 | **[NOTE]** 191 | 192 | - If your solar system is not coupled with a battery, this field will be ignored. 193 | 194 | - *If you also specify **solar production forecast***, the script will optimize your PV excess consumption right away and ensure that the specified *minimum home battery level* is reached at the **end of the day**. 195 | 196 | - *If you **do not** specify solar production forecast*, the home battery will be charged to the specified level *before* switching on appliances. 197 | default: 100 198 | selector: 199 | number: 200 | min: 0 201 | max: 100 202 | step: 5 203 | unit_of_measurement: "%" 204 | 205 | home_battery_capacity: 206 | name: "Home battery capacity" 207 | description: > 208 | The usable capacity (in kWh) of your home battery 209 | 210 | 211 | **[WARNING]** 212 | 213 | - **This sensor must be the same for all your created automations based on this blueprint!** 214 | 215 | 216 | **[NOTE]** 217 | 218 | - If your solar system is not coupled with a battery, this field will be ignored. 219 | default: 0 220 | selector: 221 | number: 222 | min: 0 223 | max: 60 224 | step: 0.5 225 | unit_of_measurement: kWh 226 | 227 | solar_production_forecast: 228 | name: "*Remaining* solar production forecast (Solcast)" 229 | description: > 230 | Sensor which represents the **remaining solar production forecast** for the current day (in kWh). Will be used to ensure the specified *minimum home battery level* is reached at the end of the day. 231 | 232 | 233 | **[WARNING]** 234 | 235 | - **This sensor must be the same for all your created automations based on this blueprint!** 236 | 237 | 238 | **[NOTE]** 239 | 240 | - If your solar system is not coupled with a battery, leave this field empty 241 | default: 242 | selector: 243 | entity: 244 | domain: sensor 245 | multiple: false 246 | 247 | 248 | appliance_switch: 249 | name: "Appliance Entity" 250 | description: "Entity to control the appliance (e.g. switch entity, climate entity, light entity, ...)" 251 | selector: 252 | entity: 253 | multiple: false 254 | appliance_switch_interval: 255 | name: "Appliance On/Off switch interval" 256 | description: > 257 | Defines the minimum time (in minutes) before switching the appliance on/off again. 258 | 259 | Will also be used as the averaging interval for available excess power. 260 | 261 | 262 | **[NOTE]** 263 | 264 | - When first creating and executing your automation, it will most likely take the here defined time till your appliance can switch on, even if enough excess solar is available beforehand. 265 | This is due to the excess_history getting populated with zeros at the beginning. 266 | default: 5 267 | selector: 268 | number: 269 | min: 1 270 | max: 60 271 | step: 1 272 | unit_of_measurement: min 273 | 274 | 275 | appliance_on_only: 276 | name: "Only-On-Appliance" 277 | description: > 278 | Tick this box if your appliance should only be turned on, but **never off**. 279 | 280 | Useful e.g. for washing machines, which should not be turned off even if there is not enough solar power anymore. 281 | default: False 282 | selector: 283 | boolean: 284 | appliance_once_only: 285 | name: "Only-Run-Once-Appliance" 286 | description: "Tick this box if your appliance should run **once a day** at most." 287 | default: False 288 | selector: 289 | boolean: 290 | 291 | dynamic_current_appliance: 292 | name: "Dynamic current control" 293 | description: "Tick this box if your appliance supports different current levels.\nUseful e.g. for **wallboxes**.\nIf False, the appliance will only be switched on or off in relation to the PV excess." 294 | default: False 295 | selector: 296 | boolean: 297 | appliance_current_set_entity: 298 | name: "Appliance SetCurrent entity" 299 | description: > 300 | The number entity to which the calculated current will be sent. 301 | 302 | 303 | **[NOTE]** 304 | 305 | - **Only relevant when dynamic current control is set!** 306 | 307 | Leave empty if *dynamic current control* is deactivated. 308 | default: 309 | selector: 310 | entity: 311 | domain: 312 | - number 313 | - input_number 314 | multiple: false 315 | min_current: 316 | name: "Minimum dynamic current" 317 | description: > 318 | Minimum current per phase your appliance can handle. 319 | 320 | 321 | **[NOTE]** 322 | 323 | - **Only relevant when dynamic current control is set!** 324 | 325 | Leave empty if *dynamic current control* is deactivated. 326 | default: 6 327 | selector: 328 | number: 329 | min: 0.1 330 | max: 16 331 | step: 0.1 332 | unit_of_measurement: A 333 | max_current: 334 | name: "Maximum dynamic current" 335 | description: > 336 | Maximum current per phase your appliance can handle. 337 | 338 | 339 | **[NOTE]** 340 | 341 | - **Only relevant when dynamic current control is set!** 342 | 343 | Leave empty if *dynamic current control* is deactivated. 344 | default: 16 345 | selector: 346 | number: 347 | min: 0.1 348 | max: 16 349 | step: 0.1 350 | unit_of_measurement: A 351 | appliance_phases: 352 | name: "Appliance Phases" 353 | description: > 354 | Input here, with how many phases your appliance works. Typically, you either have 1 phase (e.g. washing machine, dishwasher) or 3 phases (wallbox). 355 | 356 | 357 | **[NOTE]** 358 | 359 | If you have a three-phase wallbox, but your car can only charge with one phase, you need to input 1 here. 360 | default: 1 361 | selector: 362 | number: 363 | min: 1 364 | max: 3 365 | step: 1 366 | unit_of_measurement: phases 367 | 368 | 369 | defined_current: 370 | name: "Appliance typical current draw" 371 | description: > 372 | Typical/expected current draw of your appliance per phase. 373 | 374 | Relevant for deciding how much excess is needed before turning the specific appliance on. 375 | 376 | If your appliance supports dynamic current setting, set the *typical current* to the same value as the *minimum dynamic current*. 377 | default: 6 378 | selector: 379 | number: 380 | min: 0.1 381 | max: 16 382 | step: 0.1 383 | unit_of_measurement: A 384 | actual_power: 385 | name: "Appliance actual power sensor" 386 | description: > 387 | Sensor which contains the **current power consumption** of the appliance in watts. 388 | 389 | If this is left empty (not recommended), the *typical current draw* will be used instead. 390 | default: 391 | selector: 392 | entity: 393 | domain: sensor 394 | multiple: false 395 | 396 | 397 | 398 | mode: single 399 | trigger: 400 | - platform: homeassistant 401 | event: start 402 | 403 | 404 | action: 405 | - service: pyscript.pv_excess_control 406 | data: 407 | automation_id: !input automation_id 408 | appliance_priority: !input appliance_priority 409 | export_power: !input export_power 410 | pv_power: !input pv_power 411 | load_power: !input load_power 412 | home_battery_level: !input home_battery_level 413 | min_home_battery_level: !input min_home_battery_level 414 | dynamic_current_appliance: !input dynamic_current_appliance 415 | appliance_phases: !input appliance_phases 416 | min_current: !input min_current 417 | max_current: !input max_current 418 | appliance_switch: !input appliance_switch 419 | appliance_switch_interval: !input appliance_switch_interval 420 | appliance_current_set_entity: !input appliance_current_set_entity 421 | actual_power: !input actual_power 422 | defined_current: !input defined_current 423 | appliance_on_only: !input appliance_on_only 424 | grid_voltage: !input grid_voltage 425 | import_export_power: !input import_export_power 426 | home_battery_capacity: !input home_battery_capacity 427 | solar_production_forecast: !input solar_production_forecast 428 | appliance_once_only: !input appliance_once_only 429 | -------------------------------------------------------------------------------- /PV_Excess_Control/pyscript/pv_excess_control.py: -------------------------------------------------------------------------------- 1 | # INFO -------------------------------------------- 2 | # This is intended to be called once manually or on startup. See blueprint. 3 | # Automations can be deactivated correctly from the UI! 4 | # ------------------------------------------------- 5 | from typing import Union 6 | import datetime 7 | 8 | 9 | def _get_state(entity_id: str) -> Union[str, None]: 10 | """ 11 | Get the state of an entity in Home Assistant 12 | :param entity_id: Name of the entity 13 | :return: State if entity name is valid, else None 14 | """ 15 | # get entity domain 16 | domain = entity_id.split('.')[0] 17 | try: 18 | entity_state = state.get(entity_id) 19 | except Exception as e: 20 | log.error(f'Could not get state from entity {entity_id}: {e}') 21 | return None 22 | 23 | if domain == 'climate': 24 | if entity_state.lower() in ['heat', 'cool', 'boost', 'on']: 25 | return 'on' 26 | elif entity_state == 'off': 27 | return entity_state 28 | else: 29 | log.error(f'Entity state not supported: {entity_state}') 30 | return None 31 | 32 | else: 33 | return entity_state 34 | 35 | 36 | def _turn_off(entity_id: str) -> bool: 37 | """ 38 | Switches an entity off 39 | :param entity_id: ID of the entity 40 | """ 41 | # get entity domain 42 | domain = entity_id.split('.')[0] 43 | # check if service exists: 44 | if not service.has_service(domain, 'turn_off'): 45 | log.error(f'Cannot switch off appliance: Service "{domain}.turn_off" does not exist.') 46 | return False 47 | 48 | try: 49 | service.call(domain, 'turn_off', entity_id=entity_id) 50 | except Exception as e: 51 | log.error(f'Cannot switch off appliance: {e}') 52 | return False 53 | else: 54 | return True 55 | 56 | 57 | def _turn_on(entity_id: str) -> bool: 58 | """ 59 | Switches an entity on 60 | :param entity_id: ID of the entity 61 | """ 62 | # get entity domain 63 | domain = entity_id.split('.')[0] 64 | # check if service exists: 65 | if not service.has_service(domain, 'turn_on'): 66 | log.error(f'Cannot switch on appliance: Service "{domain}.turn_on" does not exist.') 67 | return False 68 | 69 | try: 70 | service.call(domain, 'turn_on', entity_id=entity_id) 71 | except Exception as e: 72 | log.error(f'Cannot switch on appliance: {e}') 73 | return False 74 | else: 75 | return True 76 | 77 | 78 | def _set_value(entity_id: str, value: Union[int, float, str]) -> bool: 79 | """ 80 | Sets a number entity to a specific value 81 | :param entity_id: ID of the entity 82 | :param value: Numerical value 83 | :return: 84 | """ 85 | # get entity domain 86 | domain = entity_id.split('.')[0] 87 | # check if service exists: 88 | if not service.has_service(domain, 'set_value'): 89 | log.error(f'Cannot set value "{value}": Service "{domain}.set_value" does not exist.') 90 | return False 91 | 92 | try: 93 | service.call(domain, 'set_value', entity_id=entity_id, value=value) 94 | except Exception as e: 95 | log.error(f'Cannot set value "{value}": {e}') 96 | return False 97 | else: 98 | return True 99 | 100 | 101 | def _get_num_state(entity_id: str, return_on_error: Union[float, None] = None) -> Union[float, None]: 102 | return _validate_number(_get_state(entity_id), return_on_error) 103 | 104 | 105 | def _validate_number(num: Union[float, str], return_on_error: Union[float, None] = None) -> Union[float, None]: 106 | """ 107 | Validate, if the passed variable is a number between 0 and 1000000. 108 | :param num: Number 109 | :param return_on_error: Value to return in case of error 110 | :return: Number if valid, else None 111 | """ 112 | if num is None or num == 'unavailable': 113 | return return_on_error 114 | 115 | min_v = -1000000 116 | max_v = 1000000 117 | try: 118 | if min_v <= float(num) <= max_v: 119 | return float(num) 120 | else: 121 | raise Exception(f'{float(num)} not in range: [{min_v}, {max_v}]') 122 | except Exception as e: 123 | log.error(f'{num=} is not a valid number between 0 and 1000000: {e}') 124 | return return_on_error 125 | 126 | 127 | def _replace_vowels(input: str) -> str: 128 | """ 129 | Function to replace lowercase vowels in a string 130 | :param input: Input string 131 | :return: String with replaced vowels 132 | """ 133 | vowel_replacement = {'ä': 'a', 'ö': 'o', 'ü': 'u'} 134 | res = [vowel_replacement[v] if v in vowel_replacement else v for v in input] 135 | return ''.join(res) 136 | 137 | 138 | @time_trigger("cron(0 0 * * *)") 139 | def reset_midnight(): 140 | log.info("Resetting 'switched_on_today' instance variables.") 141 | for e in PvExcessControl.instances.copy().values(): 142 | inst = e['instance'] 143 | inst.switched_on_today = False 144 | inst.daily_run_time = 0 145 | 146 | 147 | @service 148 | def pv_excess_control(automation_id, appliance_priority, export_power, pv_power, load_power, home_battery_level, 149 | min_home_battery_level, dynamic_current_appliance, appliance_phases, min_current, 150 | max_current, appliance_switch, appliance_switch_interval, appliance_current_set_entity, 151 | actual_power, defined_current, appliance_on_only, grid_voltage, import_export_power, 152 | home_battery_capacity, solar_production_forecast, appliance_once_only): 153 | 154 | automation_id = automation_id[11:] if automation_id[:11] == 'automation.' else automation_id 155 | automation_id = _replace_vowels(f"automation.{automation_id.strip().replace(' ', '_').lower()}") 156 | 157 | 158 | PvExcessControl(automation_id, appliance_priority, export_power, pv_power, 159 | load_power, home_battery_level, min_home_battery_level, 160 | dynamic_current_appliance, appliance_phases, min_current, 161 | max_current, appliance_switch, appliance_switch_interval, 162 | appliance_current_set_entity, actual_power, defined_current, appliance_on_only, 163 | grid_voltage, import_export_power, home_battery_capacity, solar_production_forecast, 164 | appliance_once_only) 165 | 166 | 167 | 168 | class PvExcessControl: 169 | # TODO: 170 | # - What about other domains than switches? Enable use of other domains (e.g. light, ...) 171 | # - Make min_excess_power configurable via blueprint 172 | # - Implement updating of pv sensors history more often. E.g. every 10secs, and averaging + adding to history every minute. 173 | instances = {} 174 | export_power = None 175 | pv_power = None 176 | load_power = None 177 | home_battery_level = None 178 | grid_voltage = None 179 | import_export_power = None 180 | home_battery_capacity = None 181 | solar_production_forecast = None 182 | min_home_battery_level = None 183 | # Exported Power history 184 | export_history = [0]*60 185 | export_history_buffer = [] 186 | # PV Excess history (PV power minus load power) 187 | pv_history = [0]*60 188 | pv_history_buffer = [] 189 | # Minimum excess power in watts. If the average min_excess_power at the specified appliance switch interval is greater than the actual 190 | # excess power, the appliance with the lowest priority will be shut off. 191 | # NOTE: Should be slightly negative, to compensate for inaccurate power corrections 192 | # WARNING: Do net set this to more than 0, otherwise some devices with dynamic current control will abruptly get switched off in some 193 | # situations. 194 | min_excess_power = -10 195 | on_time_counter = 0 196 | 197 | 198 | def __init__(self, automation_id, appliance_priority, export_power, pv_power, load_power, home_battery_level, 199 | min_home_battery_level, dynamic_current_appliance, appliance_phases, min_current, 200 | max_current, appliance_switch, appliance_switch_interval, appliance_current_set_entity, 201 | actual_power, defined_current, appliance_on_only, grid_voltage, import_export_power, 202 | home_battery_capacity, solar_production_forecast, appliance_once_only): 203 | if automation_id not in PvExcessControl.instances: 204 | inst = self 205 | else: 206 | inst = PvExcessControl.instances[automation_id]['instance'] 207 | inst.automation_id = automation_id 208 | inst.appliance_priority = int(appliance_priority) 209 | PvExcessControl.export_power = export_power 210 | PvExcessControl.pv_power = pv_power 211 | PvExcessControl.load_power = load_power 212 | PvExcessControl.home_battery_level = home_battery_level 213 | PvExcessControl.grid_voltage = grid_voltage 214 | PvExcessControl.import_export_power = import_export_power 215 | PvExcessControl.home_battery_capacity = home_battery_capacity 216 | PvExcessControl.solar_production_forecast = solar_production_forecast 217 | PvExcessControl.min_home_battery_level = float(min_home_battery_level) 218 | 219 | inst.dynamic_current_appliance = bool(dynamic_current_appliance) 220 | inst.min_current = float(min_current) 221 | inst.max_current = float(max_current) 222 | inst.appliance_switch = appliance_switch 223 | inst.appliance_switch_interval = int(appliance_switch_interval) 224 | inst.appliance_current_set_entity = appliance_current_set_entity 225 | inst.actual_power = actual_power 226 | inst.defined_current = float(defined_current) 227 | inst.appliance_on_only = bool(appliance_on_only) 228 | inst.appliance_once_only = appliance_once_only 229 | 230 | inst.phases = appliance_phases 231 | 232 | inst.log_prefix = f'[{inst.appliance_switch} {inst.automation_id} (Prio {inst.appliance_priority})]' 233 | inst.domain = inst.appliance_switch.split('.')[0] 234 | 235 | 236 | # start if needed 237 | if inst.automation_id not in PvExcessControl.instances: 238 | inst.switched_on_today = False 239 | inst.switch_interval_counter = 0 240 | inst.switched_on_time = datetime.datetime.now() 241 | inst.daily_run_time = 0 242 | inst.trigger_factory() 243 | PvExcessControl.instances[inst.automation_id] = {'instance': inst, 'priority': inst.appliance_priority} 244 | log.info(f'{self.log_prefix} Trigger Method started.') 245 | PvExcessControl.instances = dict(sorted(PvExcessControl.instances.items(), key=lambda item: item[1]['priority'], reverse=True)) 246 | log.info(f'{inst.log_prefix} Registered appliance.') 247 | 248 | def trigger_factory(self): 249 | # trigger every 10s 250 | @time_trigger('period(now, 10s)') 251 | def on_time(): 252 | # Sanity check 253 | if (not PvExcessControl.instances) or (not self.sanity_check()): 254 | return on_time 255 | 256 | # execute only if this the first instance of the dictionary (avoid two automations acting) 257 | #log.info(f'{self.log_prefix} I am around.') 258 | first_item = next(iter(PvExcessControl.instances.values())) 259 | if first_item["instance"] != self: 260 | return on_time 261 | 262 | PvExcessControl.on_time_counter += 1 263 | PvExcessControl._update_pv_history() 264 | # ensure that control algo only runs every minute (= every 6th on_time trigger) 265 | if PvExcessControl.on_time_counter % 6 != 0: 266 | return on_time 267 | PvExcessControl.on_time_counter = 0 268 | 269 | # ----------------------------------- go through each appliance (highest prio to lowest) --------------------------------------- 270 | # this is for determining which devices can be switched on 271 | instances = [] 272 | for a_id, e in PvExcessControl.instances.copy().items(): 273 | inst = e['instance'] 274 | inst.switch_interval_counter += 1 275 | log_prefix = inst.log_prefix 276 | 277 | # Check if automation is activated for specific instance 278 | if not self.automation_activated(inst.automation_id): 279 | continue 280 | 281 | # check min bat lvl and decide whether to regard export power or solar power minus load power 282 | if PvExcessControl.home_battery_level is None: 283 | home_battery_level = 100 284 | else: 285 | home_battery_level = _get_num_state(PvExcessControl.home_battery_level) 286 | if home_battery_level >= PvExcessControl.min_home_battery_level or not self._force_charge_battery(): 287 | # home battery charge is high enough to direct solar power to appliances, if solar power is higher than load power 288 | # calc avg based on pv excess (solar power - load power) according to specified window 289 | avg_excess_power = int(sum(PvExcessControl.pv_history[-inst.appliance_switch_interval:]) / max(1,inst.appliance_switch_interval)) 290 | log.debug(f'{log_prefix} Home battery charge is sufficient ({home_battery_level}/{PvExcessControl.min_home_battery_level} %)' 291 | f' OR remaining solar forecast is higher than remaining capacity of home battery. ' 292 | f'Calculated average excess power based on >> solar power - load power <<: {avg_excess_power} W') 293 | 294 | else: 295 | # home battery charge is not yet high enough OR battery force charge is necessary. 296 | # Only use excess power (which would otherwise be exported to the grid) for appliance 297 | # calc avg based on export power history according to specified window 298 | avg_excess_power = int(sum(PvExcessControl.export_history[-inst.appliance_switch_interval:]) / max(1,inst.appliance_switch_interval)) 299 | log.debug(f'{log_prefix} Home battery charge is not sufficient ({home_battery_level}/{PvExcessControl.min_home_battery_level} %), ' 300 | f'OR remaining solar forecast is lower than remaining capacity of home battery. ' 301 | f'Calculated average excess power based on >> export power <<: {avg_excess_power} W') 302 | 303 | # add instance including calculated excess power to inverted list (priority from low to high) 304 | instances.insert(0, {'instance': inst, 'avg_excess_power': avg_excess_power}) 305 | 306 | 307 | # ------------------------------------------------------------------- 308 | # Determine if appliance can be turned on or current can be increased 309 | if _get_state(inst.appliance_switch) == 'on': 310 | # check if current of appliance can be increased 311 | log.debug(f'{log_prefix} Appliance is already switched on.') 312 | run_time = inst.daily_run_time + (datetime.datetime.now() - inst.switched_on_time).total_seconds() 313 | log.info(f'{inst.log_prefix} Application has run for {(run_time / 60):.1f} minutes') 314 | if avg_excess_power >= PvExcessControl.min_excess_power and inst.dynamic_current_appliance: 315 | # try to increase dynamic current, because excess solar power is available 316 | prev_amps = _get_num_state(inst.appliance_current_set_entity, return_on_error=inst.min_current) 317 | excess_amps = round(avg_excess_power / (PvExcessControl.grid_voltage * inst.phases), 1) + prev_amps 318 | amps = max(inst.min_current, min(excess_amps, inst.max_current)) 319 | if amps > (prev_amps+0.09): 320 | _set_value(inst.appliance_current_set_entity, amps) 321 | log.info(f'{log_prefix} Setting dynamic current appliance from {prev_amps} to {amps} A per phase.') 322 | diff_power = (amps-prev_amps) * PvExcessControl.grid_voltage * inst.phases 323 | # "restart" history by subtracting power difference from each history value within the specified time frame 324 | self._adjust_pwr_history(inst, -diff_power) 325 | 326 | elif not (inst.appliance_once_only and inst.switched_on_today): 327 | # check if appliance can be switched on 328 | if _get_state(inst.appliance_switch) != 'off': 329 | log.warning(f'{log_prefix} Appliance state (={_get_state(inst.appliance_switch)}) is neither ON nor OFF. ' 330 | f'Assuming OFF state.') 331 | defined_power = inst.defined_current * PvExcessControl.grid_voltage * inst.phases 332 | 333 | if avg_excess_power >= defined_power or (inst.appliance_priority > 1000 and avg_excess_power > 0): 334 | log.debug(f'{log_prefix} Average Excess power is high enough to switch on appliance.') 335 | if inst.switch_interval_counter >= inst.appliance_switch_interval: 336 | self.switch_on(inst) 337 | inst.switch_interval_counter = 0 338 | log.info(f'{log_prefix} Switched on appliance.') 339 | # "restart" history by subtracting defined power from each history value within the specified time frame 340 | self._adjust_pwr_history(inst, -defined_power) 341 | task.sleep(1) 342 | if inst.dynamic_current_appliance: 343 | _set_value(inst.appliance_current_set_entity, inst.min_current) 344 | else: 345 | log.debug(f'{log_prefix} Cannot switch on appliance, because appliance switch interval is not reached ' 346 | f'({inst.switch_interval_counter}/{inst.appliance_switch_interval}).') 347 | else: 348 | log.debug(f'{log_prefix} Average Excess power not high enough to switch on appliance.') 349 | # ------------------------------------------------------------------- 350 | 351 | 352 | # ----------------------------------- go through each appliance (lowest prio to highest prio) ---------------------------------- 353 | # this is for determining which devices need to be switched off or decreased in current 354 | prev_consumption_sum = 0 355 | for dic in instances: 356 | inst = dic['instance'] 357 | avg_excess_power = dic['avg_excess_power'] + prev_consumption_sum 358 | log_prefix = f'[{inst.appliance_switch} (Prio {inst.appliance_priority})]' 359 | 360 | # ------------------------------------------------------------------- 361 | if _get_state(inst.appliance_switch) == 'on': 362 | # check if inst.appliance_priority > 1000 and switching of will cause excess. In that case keep it on 363 | if inst.appliance_priority > 1000: 364 | if inst.actual_power is None: 365 | allowed_excess_power_consumption = inst.defined_current * PvExcessControl.grid_voltage * inst.phases 366 | else: 367 | allowed_excess_power_consumption = _get_num_state(inst.actual_power) 368 | else: 369 | allowed_excess_power_consumption = 0 370 | if avg_excess_power < PvExcessControl.min_excess_power - allowed_excess_power_consumption: 371 | log.debug(f'{log_prefix} Average Excess Power ({avg_excess_power} W) is less than minimum excess power ' 372 | f'({PvExcessControl.min_excess_power} W).') 373 | 374 | # check if current of dyn. curr. appliance can be reduced 375 | if inst.dynamic_current_appliance: 376 | if inst.actual_power is None: 377 | actual_current = round((inst.defined_current * PvExcessControl.grid_voltage * inst.phases) / ( 378 | PvExcessControl.grid_voltage * inst.phases), 1) 379 | else: 380 | actual_current = round(_get_num_state(inst.actual_power) / (PvExcessControl.grid_voltage * inst.phases), 1) 381 | diff_current = round(avg_excess_power / (PvExcessControl.grid_voltage * inst.phases), 1) 382 | target_current = max(inst.min_current, actual_current + diff_current) 383 | log.debug(f'{log_prefix} {actual_current=}A | {diff_current=}A | {target_current=}A') 384 | if inst.min_current < target_current < actual_current: 385 | # current can be reduced 386 | log.info(f'{log_prefix} Reducing dynamic current appliance from {actual_current} A to {target_current} A.') 387 | _set_value(inst.appliance_current_set_entity, target_current) 388 | # add released power consumption to next appliances in list 389 | diff_power = (actual_current - target_current) * PvExcessControl.grid_voltage * inst.phases 390 | prev_consumption_sum += diff_power 391 | log.debug(f'{log_prefix} Added {diff_power=} W to prev_consumption_sum, ' 392 | f'which is now {prev_consumption_sum} W.') 393 | # "restart" history by adding defined power to each history value within the specified time frame 394 | self._adjust_pwr_history(inst, diff_power) 395 | else: 396 | # current cannot be reduced 397 | # turn off appliance 398 | power_consumption = self.switch_off(inst) 399 | if power_consumption != 0: 400 | prev_consumption_sum += power_consumption 401 | log.debug(f'{log_prefix} Added {power_consumption=} W to prev_consumption_sum, ' 402 | f'which is now {prev_consumption_sum} W.') 403 | 404 | else: 405 | # Try to switch off appliance 406 | power_consumption = self.switch_off(inst) 407 | if power_consumption != 0: 408 | prev_consumption_sum += power_consumption 409 | log.debug(f'{log_prefix} Added {power_consumption=} W to prev_consumption_sum, ' 410 | f'which is now {prev_consumption_sum} W.') 411 | else: 412 | log.debug(f'{log_prefix} Average Excess Power ({avg_excess_power} W) is still greater than minimum excess power ' 413 | f'({PvExcessControl.min_excess_power} W) - Doing nothing.') 414 | 415 | 416 | else: 417 | if _get_state(inst.appliance_switch) != 'off': 418 | log.warning(f'{log_prefix} Appliance state (={_get_state(inst.appliance_switch)}) is neither ON nor OFF. ' 419 | f'Assuming OFF state.') 420 | # Note: This can misfire right after an appliance has been switched on. Generally no problem. 421 | log.debug(f'{log_prefix} Appliance is already switched off.') 422 | # ------------------------------------------------------------------- 423 | 424 | return on_time 425 | 426 | @staticmethod 427 | def _update_pv_history(): 428 | """ 429 | Update Export and PV history 430 | """ 431 | try: 432 | if PvExcessControl.import_export_power: 433 | # Calc values based on combined import/export power sensor 434 | import_export_state = _get_num_state(PvExcessControl.import_export_power) 435 | if import_export_state is None: 436 | raise Exception(f'Could not update Export/PV history: {PvExcessControl.import_export_power} is None.') 437 | import_export = int(import_export_state) 438 | # load_pwr = pv_pwr + import_export 439 | export_pwr = abs(min(0, import_export)) 440 | excess_pwr = -import_export 441 | else: 442 | # Calc values based on separate sensors 443 | export_pwr_state = _get_num_state(PvExcessControl.export_power) 444 | pv_power_state = _get_num_state(PvExcessControl.pv_power) 445 | load_power_state = _get_num_state(PvExcessControl.load_power) 446 | if export_pwr_state is None or pv_power_state is None or load_power_state is None: 447 | raise Exception(f'Could not update Export/PV history {PvExcessControl.export_power=} | {PvExcessControl.pv_power=} | ' 448 | f'{PvExcessControl.load_power=} = {export_pwr_state=} | {pv_power_state=} | {load_power_state=}') 449 | export_pwr = int(export_pwr_state) 450 | excess_pwr = int(pv_power_state - load_power_state) 451 | except Exception as e: 452 | log.error(f'Could not update Export/PV history!: {e}') 453 | return 454 | else: 455 | PvExcessControl.export_history_buffer.append(export_pwr) 456 | PvExcessControl.pv_history_buffer.append(excess_pwr) 457 | 458 | # log.debug(f'Export History Buffer: {PvExcessControl.export_history_buffer}') 459 | # log.debug(f'PV Excess (PV Power - Load Power) History Buffer: {PvExcessControl.pv_history_buffer}') 460 | 461 | if PvExcessControl.on_time_counter % 6 == 0: 462 | # enforce max. 60 minute length of history 463 | if len(PvExcessControl.export_history) >= 60: 464 | PvExcessControl.export_history.pop(0) 465 | if len(PvExcessControl.pv_history) >= 60: 466 | PvExcessControl.pv_history.pop(0) 467 | # calc avg of buffer 468 | export_avg = round(sum(PvExcessControl.export_history_buffer) / len(PvExcessControl.export_history_buffer)) 469 | excess_avg = round(sum(PvExcessControl.pv_history_buffer) / len(PvExcessControl.pv_history_buffer)) 470 | # add avg to history 471 | PvExcessControl.export_history.append(export_avg) 472 | PvExcessControl.pv_history.append(excess_avg) 473 | log.debug(f'Export History: {PvExcessControl.export_history}') 474 | log.debug(f'PV Excess (PV Power - Load Power) History: {PvExcessControl.pv_history}') 475 | # clear buffer 476 | PvExcessControl.export_history_buffer = [] 477 | PvExcessControl.pv_history_buffer = [] 478 | 479 | 480 | def sanity_check(self) -> bool: 481 | if PvExcessControl.import_export_power is not None and PvExcessControl.home_battery_level is not None: 482 | log.warning('"Import/Export power" has been defined together with "Home Battery". This is not intended and will lead to always ' 483 | 'giving the home battery priority over appliances, regardless of the specified min. battery level.') 484 | return True 485 | if PvExcessControl.import_export_power is not None and (PvExcessControl.export_power is not None or 486 | PvExcessControl.load_power is not None): 487 | log.error('"Import/Export power" has been defined together with either "Export power" or "Load power". This is not ' 488 | 'allowed. Please specify either "Import/Export power" or both "Load power" & "Export Power".') 489 | return False 490 | if not (PvExcessControl.import_export_power is not None or (PvExcessControl.export_power is not None and 491 | PvExcessControl.load_power is not None)): 492 | log.error('Either "Export power" or "Load power" have not been defined. This is not ' 493 | 'allowed. Please specify either "Import/Export power" or both "Load power" & "Export Power".') 494 | return False 495 | return True 496 | 497 | def switch_on(self, inst): 498 | """ 499 | Switches an appliance on, if possible. 500 | :param inst: PVExcesscontrol Class instance 501 | """ 502 | if inst.appliance_once_only and inst.switched_on_today: 503 | log.debug(f'{inst.log_prefix} "Only-Run-Once-Appliance" detected - Appliance was already switched on today - ' 504 | f'Not switching on again.') 505 | elif _turn_on(inst.appliance_switch): 506 | inst.switched_on_today = True 507 | inst.switched_on_time = datetime.datetime.now() 508 | 509 | def switch_off(self, inst) -> float: 510 | """ 511 | Switches an appliance off, if possible. 512 | :param inst: PVExcesscontrol Class instance 513 | :return: Power consumption relief achieved through switching the appliance off (will be 0 if appliance could 514 | not be switched off) 515 | """ 516 | # Check if automation is activated for specific instance 517 | if not self.automation_activated(inst.automation_id): 518 | return 0 519 | # Do not turn off only-on-appliances 520 | if inst.appliance_on_only: 521 | log.debug(f'{inst.log_prefix} "Only-On-Appliance" detected - Not switching off.') 522 | return 0 523 | # Do not turn off if switch interval not reached 524 | elif inst.switch_interval_counter < inst.appliance_switch_interval: 525 | log.debug(f'{inst.log_prefix} Cannot switch off appliance, because appliance switch interval is not reached ' 526 | f'({inst.switch_interval_counter}/{inst.appliance_switch_interval}).') 527 | return 0 528 | else: 529 | # switch off 530 | # get last power consumption 531 | if inst.actual_power is None: 532 | power_consumption = inst.defined_current * PvExcessControl.grid_voltage * inst.phases 533 | else: 534 | power_consumption = _get_num_state(inst.actual_power) 535 | log.debug(f'{inst.log_prefix} Current power consumption: {power_consumption} W') 536 | # switch off appliance 537 | _turn_off(inst.appliance_switch) 538 | inst.daily_run_time += (datetime.datetime.now() - inst.switched_on_time).total_seconds() 539 | log.info(f'{inst.log_prefix} Switched off appliance.') 540 | log.info(f'{inst.log_prefix} Application has run for {(inst.daily_run_time / 60):.1f} minutes') 541 | task.sleep(1) 542 | inst.switch_interval_counter = 0 543 | # "restart" history by adding defined power to each history value within the specified time frame 544 | self._adjust_pwr_history(inst, power_consumption) 545 | return power_consumption 546 | 547 | 548 | def automation_activated(self, a_id): 549 | """ 550 | Checks if the automation for a specific appliance is activated or not. 551 | :param a_id: Automation ID in Home Assistant 552 | :return: True if automation is activated, False otherwise 553 | """ 554 | automation_state = _get_state(a_id) 555 | if automation_state == 'off': 556 | log.debug(f'Doing nothing, because automation is not activated: State is {automation_state}.') 557 | return False 558 | elif automation_state is None: 559 | log.info(f'Automation "{a_id}" was deleted. Removing related class instance.') 560 | del PvExcessControl.instances[a_id] 561 | return False 562 | return True 563 | 564 | 565 | def _adjust_pwr_history(self, inst, value): 566 | log.debug(f'Adjusting power history by {value}.') 567 | log.debug(f'Export history: {PvExcessControl.export_history}') 568 | PvExcessControl.export_history[-inst.appliance_switch_interval:] = [max(0, x + value) for x in 569 | PvExcessControl.export_history[-inst.appliance_switch_interval:]] 570 | log.debug(f'Adjusted export history: {PvExcessControl.export_history}') 571 | log.debug(f'PV Excess (solar power - load power) history: {PvExcessControl.pv_history}') 572 | PvExcessControl.pv_history[-inst.appliance_switch_interval:] = [x + value for x in 573 | PvExcessControl.pv_history[-inst.appliance_switch_interval:]] 574 | log.debug(f'Adjusted PV Excess (solar power - load power) history: {PvExcessControl.pv_history}') 575 | 576 | 577 | def _force_charge_battery(self, kwh_offset: float = 1): 578 | """ 579 | Calculates if the remaining solar power forecast is enough to ensure the specified min. home battery level is reached at the end 580 | of the day. 581 | :param kwh_offset: Offset in kWh, which will be added to the calculated remaining battery capacity to ensure an earlier 582 | triggering of a force charge 583 | :return: True if force charge is necessary, False otherwise 584 | """ 585 | if PvExcessControl.home_battery_level is None: 586 | return False 587 | 588 | capacity = PvExcessControl.home_battery_capacity 589 | remaining_capacity = capacity - (0.01 * capacity * _get_num_state(PvExcessControl.home_battery_level, return_on_error=0)) 590 | remaining_forecast = _get_num_state(PvExcessControl.solar_production_forecast, return_on_error=0) 591 | if remaining_forecast <= remaining_capacity + kwh_offset: 592 | log.debug(f'Force battery charge necessary: {capacity=} kWh|{remaining_capacity=} kWh|{remaining_forecast=} kWh| ' 593 | f'{kwh_offset=} kWh') 594 | # go through appliances lowest to highest priority, and try switching them off individually 595 | for a_id, e in dict(sorted(PvExcessControl.instances.items(), key=lambda item: item[1]['priority'])).items(): 596 | inst = e['instance'] 597 | self.switch_off(inst) 598 | return True 599 | return False 600 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ha-advanced-blueprints 2 | Advanced Home Assistant Blueprints combined with pyscript for extra useful automations 3 | 4 | If you like my work, you can support me here:\ 5 | [Buy me a coffee](https://buymeacoffee.com/henrikIC) 6 | 7 | ## Prerequisites 8 | - A working installation of [pyscript](https://github.com/custom-components/pyscript) (can be installed via [HACS](https://hacs.xyz/)) 9 | - Home Assistant v2023.1 or greater 10 | 11 | ## Documentation 12 | See seperate README within sub-folders 13 | --------------------------------------------------------------------------------