diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index a3dba1958..bf2fb5f43 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -208,6 +208,9 @@ hadashboard hahistory hainterface halfhourly +Hanchu +hanchu +hanchuess hanres HAOS hasattr @@ -584,6 +587,7 @@ yaxis yaxistooltip yday ylabel +YOURSERIAL yuanzhi zappi zigbee diff --git a/docs/inverter-setup.md b/docs/inverter-setup.md index e9ba1b1e3..c3e73b7ad 100644 --- a/docs/inverter-setup.md +++ b/docs/inverter-setup.md @@ -46,6 +46,7 @@ Once you get everything working please share the configuration as a GitHub issue | [Fox Cloud](#fox-cloud) | Predbat | [fox_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/refs/heads/main/templates/fox_cloud.yaml) | | [Fronius GEN24](#fronius-gen24) | [Fronius](https://www.home-assistant.io/integrations/fronius/) + [fronius-modbus-control](https://github.com/knackerbrot/fronius-modbus-control) | [fronius.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/fronius.yaml) | | [Growatt with Solar Assistant](#growatt-with-solar-assistant) | [Solar Assistant](https://solar-assistant.io/help/home-assistant/setup) | [spa.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/solar_assistant_growatt_spa.yaml) or [sph.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/solar_assistant_growatt_sph.yaml) | + | [Hanchu iESS](#hanchu-iess) | [hanchu-ess-ha](https://github.com/upton68/hanchu-ess-ha) | [hanchu_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/hanchu_cloud.yaml) | | [Huawei](#huawei) | [Huawei Solar](https://github.com/wlcrs/huawei_solar) | [huawei.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/huawei.yaml) | | [Kostal Plenticore](#kostal-plenticore) | [Kostal Plenticore](https://www.home-assistant.io/integrations/kostal_plenticore) | [kostal.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/kostal.yaml) | | [LuxPower](#luxpower) | [LuxPython](https://github.com/guybw/LuxPython_DEV) | [luxpower.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/luxpower.yaml) | @@ -559,6 +560,173 @@ You need to have a Solar Assistant installation Growatt has two popular series of inverters, SPA and SPH. Copy the template that matches your model from templates over the top of your `apps.yaml`, and edit inverter and battery settings as required. Yours may have different entity IDs on Home Assistant. +## Hanchu iESS + +The Hanchu iESS has no native Predbat integration. Control is implemented via Predbat's generic Service API: Predbat calls four service hooks (`charge_start_service`, `charge_stop_service`, `discharge_start_service`, `discharge_stop_service`), all of which point at a single Home Assistant script that writes the corresponding time slots directly to the device via `hanchuess.device_control`. + +Copy the template [hanchu_cloud.yaml](https://raw.githubusercontent.com/springfall2008/batpred/main/templates/hanchu_cloud.yaml) over your `apps.yaml` and follow the steps below. + +### Hanchu iESS Prerequisites + +Install the [hanchu-ess-ha](https://github.com/upton68/hanchu-ess-ha) integration via HACS and configure it with your Hanchu cloud account credentials. Confirm that inverter and battery sensors are appearing in Home Assistant before proceeding. + +### Step 1 — Create helpers + +Create the following helpers in Home Assistant (Settings → Devices & Services → Helpers): + +**Toggle helpers** (toggle type): + +| Entity ID | Name | +| --------- | ---- | +| `input_boolean.predbat_charge_start` | Predbat Charge Start | +| `input_boolean.predbat_discharge_start` | Predbat Discharge Start | + +**Text helper** (text type): + +| Entity ID | Name | +| --------- | ---- | +| `input_text.hanchu_last_mode_action` | Hanchu Last Mode Action | + +`input_text.hanchu_last_mode_action` tracks the last mode successfully applied so the bridge script can skip a redundant API call when Predbat reasserts a state that is already active. + +### Step 2 — Create the bridge script + +All four of Predbat's service hooks call the same script, `script.hanchu_set_state_queued`, passing a `mode_action` field to indicate which state to apply. The script runs with `mode: queued` so if Predbat fires two calls close together — for example stopping a discharge and starting a charge in the same plan-evaluation cycle — Home Assistant queues the second call behind the first rather than letting both `device_control` calls race each other. + +Create a new script (Settings → Automations & Scenes → Scripts → Add Script → Edit in YAML) and paste the following, replacing `YOURSERIAL` with your device serial number as it appears in your HA entity IDs, and replacing `notify.notify` with your own mobile notification service: + +```yaml +alias: Hanchu Set State Queued +mode: queued +fields: + mode_action: + required: true + selector: + select: + options: + - charge_start + - charge_stop + - discharge_start + - discharge_stop +sequence: + - variables: + # mode_action is sometimes only populated under `data` rather than as a + # bare template variable, depending on whether the script is invoked from + # the HA UI or by a real service call from Predbat's AppDaemon dispatch. + # Check both so it works reliably either way. + act: >- + {% if mode_action is defined %}{{ mode_action }} + {% elif data is defined and data.mode_action is defined %}{{ data.mode_action }} + {% else %}unknown{% endif %} + - if: + - condition: template + value_template: "{{ act == states('input_text.hanchu_last_mode_action') }}" + then: + - stop: "No change — same action already applied, skipping API call" + - variables: + start_seconds: "{{ (now() - now().replace(hour=0, minute=0, second=0, microsecond=0)).seconds }}" + tct_start: "{{ start_seconds if act == 'charge_start' else 0 }}" + tct_end: "{{ 39600 if act == 'charge_start' else 0 }}" # 11:00:00 + tdt_start: "{{ start_seconds if act == 'discharge_start' else 0 }}" + tdt_end: "{{ 86340 if act == 'discharge_start' else 0 }}" # 23:59:00 + - action: hanchuess.device_control + data: + sn: YOURSERIAL + dev_type: "2" + value: + TCT_START_1: "{{ tct_start }}" + TCT_END_1: "{{ tct_end }}" + TDT_START_1: "{{ tdt_start }}" + TDT_END_1: "{{ tdt_end }}" + response_variable: result + - if: + - condition: template + value_template: "{{ not result.success }}" + then: + - delay: + seconds: 5 + - action: hanchuess.device_control + data: + sn: YOURSERIAL + dev_type: "2" + value: + TCT_START_1: "{{ tct_start }}" + TCT_END_1: "{{ tct_end }}" + TDT_START_1: "{{ tdt_start }}" + TDT_END_1: "{{ tdt_end }}" + response_variable: result2 + - if: + - condition: template + value_template: "{{ not result2.success }}" + then: + - action: notify.notify # Replace with your own notification service + data: + title: "⚠️ Hanchu {{ act }} FAILED" + message: >- + {{ act }} write failed after retry ({{ result2.message }}) + — check manually. + - stop: "Both attempts failed — leaving last_mode_action unchanged for retry" + - action: input_text.set_value + target: + entity_id: input_text.hanchu_last_mode_action + data: + value: "{{ act }}" + - choose: + - conditions: "{{ act == 'charge_start' }}" + sequence: + - action: input_boolean.turn_on + entity_id: input_boolean.predbat_charge_start + - conditions: "{{ act == 'charge_stop' }}" + sequence: + - action: input_boolean.turn_off + entity_id: input_boolean.predbat_charge_start + - conditions: "{{ act == 'discharge_start' }}" + sequence: + - action: input_boolean.turn_on + entity_id: input_boolean.predbat_discharge_start + - conditions: "{{ act == 'discharge_stop' }}" + sequence: + - action: input_boolean.turn_off + entity_id: input_boolean.predbat_discharge_start +``` + +The script always writes all four time slot fields (`TCT_START_1`, `TCT_END_1`, `TDT_START_1`, `TDT_END_1`) on every call, zeroing whichever pair is not the active mode. This keeps charge and discharge mutually exclusive on the device without relying on separate stop/start calls landing in the right order. + +### Step 3 — Add the soc_kw template sensor + +Predbat requires a `soc_kw` sensor reporting battery state of charge in kWh. Add the following to your `configuration.yaml`: + +```yaml +template: + - sensor: + - name: "Home Battery State of Charge kWh" + unique_id: home_battery_soc_kwh + unit_of_measurement: "kWh" + state_class: measurement + device_class: energy + state: > + {{ ((states('sensor.hanchuess_YOURSERIAL_battery_soc') | float(0)) / 100 * NN.NN) | round(2) }} +``` + +Replace `YOURSERIAL` with your device serial number and `NN.NN` with your total battery capacity in kWh (for example `18.80` for a dual 9.4 kWh system). Restart Home Assistant after adding this. + +### Step 4 — Configure apps.yaml + +- Replace `YOURSERIAL` throughout the template with your device serial number as it appears in your HA entity IDs +- Adjust `inverter_limit`, `inverter_limit_charge`, `inverter_limit_discharge`, `inverter_limit_export` and `battery_rate_max` to match your inverter and battery rated capacity in watts +- Delete the `template: True` line to allow Predbat to start +- Configure your energy rates — see [Energy Rates](https://springfall2008.github.io/batpred/energy-rates/) + +> **Note:** Double-check that `inverter_limit` is spelled exactly as shown — an accented character (for example `é` instead of `e` from autocorrect) will cause Predbat to silently ignore the setting and fall back to its own default. + +### Hanchu Notes + +- **Skipping redundant calls:** Predbat re-evaluates its plan on its normal cycle and can re-issue the same service call mid-window, simply reasserting the plan rather than changing anything. The `input_text.hanchu_last_mode_action` check skips the API call entirely when the requested mode is already the last one successfully applied. The tracker only updates after a confirmed successful write, so a failed attempt still retries correctly on the next cycle. +- **Behaviour on Predbat restart:** Whenever Predbat restarts it issues both `charge_stop_service` and `discharge_stop_service` in quick succession to put the inverter into a known neutral state. This is expected behaviour. The queued script handles this cleanly — if one of the calls matches the already-active state it is skipped as redundant; the other runs if it represents a real change. You may see one or both fire immediately after any restart. +- **Automation latency:** Start/stop commands are occasionally delayed by up to ~2 minutes due to HA scheduling. This has not caused any practical issues in production use. +- **No charge/discharge enable toggle:** Hanchu has no explicit enable/disable for charge or discharge. The slot zeroing mechanism (setting both start and end to `00:00:00`) is the disable method. +- **Min SOC:** Managed via `battery_min_soc` pointing directly to the Hanchu entity — no separate Predbat reserve setting needed. + ## Huawei Copy the Huawei template over your existing `apps.yaml` and modify all entity IDs, battery capacity and power limits for your own system: diff --git a/templates/hanchu_cloud.yaml b/templates/hanchu_cloud.yaml new file mode 100644 index 000000000..176d162a0 --- /dev/null +++ b/templates/hanchu_cloud.yaml @@ -0,0 +1,270 @@ +# Predbat config for the Hanchu iESS, via the hanchu-ess-ha HACS +# integration (https://github.com/upton68/hanchu-ess-ha) +# +# Copy this over your apps.yaml and: +# 1. Install the "Hanchu iESS" integration via HACS and configure it +# with your Hanchu cloud account credentials. +# 2. Create three helpers in Home Assistant (see inverter-setup docs). +# 3. Create the bridge script (see inverter-setup docs). +# 4. Add the soc_kw template sensor to configuration.yaml (see inverter-setup docs). +# 5. Replace YOURSERIAL throughout with your device serial number as it +# appears in your HA entity IDs (set automatically by the integration). +# 6. Adjust inverter_limit, inverter_limit_charge, inverter_limit_discharge, +# inverter_limit_export and battery_rate_max to match your hardware. +# 7. Delete the template: True line below once you have configured this file. +# +# Note: The 5000W limits are set to the author's hardware — adjust these +# to match your own inverter and battery rated capacity. +--- +pred_bat: + module: predbat + class: PredBat + prefix: predbat + timezone: Europe/London + currency_symbols: + - '£' + - 'p' + threads: auto + # XXX: This is a configuration template, delete this line once you edit your configuration + template: True + load_filter_threshold: 30 + # ─── Hanchu iESS daily energy sensors ───────────────────────────────────── + load_today: + - sensor.hanchuess_YOURSERIAL_daily_load_energy + import_today: + - sensor.hanchuess_YOURSERIAL_daily_grid_import + export_today: + - sensor.hanchuess_YOURSERIAL_daily_grid_export + pv_today: + - sensor.hanchuess_YOURSERIAL_daily_pv_energy + # ─── Hanchu iESS inverter definition ────────────────────────────────────── + num_inverters: 1 + inverter_type: "HC" + inverter: + name: "Hanchu iESS" + has_rest_api: false + has_mqtt_api: false + has_service_api: true + output_charge_control: "power" + charge_control_immediate: true + has_charge_enable_time: false + has_discharge_enable_time: false + has_target_soc: false + has_reserve_soc: false + has_timed_pause: false + charge_time_format: "S" + charge_time_entity_is_option: false + soc_units: "%" + num_load_entities: 1 + has_ge_inverter_mode: false + has_fox_inverter_mode: false + time_button_press: false + clock_time_format: "%d-%m-%y %H:%M:%S" + write_and_poll_sleep: 2 + has_time_window: false + support_charge_freeze: false + support_discharge_freeze: false + balance_inverters_seconds: 0 + # ─── Hanchu iESS control surface (write) ────────────────────────────────── + # All four hooks call the same queued script, passing mode_action so it + # knows which state to apply. See inverter-setup docs for the full script. + charge_start_service: + - service: script.hanchu_set_state_queued + data: + mode_action: charge_start + charge_stop_service: + - service: script.hanchu_set_state_queued + data: + mode_action: charge_stop + discharge_start_service: + - service: script.hanchu_set_state_queued + data: + mode_action: discharge_start + discharge_stop_service: + - service: script.hanchu_set_state_queued + data: + mode_action: discharge_stop + # ─── Hanchu iESS sensor entities (read) ─────────────────────────────────── + charge_rate: + - number.hanchuess_YOURSERIAL_charge_power_limit + discharge_rate: + - number.hanchuess_YOURSERIAL_discharge_power_limit + battery_power: + - sensor.hanchuess_YOURSERIAL_battery_power + battery_power_invert: + - true # Hanchu sign convention is opposite to Predbat's + pv_power: + - sensor.hanchuess_YOURSERIAL_power + load_power: + - sensor.hanchuess_YOURSERIAL_load_power + load_power_invert: + - false + grid_power: + - sensor.hanchuess_YOURSERIAL_grid_power + grid_power_invert: + - true # Hanchu sign convention is opposite to Predbat's + soc_percent: + - sensor.hanchuess_YOURSERIAL_battery_soc + soc_max: + - sensor.hanchuess_YOURSERIAL_battery_capacity_kwh # Returns capacity in kWh, used to derive soc_kw + soc_kw: + - sensor.home_battery_state_of_charge_kwh # Template sensor — see configuration.yaml step in inverter-setup docs + battery_min_soc: + - number.hanchuess_YOURSERIAL_minimum_discharge_soc + # ─── Hanchu iESS battery physical specs ─────────────────────────────────── + # Adjust all limits to match your inverter and battery rated capacity in watts + inverter_battery_rate_min: + - 0 # Older firmware may require 500 + inverter_limit: + - 5000 + inverter_limit_charge: + - 5000 + inverter_limit_discharge: + - 5000 + inverter_limit_export: + - 5000 + battery_rate_max: + - 5000 + # Charge/discharge power curves based on observed Hanchu iESS behaviour + battery_charge_power_curve: + 97: 0.5 + 98: 0.2 + 99: 0.1 + battery_discharge_power_curve: + 4: 1.0 + inverter_clock_skew_start: 0 + inverter_clock_skew_end: 0 + inverter_clock_skew_discharge_start: 0 + inverter_clock_skew_discharge_end: 0 + inverter_can_charge_during_export: false # Single inverter setup + clock_skew: 0 + # ─── Solcast solar forecast ─────────────────────────────────────────────── + # Solcast cloud interface, set this or the local interface below + #solcast_host: 'https://api.solcast.com.au/' + #solcast_api_key: 'xxxx' + #solcast_poll_hours: 8 + pv_forecast_today: re:(sensor.(solcast_|)(pv_forecast_|)forecast_today) + pv_forecast_tomorrow: re:(sensor.(solcast_|)(pv_forecast_|)forecast_tomorrow) + pv_forecast_d3: re:(sensor.(solcast_|)(pv_forecast_|)forecast_(day_3|d3)) + pv_forecast_d4: re:(sensor.(solcast_|)(pv_forecast_|)forecast_(day_4|d4)) + # ─── Car charging ───────────────────────────────────────────────────────── + car_charging_energy: 're:(sensor.myenergi_zappi_[0-9a-z]+_charge_added_session|sensor.wallbox_portal_added_energy)' + num_cars: 1 + car_charging_planned: + - 're:(sensor.wallbox_portal_status_description|sensor.myenergi_zappi_[0-9a-z]+_plug_status)' + car_charging_planned_response: + - 'yes' + - 'on' + - 'true' + - 'connected' + - 'ev connected' + - 'charging' + - 'paused' + - 'waiting for car demand' + - 'waiting for ev' + - 'scheduled' + - 'enabled' + - 'latched' + - 'locked' + - 'plugged in' + - 'waiting' + #car_charging_now: + # - off + car_charging_now_response: + - 'yes' + - 'on' + - 'true' + #car_charging_battery_size: + # - 75 + #car_charging_limit: + # - 're:number.tsunami_charge_limit' + #car_charging_soc: + # - 're:sensor.tsunami_battery' + #car_charging_exclusive: + # - True + # ─── Octopus Energy ─────────────────────────────────────────────────────── + octopus_intelligent_slot: 're:(binary_sensor.octopus_energy([0-9a-z_]+|)_intelligent_dispatching)' + octopus_ready_time: 're:((select|time).octopus_energy_([0-9a-z_]+|)_intelligent_target_time)' + octopus_charge_limit: 're:(number.octopus_energy([0-9a-z_]+|)_intelligent_charge_target)' + #octopus_intelligent_slot: 'binary_sensor.ohme_slot_active' + #octopus_ready_time: 'time.ohme_target_time' + #octopus_charge_limit: 'number.ohme_target_percent' + #octopus_slot_low_rate: False + octopus_saving_session: 're:(event.octopus_energy([0-9a-z_]+|)_saving_session_event(s|))' + octopus_saving_session_octopoints_per_penny: 8 + octopus_free_session: 're:(event.octopus_energy_([0-9a-z_]+|)_octoplus_free_electricity_session_events)' + # octopus_free_url: 'http://octopus.energy/free-electricity' + # axle_api_key: "xxxxxxx" + # ─── Energy rates ───────────────────────────────────────────────────────── + metric_octopus_import: 're:(event.octopus_energy_electricity_[0-9a-z_]+(?