├── LICENSE ├── README.md ├── automations └── solar.yaml ├── esphome └── inverter-controller.yaml ├── pyscript └── apps │ └── agile_battery_charge_plan │ └── __init__.py └── template_sensors └── sensor └── solar.yaml /LICENSE: -------------------------------------------------------------------------------- 1 | The Clear BSD License 2 | 3 | Copyright (c) 2023 Richard North 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted (subject to the limitations in the disclaimer 8 | below) provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, 11 | this list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in the 15 | documentation and/or other materials provided with the distribution. 16 | 17 | * Neither the name of the copyright holder nor the names of its 18 | contributors may be used to endorse or promote products derived from this 19 | software without specific prior written permission. 20 | 21 | NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY 22 | THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 23 | CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 24 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 25 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 26 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 27 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 28 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 29 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 30 | IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 31 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 32 | POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sofar-inverter-control 2 | 3 | ## Background 4 | 5 | Inspired by [sofar2mqtt](https://github.com/cmcgerty/Sofar2mqtt) but wanting something based on ESPHome rather than MQTT, I wrote this code to control a Sofar Solar inverter. This works with the inverter in passive mode, so that the inverter can be controlled by Home Assistant. 6 | 7 | Like sofar2mqtt, this code uses the Sofar Solar Modbus protocol to read and write registers in the inverter. The inverter is connected to the ESP8266 via a MAX3485 serial to RS485 adapter. 8 | 9 | After developing the ESPHome module, I also wrote a pyscript program that runs each night, looks at the half hourly Octopus Agile electricity prices and works out a plan to charge the battery during the cheapest slots (accounting for typical usage and expected solar generation). 10 | 11 | It’s not too clever - just some simple heuristics at this time. The output of that script (basically a ‘desired mode’ for every half hour of the day) feeds an automation which controls the inverter. 12 | 13 | One additional part of the daily plan is to put the inverter in 'battery_save' mode whenever the price is below the average for the day. During these periods, grid power is used even if the battery has charge. This really stretches out the battery storage so that we usually have some left for peak periods. 14 | 15 | The overall effect is pretty nice: even on cloudy days we’re always paying below the average Agile Octopus price, so it feels like a worthwhile experiment. 16 | 17 | ## Repo contents 18 | 19 | This repo contains: 20 | 21 | * an ESPHhome configuration for an ESP8266 to monitor and control a Sofar Solar inverter. This includes passive mode control, enabling charge/discharge behaviour to be changed by Home Assistant. 22 | * a Home Assistant Pyscript program to, on a nightly basis, calculate a charging plan that fills the batteries with cheap power (for Octopus Agile customers). 23 | * a Home Assistant template sensor and automation to apply the charging plan as the day progresses. 24 | 25 | This repo is a WIP, and may be written up into a blog post at some point. 26 | 27 | I use this with a Sofar HYD-3000-ES inverter; the following inverters use the same protocol: 28 | 29 | * ME3000SP 30 | * HYD-xx00-ES 31 | 32 | No guarantees are made about the suitability of this code for your own use. It is provided as-is, and you are responsible for any damage it may cause. You should not use this code without understanding it first. 33 | -------------------------------------------------------------------------------- /automations/solar.yaml: -------------------------------------------------------------------------------- 1 | - id: automation_charge_batteries 2 | alias: Set battery charge rate for Agile pricing 3 | mode: single 4 | trigger: 5 | - platform: state 6 | entity_id: sensor.agile_battery_charge_plan_now 7 | not_to: 8 | - unavailable 9 | action: 10 | - service: select.select_option 11 | entity_id: select.solar_mode 12 | data: 13 | option: "{{ states('sensor.agile_battery_charge_plan_now') }}" 14 | # avoid rapid toggling for any reason 15 | - delay: 16 | minutes: 1 -------------------------------------------------------------------------------- /esphome/inverter-controller.yaml: -------------------------------------------------------------------------------- 1 | # ESPhome Inverter controller for Sofar inverters, with Passive mode control 2 | esphome: 3 | name: "inverter-controller" 4 | 5 | on_boot: 6 | priority: -100 7 | then: 8 | # turn on auto mode at boot just in case 9 | - lambda: |- 10 | ESP_LOGI("mode", "Mode set to auto"); 11 | 12 | modbus_controller::ModbusController *controller = id(solar_inverter); 13 | std::vector payload = {0x1, 0x42, 0x01, 0x03, 0x00, 0x00}; 14 | modbus_controller::ModbusCommandItem command = modbus_controller::ModbusCommandItem::create_custom_command(controller, payload); 15 | controller->queue_command(command); 16 | 17 | esp8266: 18 | board: nodemcuv2 19 | 20 | # Enable logging 21 | logger: 22 | baud_rate: 0 23 | 24 | # Enable Home Assistant API 25 | api: 26 | 27 | ota: 28 | 29 | wifi: 30 | ssid: !secret wifi_ssid 31 | password: !secret wifi_password 32 | 33 | # Enable fallback hotspot (captive portal) in case wifi connection fails 34 | #ap: 35 | # ssid: "" 36 | # password: "" 37 | 38 | captive_portal: 39 | 40 | uart: 41 | id: mod_bus 42 | tx_pin: TX 43 | rx_pin: RX 44 | baud_rate: 9600 45 | stop_bits: 1 46 | debug: 47 | direction: BOTH 48 | dummy_receiver: false 49 | # after: 50 | # delimiter: [0xF8,0xF7,0xF6,0xF5] 51 | 52 | modbus: 53 | send_wait_time: 200ms 54 | id: modbus_inverter 55 | 56 | modbus_controller: 57 | - id: solar_inverter 58 | address: 0x1 59 | modbus_id: modbus_inverter 60 | command_throttle: 0ms 61 | setup_priority: -10 62 | update_interval: 30s 63 | - id: solar_inverter_rapid 64 | address: 0x1 65 | modbus_id: modbus_inverter 66 | command_throttle: 0ms 67 | setup_priority: -10 68 | update_interval: 5s 69 | 70 | text_sensor: 71 | - platform: modbus_controller 72 | modbus_controller_id: solar_inverter 73 | id: solar_state 74 | name: "state" 75 | address: 0x0200 76 | register_type: holding 77 | raw_encode: HEXBYTES 78 | # value_type: U_WORD 79 | lambda: |- 80 | uint16_t value = modbus_controller::word_from_hex_str(x, 0); 81 | switch (value) { 82 | case 0: return std::string("waiting"); 83 | case 1: return std::string("charging check"); 84 | case 2: return std::string("charging"); 85 | case 3: return std::string("discharging check"); 86 | case 4: return std::string("discharging"); 87 | case 5: return std::string("EPS"); 88 | case 6: return std::string("fault"); 89 | case 7: return std::string("permanent fault"); 90 | default: return std::string("Unknown"); 91 | } 92 | return x; 93 | 94 | sensor: 95 | 96 | - platform: modbus_controller 97 | modbus_controller_id: solar_inverter 98 | id: solar_export_energy_total_today 99 | name: "Export energy total today" 100 | address: 0x0219 101 | register_type: holding 102 | value_type: U_WORD 103 | filters: 104 | - multiply: 0.01 105 | unit_of_measurement: kWh 106 | device_class: energy 107 | accuracy_decimals: 3 108 | icon: "mdi:transmission-tower-export" 109 | state_class: total_increasing 110 | - platform: modbus_controller 111 | modbus_controller_id: solar_inverter 112 | id: solar_import_energy_total_today 113 | name: "Import energy total today" 114 | address: 0x021A 115 | register_type: holding 116 | value_type: U_WORD 117 | filters: 118 | - multiply: 0.01 119 | unit_of_measurement: kWh 120 | device_class: energy 121 | accuracy_decimals: 3 122 | icon: "mdi:transmission-tower-import" 123 | state_class: total_increasing 124 | - platform: modbus_controller 125 | modbus_controller_id: solar_inverter 126 | id: solar_consumed_energy_total_today 127 | name: "Consumed energy total today" 128 | address: 0x021B 129 | register_type: holding 130 | value_type: U_WORD 131 | filters: 132 | - multiply: 0.01 133 | unit_of_measurement: kWh 134 | device_class: energy 135 | accuracy_decimals: 3 136 | icon: "mdi:lightning-bolt" 137 | state_class: total_increasing 138 | - platform: modbus_controller 139 | modbus_controller_id: solar_inverter 140 | id: solar_generated_energy_total_today 141 | name: "Generated energy total today" 142 | address: 0x0218 143 | register_type: holding 144 | value_type: U_WORD 145 | filters: 146 | - multiply: 0.01 147 | unit_of_measurement: kWh 148 | device_class: energy 149 | accuracy_decimals: 3 150 | icon: "mdi:solar-power" 151 | state_class: total_increasing 152 | - platform: modbus_controller 153 | modbus_controller_id: solar_inverter 154 | id: solar_battery_charge_energy_total_today 155 | name: "Battery charge energy total today" 156 | address: 0x0224 157 | register_type: holding 158 | value_type: U_WORD 159 | filters: 160 | - multiply: 0.01 161 | unit_of_measurement: kWh 162 | device_class: energy 163 | accuracy_decimals: 3 164 | icon: "mdi:battery-charging" 165 | state_class: total_increasing 166 | - platform: modbus_controller 167 | modbus_controller_id: solar_inverter 168 | id: solar_battery_discharge_energy_total_today 169 | name: "Battery discharge energy total today" 170 | address: 0x0225 171 | register_type: holding 172 | value_type: U_WORD 173 | filters: 174 | - multiply: 0.01 175 | unit_of_measurement: kWh 176 | device_class: energy 177 | accuracy_decimals: 3 178 | icon: "mdi:battery-charging" 179 | state_class: total_increasing 180 | 181 | - platform: modbus_controller 182 | modbus_controller_id: solar_inverter 183 | id: solar_battery_percentage 184 | name: "Battery percentage" 185 | address: 0x0210 186 | register_type: holding 187 | value_type: U_WORD 188 | filters: 189 | - calibrate_linear: 190 | # Account for 20% depth-of-discharge buffer 191 | # Map 0.0 (from sensor) to 0.0 (true value) 192 | - 20.0 -> 0.0 193 | - 100.0 -> 100.0 194 | unit_of_measurement: "%" 195 | device_class: energy 196 | accuracy_decimals: 0 197 | icon: "mdi:battery" 198 | state_class: measurement 199 | 200 | - platform: modbus_controller 201 | modbus_controller_id: solar_inverter 202 | id: solar_inverter_internal_temperature 203 | name: "Inverter internal temperature" 204 | address: 0x0238 205 | register_type: holding 206 | value_type: S_WORD 207 | filters: 208 | - multiply: 1 209 | unit_of_measurement: "°C" 210 | device_class: temperature 211 | state_class: measurement 212 | accuracy_decimals: 1 213 | icon: "mdi:thermometer-lines" 214 | - platform: modbus_controller 215 | modbus_controller_id: solar_inverter 216 | id: solar_inverter_heat_sink_temperature 217 | name: "Inverter heat sink temperature" 218 | address: 0x0239 219 | register_type: holding 220 | value_type: S_WORD 221 | filters: 222 | - multiply: 1 223 | unit_of_measurement: "°C" 224 | device_class: temperature 225 | state_class: measurement 226 | accuracy_decimals: 1 227 | icon: "mdi:thermometer-lines" 228 | - platform: modbus_controller 229 | modbus_controller_id: solar_inverter 230 | id: solar_battery_temperature 231 | name: "Battery temperature" 232 | address: 0x0211 233 | register_type: holding 234 | value_type: S_WORD 235 | filters: 236 | - multiply: 1 237 | unit_of_measurement: "°C" 238 | device_class: temperature 239 | state_class: measurement 240 | accuracy_decimals: 1 241 | icon: "mdi:thermometer-lines" 242 | 243 | - platform: modbus_controller 244 | modbus_controller_id: solar_inverter 245 | id: solar_grid_flow_power 246 | name: "Grid flow power" 247 | address: 0x0212 248 | register_type: holding 249 | value_type: S_WORD 250 | filters: 251 | - multiply: 0.01 252 | - filter_out: 168.980 253 | unit_of_measurement: kW 254 | device_class: power 255 | accuracy_decimals: 3 256 | icon: "mdi:lightning-bolt" 257 | - platform: modbus_controller 258 | modbus_controller_id: solar_inverter 259 | id: solar__rapid 260 | name: "Generation power" 261 | address: 0x0215 262 | register_type: holding 263 | value_type: U_WORD 264 | filters: 265 | - multiply: 0.01 266 | - filter_out: 168.980 267 | unit_of_measurement: kW 268 | device_class: power 269 | accuracy_decimals: 3 270 | icon: "mdi:lightning-bolt" 271 | - platform: modbus_controller 272 | modbus_controller_id: solar_inverter 273 | id: solar_load_power 274 | name: "Load power" 275 | address: 0x0213 276 | register_type: holding 277 | value_type: U_WORD 278 | filters: 279 | - multiply: 0.01 280 | - filter_out: 168.980 281 | unit_of_measurement: kW 282 | device_class: power 283 | accuracy_decimals: 3 284 | icon: "mdi:lightning-bolt" 285 | - platform: modbus_controller 286 | modbus_controller_id: solar_inverter 287 | id: solar_battery_flow_power 288 | name: "Battery flow power" 289 | address: 0x020D 290 | register_type: holding 291 | value_type: S_WORD 292 | filters: 293 | - multiply: 0.01 294 | - filter_out: 168.980 295 | unit_of_measurement: kW 296 | device_class: power 297 | accuracy_decimals: 3 298 | icon: "mdi:lightning-bolt" 299 | 300 | - platform: modbus_controller 301 | modbus_controller_id: solar_inverter 302 | id: solar_battery_cycle_times 303 | name: "Battery cycle times" 304 | address: 0x022C 305 | register_type: holding 306 | value_type: U_WORD 307 | device_class: battery 308 | accuracy_decimals: 0 309 | icon: "mdi:battery-sync" 310 | 311 | # Read generation and load more rapidly but as internal states 312 | - platform: modbus_controller 313 | modbus_controller_id: solar_inverter_rapid 314 | id: solar_generation_power_rapid 315 | internal: True 316 | address: 0x0215 317 | register_type: holding 318 | value_type: U_WORD 319 | filters: 320 | - multiply: 0.01 321 | - filter_out: 168.980 322 | accuracy_decimals: 3 323 | - platform: modbus_controller 324 | modbus_controller_id: solar_inverter_rapid 325 | id: solar_load_power_rapid 326 | internal: True 327 | address: 0x0213 328 | register_type: holding 329 | value_type: U_WORD 330 | filters: 331 | - multiply: 0.01 332 | - filter_out: 168.980 333 | accuracy_decimals: 3 334 | 335 | select: 336 | - platform: template 337 | name: "Mode" 338 | id: solar_mode 339 | options: 340 | - auto 341 | - battery_save 342 | - charge 343 | - discharge 344 | initial_option: auto 345 | optimistic: True 346 | restore_value: True 347 | on_value: 348 | - lambda: |- 349 | if (id(solar_mode).state == "auto") { 350 | ESP_LOGI("mode", "Mode set to auto"); 351 | 352 | modbus_controller::ModbusController *controller = id(solar_inverter); 353 | std::vector payload = {0x1, 0x42, 0x01, 0x03, 0x00, 0x00}; 354 | modbus_controller::ModbusCommandItem command = modbus_controller::ModbusCommandItem::create_custom_command(controller, payload); 355 | controller->queue_command(command); 356 | 357 | } else if (id(solar_mode).state == "battery_save") { 358 | ESP_LOGI("mode", "Mode set to battery_save"); 359 | 360 | } else if (id(solar_mode).state == "charge") { 361 | ESP_LOGI("mode", "Mode set to charge"); 362 | 363 | int rate = min(3000, (int) id(solar_charge_rate).state); 364 | ESP_LOGI("charging", "Forcing charge at %d W", rate); 365 | 366 | modbus_controller::ModbusController *controller = id(solar_inverter); 367 | std::vector payload = {0x1, 0x42, 0x01, 0x02, (rate >> 8) & 0xFF, rate & 0xFF}; 368 | modbus_controller::ModbusCommandItem command = modbus_controller::ModbusCommandItem::create_custom_command(controller, payload); 369 | controller->queue_command(command); 370 | 371 | } else if (id(solar_mode).state == "discharge") { 372 | ESP_LOGI("mode", "Mode set to discharge"); 373 | 374 | int rate = min(3000, (int) id(solar_discharge_rate).state); 375 | ESP_LOGI("discharging", "Forcing discharge at %d W", rate); 376 | 377 | modbus_controller::ModbusController *controller = id(solar_inverter); 378 | std::vector payload = {0x1, 0x42, 0x01, 0x1, (rate >> 8) & 0xFF, rate & 0xFF}; 379 | modbus_controller::ModbusCommandItem command = modbus_controller::ModbusCommandItem::create_custom_command(controller, payload); 380 | controller->queue_command(command); 381 | 382 | } 383 | 384 | number: 385 | - platform: template 386 | name: "Charge rate" 387 | id: solar_charge_rate 388 | icon: "mdi:lightning-bolt" 389 | unit_of_measurement: W 390 | device_class: power 391 | optimistic: true 392 | min_value: 0 393 | max_value: 3000 394 | restore_value: True 395 | initial_value: 3000 396 | step: 1 397 | on_value: 398 | - lambda: |- 399 | if (id(solar_mode).state == "charge") { 400 | ESP_LOGI("charging", "Changing charge rate to %d", x); 401 | 402 | int rate = min(3000, (int) x); 403 | ESP_LOGI("charging", "Forcing charge at %d W", rate); 404 | 405 | modbus_controller::ModbusController *controller = id(solar_inverter); 406 | std::vector payload = {0x1, 0x42, 0x01, 0x02, (rate >> 8) & 0xFF, rate & 0xFF}; 407 | modbus_controller::ModbusCommandItem command = modbus_controller::ModbusCommandItem::create_custom_command(controller, payload); 408 | controller->queue_command(command); 409 | } 410 | - platform: template 411 | name: "Discharge rate" 412 | id: solar_discharge_rate 413 | icon: "mdi:lightning-bolt" 414 | unit_of_measurement: W 415 | device_class: power 416 | optimistic: true 417 | min_value: 0 418 | max_value: 3000 419 | restore_value: True 420 | initial_value: 3000 421 | step: 1 422 | on_value: 423 | - lambda: |- 424 | if (id(solar_mode).state == "discharge") { 425 | ESP_LOGI("discharging", "Changing discharge rate to %d", x); 426 | 427 | int rate = min(3000, (int) x); 428 | ESP_LOGI("discharging", "Forcing discharge at %d W", rate); 429 | 430 | modbus_controller::ModbusController *controller = id(solar_inverter); 431 | std::vector payload = {0x1, 0x42, 0x01, 0x1, (rate >> 8) & 0xFF, rate & 0xFF}; 432 | modbus_controller::ModbusCommandItem command = modbus_controller::ModbusCommandItem::create_custom_command(controller, payload); 433 | controller->queue_command(command); 434 | } 435 | 436 | interval: 437 | - interval: 5s 438 | then: 439 | - lambda: |- 440 | if (id(solar_mode).state == "battery_save") { 441 | float generation_surplus_kw = id(solar_generation_power_rapid).state - id(solar_load_power_rapid).state; 442 | ESP_LOGD("balancing", "Generation power - load power = %.3f", generation_surplus_kw); 443 | 444 | // prevent discharge 445 | int rate = max(0, (int) (generation_surplus_kw * 1000)); 446 | 447 | rate = min(3000, rate); 448 | ESP_LOGD("balancing", "Charging at rate of %d W", rate); 449 | 450 | modbus_controller::ModbusController *controller = id(solar_inverter); 451 | std::vector payload = {0x1, 0x42, 0x01, 0x02, (rate >> 8) & 0xFF, rate & 0xFF}; 452 | modbus_controller::ModbusCommandItem command = modbus_controller::ModbusCommandItem::create_custom_command(controller, payload); 453 | controller->queue_command(command); 454 | } 455 | -------------------------------------------------------------------------------- /pyscript/apps/agile_battery_charge_plan/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Pyscript Agile Octopus daily charge planner 3 | # Uses a simple heuristic to work out cheapest times of day to charge/discharge batteries 4 | # This file should be named: /config/pyscript/apps/agile_battery_charge_plan/__init__.py 5 | # 6 | from datetime import datetime, timezone, timedelta 7 | import math 8 | 9 | # 10 | # Required configuration in /config/pyscript/apps/config.yml 11 | # e.g: 12 | # ``` 13 | # allow_all_imports: true 14 | # hass_is_global: true 15 | # apps: 16 | # agile_battery_charge_plan: 17 | # expected_daily_total_load: 13 18 | # battery_capacity: 6.88 19 | # current_battery_pct_entity_id: sensor.solar_battery_percentage 20 | # octopus_current_rate_entity_id: sensor.octopus_energy_electricity_01p223456_01234567890_current_rate 21 | # forecast_solar_generation_entity_id: sensor.estimated_solar_production_energy_production_today 22 | # forecast_solar_generation_multiplier: 1.2 23 | # charge_rate_kwh_per_slot: 1.5 24 | # 25 | # ``` 26 | # 27 | config = pyscript.config["apps"]["agile_battery_charge_plan"] 28 | expected_daily_total_load = config["expected_daily_total_load"] 29 | battery_capacity = config["battery_capacity"] 30 | current_battery_pct_entity_id = config["current_battery_pct_entity_id"] 31 | octopus_current_rate_entity_id = config["octopus_current_rate_entity_id"] 32 | forecast_solar_generation_entity_id = config["forecast_solar_generation_entity_id"] 33 | forecast_solar_generation_multiplier = config["forecast_solar_generation_multiplier"] 34 | charge_rate_kwh_per_slot = config["charge_rate_kwh_per_slot"] 35 | 36 | 37 | @time_trigger("startup", "cron(5 0 * * *)", "cron(5 17 * * *)", "cron(5 22 * * *)") 38 | @service 39 | def agile_battery_charge_plan(): 40 | log.info(f"agile_battery_charge_plan running") 41 | 42 | # 43 | # Work out how much battery charge we need 44 | # 45 | current_charge = float(state.get(current_battery_pct_entity_id)) * battery_capacity / 100 46 | charge_battery_to_kwh = max(0, min(battery_capacity, expected_daily_total_load - current_charge)) 47 | 48 | rates = state.getattr(octopus_current_rate_entity_id)["rates"] 49 | forecast_solar_generation = float(state.get(forecast_solar_generation_entity_id)) 50 | 51 | # tweak our expected generation 52 | forecast_solar_generation = forecast_solar_generation * float(forecast_solar_generation_multiplier) 53 | 54 | goal_kwh = min(12 - forecast_solar_generation, charge_battery_to_kwh) 55 | log.info(f"Goal is to store {goal_kwh} kWh. Starting charge: {current_charge} kWh, expecting to generate: {forecast_solar_generation}") 56 | 57 | now = datetime.now().replace(tzinfo=timezone.utc) 58 | 59 | # 60 | # Try and fill the battery to the required level, filling from cheapest morning slots first 61 | # 62 | plan = sorted(rates, key=lambda x: x['rate']) 63 | cumulative_charge = 0 64 | 65 | sum_rate = 0 66 | for period in plan: 67 | sum_rate += period["rate"] 68 | mean_rate = sum_rate / len(plan) 69 | 70 | for period in plan: 71 | if cumulative_charge < goal_kwh and period["to"].hour < 10: 72 | # charge during this slot 73 | cumulative_charge += float(charge_rate_kwh_per_slot) 74 | period["desired_mode"] = "charge" 75 | elif period["rate"] < mean_rate: 76 | # if rate is less than mean average, use from grid instead of battery 77 | period["desired_mode"] = "battery_save" 78 | else: 79 | # use from battery, if available 80 | period["desired_mode"] = "auto" 81 | 82 | 83 | plan = sorted(plan, key=lambda x: x["from"]) 84 | for period in plan: 85 | log.debug(f"agile_battery_charge_plan: {period['from']}: {period['desired_mode']}") 86 | 87 | state.set(f"sensor.agile_inverter_plan", value=cumulative_charge, new_attributes={ 88 | "plan": plan, 89 | "unit_of_measurement": "kWh" 90 | }) 91 | 92 | log.info("agile_battery_charge_plan completed") -------------------------------------------------------------------------------- /template_sensors/sensor/solar.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Template sensor to extract the current desired solar inverter mode from sensor.agile_inverter_plan, 3 | # which is computed in advance by the agile_battery_charge_plan script 4 | # 5 | - name: "Agile battery charge mode now" 6 | unique_id: agile_battery_charge_plan_now 7 | state: > 8 | {% set ns = namespace(value='auto') %} 9 | {% set current_soc = states('sensor.effective_solar_battery_energy') | float(0) %} 10 | 11 | {% for period in state_attr('sensor.agile_inverter_plan', 'plan') %} 12 | {% set in_on_period = (period['from']) < now() < (period['to']) %} 13 | {% if in_on_period %} 14 | {% if period['desired_mode'] == 'battery_save' and states('sensor.solar_battery_percentage') | float(0) > 70 %} 15 | {% set ns.value = 'auto' %} 16 | {% else %} 17 | {% set ns.value = period['desired_mode'] %} 18 | {% endif %} 19 | {% endif %} 20 | {% endfor %} 21 | 22 | {% if is_state('binary_sensor.octopus_energy_saving_session', 'on') %} 23 | {% set ns.value = 'auto' %} 24 | {% endif %} 25 | 26 | {% set next_ss_start = state_attr('binary_sensor.octopus_energy_saving_session', 'next_joined_event_start') %} 27 | 28 | {% if next_ss_start %} 29 | {% if (next_ss_start - timedelta(hours=4)) < now() < (next_ss_start - timedelta(hours=1)) %} 30 | {% set ns.value = 3 %} 31 | {% endif %} 32 | {% endif %} 33 | 34 | {{ ns.value }} --------------------------------------------------------------------------------