├── 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 | [
](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 | [
](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 |
--------------------------------------------------------------------------------