Angry DadAngry Dad
← The blog
Solar & EV· 12 min read

Chasing Sunbeams: Bullying a Cheap Tuya EV Charger Into Solar-Smart Charging (and Making It Share With the Pool)

Solar on the roof, an EV in the driveway, and a tariff that charges 40c at 4pm and 8c at midnight. So I took a cheap Tuya charger off the cloud, fixed its lying sensors, taught it to modulate amps against live solar surplus — and then made it share the sunshine with the pool. Full config included.

Here's the setup, and see if you can spot the trap.

I've got solar on the roof, an EV in the driveway, and one of those shiny new "EV plan" time-of-use tariffs that reads like a dare:

  • Super off-peak, midnight to 6am: 8c/kWh. Lovely.
  • Peak, 4pm–8pm weekdays: ~40c/kWh. An act of violence.
  • Solar feed-in: 2c/kWh. They are, essentially, taking my sunshine and leaving me a gold coin for the parking meter.

So the maths is brutal and simple: charge the car on my own solar (effectively ~2c of "lost" feed-in) or on the 8c overnight window, and never, ever let it sip from the grid at 4:30pm. Meanwhile the pool filter needs to run a couple of hours a day, and it would very much like a piece of that same solar surplus.

Two hungry loads, one plate of sunshine, and a tariff designed to punish stupidity. Naturally I automated the whole thing in Home Assistant. Naturally it fought back. Let's go.

Step one: cut the cloud cord

The charger is a cheap Kolanky 15A portable EVSE — one of a thousand rebadged Tuya "AC charging control boxes" (Tuya category qccdz, product id 7bvgooyjhiua1yyq, protocol 3.4/3.5). Out of the box it only talks to Tuya's cloud through the Smart Life app, which means my charging strategy would depend on a server in another country having a good day. No thanks.

Enter LocalTuya. Pull the device's local key out of the Tuya IoT portal once, and from then on Home Assistant talks to the charger directly over your LAN. No cloud, no latency, no "the internet is down so your car won't charge" nonsense. If you're doing this, use the actively-maintained xZetsubou/hass-localtuya fork — the original is asleep and won't handle newer protocol versions.

Great. Local control. Now let's look at what LocalTuya thinks this device is.

Step two: the auto-detect dumpster fire

LocalTuya's auto-detect took one look at my charger and confidently reported:

  • Voltage: 22 V. My house is not, in fact, running on a car battery.
  • Current: "1 V". That's not even the right unit, mate.
  • A slider labelled "Set 400A". On a 15-amp portable charger. Sure. Let me just weld the driveway.

What actually happened: the raw datapoints are fine, but the integration guessed the scaling wrong. Voltage (DP102) got divided by 10 (222V → "22V"), current (DP105) got typed as a voltage and divided by 100 (140 → "1V" instead of 14.0A), and the "Set 400A" thing is a vestigial datapoint the firmware ships for its bigger 32/50/400A siblings.

Rather than hand-fix 20-odd entities every time, I wrote a LocalTuya device template that maps this thing correctly. Drop it in custom_components/localtuya/templates/, restart, and it shows up as a one-click "Import template" when you add the device. It's at the bottom of this post — pinch it if you've got the same charger.

The important corrections:

  • Voltage → scaling 1 (the raw value is already volts)
  • Current → device_class current, scaling 0.1 (raw is deci-amps)
  • On/off is the "Charge State" select (Open charging / Close charging), not the "switch" datapoint (which reads unknown and is useless)

Which brings me to the bit I was actually hoping for.

Step three: the amps are reachable (the good surprise)

Adjustable current is why I bought this thing. Plenty of cheap EVSEs are a dumb on/off brick, and a brick is useless here — if all you can do is start and stop, you can't chase a solar surplus that's moving around all afternoon. This one advertised 6–15A adjustable, so it went in the cart.

What I didn't know was whether that dial would be reachable from Home Assistant, or whether it'd be sealed inside the Smart Life app where an automation can't get at it. Plenty of gear has a knob you can only turn with your thumb.

It's datapoint 114. Write a number to it over LocalTuya and the charge current changes live. Set 6 → the car pulls ~6A. Set 15 → ~14A. Mid-session. No unplugging. I made a small undignified noise when that worked.

Because that's the whole ballgame: it turns "charge or don't" into "charge at exactly the rate my solar can cover right now." When a cloud rolls over and my export drops from 4kW to 1kW, I don't have to yank the whole charge — I just wind the amps back. That's the difference between dumping surplus to the grid for 2c and actually drinking it.

(There's a catch. There's always a catch. Setting DP114 to 16 — the theoretical hardware max — does nothing. The device just ignores it and holds whatever low current it was on. 15 is the real ceiling. I found this the fun way: wondering why my "max amps" overnight was quietly charging at 6A. Cap your slider at 15 and move on with your life.)

Step four: the charging brain

The logic ended up being one desired-state controller that re-evaluates every couple of minutes and decides should this be charging, and if so, at what amps:

  • Super off-peak (8c) or a manual "charge now" override → charge at max (15A). It's cheap, go hard.
  • Daytime with solar surplus → charge, but modulate the amps so I consume the surplus without importing. Ramp up when the sun's out, ramp down when it isn't, stop before I start buying grid.
  • Everything else (hello, 4pm peak) → don't you dare.

Plus the usual sanity rails: only when the car's plugged in and below its target state-of-charge, hold the car to 80% daily / 100% for trips, and clean up when it's unplugged.

The EV Optimised Charging dashboard in Home Assistant

Caught mid-modulation: 2.5 kW of sun about, so it's settled on 8 amps rather than the full 15. The chips are the live decision — solar in, grid in/out, what the charger's actually pulling, and the amps it landed on. Every threshold is a slider rather than a number buried in YAML, because the one thing I know about tariffs is that they change.

On paper, chef's kiss. In practice, this is where I earned the name of the blog.

Step five: the bugs that made me Angry Dad

Bug one: it charged straight into peak and I nearly had a stroke. I caught it pulling a full 3.3kW off the grid at peak rates one evening — max amps, worst possible price. The controller's logic was correct — it knew it shouldn't charge — but a single template threw can't subtract offset-naive and offset-aware datetimes. That's Home Assistant's way of telling you you've subtracted a timezone-naive datetime (an input_datetime I'd stored) from a timezone-aware one (now()). The error killed the "should I stop?" branch, so once it started on daytime solar it never turned off — it just rode the sunset straight into the most expensive four hours of the day.

Lesson, tattoo it on your arm: never do now() - as_datetime(some_state) in HA. Use as_timestamp() diffs. Epoch maths doesn't care about timezones.

Bug two: the car falls asleep. The EV9, when it's been idle a while, ignores the charger politely asking it to start. The fix is a car-side nudge, but it means "just send Open charging" isn't always enough — worth knowing before you trust it unattended overnight. Remember this one; it makes the next bug much worse.

Bug three: death by a thousand clouds. My first cut of the "am I still charging?" logic was blunt as a brick: if import goes over 500W, stop. Sounds sensible. It is not. Solar is a jittery, twitchy signal — one cloud drifting across the panels drops your export from 4kW to 1kW and back in the time it takes to blink. So every passing cloud tripped the "import over threshold → STOP" rule, killed the whole charge session, and then — thanks to Bug Two — left me with a sleeping car that wouldn't wake back up. A partly cloudy afternoon became the world's most pathetic strobe light: charge, stop, snooze, charge, stop, snooze.

The fix is the entire reason the amp modulation exists, and I should've built it this way from the start: when the surplus shrinks, wind the amps down — don't cut off. Ramp toward the 6A floor as export fades, and only actually stop once even the minimum can't be covered without buying grid — with a dwell timer on that decision so a two-second cloud can't end anything. A noisy input needs hysteresis, not a hair trigger. I kept the instant-stop for the cases that genuinely deserve it (peak window, nighttime, car unplugged), but a passing cloud is not one of them. Now it just... breathes. Charges hard in the sun, eases back under cloud, rides through the wobbles instead of face-planting on every one.

That fix ended up splitting the whole thing into two loops, and this is the bit I'd actually recommend stealing. The slow one runs every two minutes and only ever decides whether to be charging at all — cheap window, peak window, plugged in, below target. The fast one runs every fifteen seconds and only ever decides how hard, nudging the amps a single step at a time off the live grid reading, with a small deadband so it doesn't sit there hunting.

Same twitchy input, two completely different temperaments — purely because of what each loop is allowed to do about it. Ask a jittery signal "should I stop?" every fifteen seconds and you've built a strobe light. Ask it "should I ease off a notch?" and you've built cruise control.

Bug four: the aforementioned cursed 16A. Already ranted about it. Still mad.

Step six: enter the pool (the turf war)

Now the fun part — the pool wants that solar too, and the whole point of an "EV plan" is that the car gets first dibs.

I ripped out the old "run the filter for a fixed block at 1am" automation and replaced it with a daily runtime target. The pool needs, say, 100 minutes of filtering a day — but it doesn't care when, and it doesn't have to be one block. So:

  • During the day, run the filter on leftover solar surplus — but only when the car won't use it: either the EV is done charging, or it's already pinned at max amps and there's still enough export left over. Car eats first; pool gets the scraps.
  • In the 8c super off-peak window, top up whatever runtime the day fell short. Cheap grid backfill.
  • Cap at the target so it doesn't over-run (and over-chlorinate).

There's a genuinely satisfying trick buried in here. The makeup window (midnight–6am) technically belongs to the next calendar day, so naively you'd need some horrible cross-midnight arithmetic to know how short "yesterday" was. Instead I reset the runtime counter at 6am, not midnight. That makes the "pool day" run 6am→6am, so the counter just... carries straight through midnight into the makeup window. No special cases. Sometimes the elegant answer is just moving the fence post.

The Pool Control dashboard in Home Assistant

Runtime-target automation, solar-first, super off-peak makeup. 5 of 100 minutes done, 95 to go, currently idle — because the chip on the end says the EV is drawing 2,933 W and the car eats first.

And yes, of course there was a priority bug on day one: the pool controller saw the EV sitting idle (not currently drawing) and went "oh, it's done, I'll take the surplus" — then the car tried to start and found its lunch money gone. Turns out "not charging right now" and "done charging" are very different things. Fixed it so the pool only counts the EV as out of the picture if it's genuinely full/unplugged or actively maxed — and if the car ever wants the surplus back, the pool yields immediately instead of waiting out its anti-flap timer.

Where it landed

The car now charges on sunshine when there's sun, on 8c grid when there isn't, and never on 40c peak. The pool sips whatever's left over during the day and quietly makes up the difference overnight for pennies. Both of them show their live decision on a dashboard so I can see exactly why each one is on or off. And when the retailer next "improves" my tariff, it's a one-line change instead of a rewrite.

A day of energy: solid grid import overnight in the cheap window, then solar takes over

A day, in one picture. Those fat blue bars from midnight to 6am are the car drinking 8c electricity. They stop dead at 6am. Then the sun comes up, the orange takes over, and the purple underneath is what's left going back to the grid for tuppence — which is exactly what the pool is there to eat.

Total spend on the charger: about the price of a nice dinner. Total spend on my dignity, debugging timezone subtraction at 11pm: incalculable.

Next up: a plan

Right now the off-peak rule is dumb in one specific way: at midnight it has no idea what tomorrow looks like. If the car's below target, it takes the 8c grid. That's exactly right in the middle of a grey week, and exactly wrong the night before a cloudless one — because every kWh I buy at 8c overnight is a kWh of free sunshine that won't fit in the car tomorrow.

Home Assistant already knows what the day's likely to produce; the forecast is sitting right there as a dashed line over the solar chart. So the next job is to actually use it — look at the predicted PV output for the day ahead and let that decide whether to fill up cheap overnight or hold off and wait for the sun.

That's the plan. It is not built yet. Ask me how it went in a month.

Right, the actual config

Everything lives in two Home Assistant packages — drop them in config/packages/ and reload (no restart needed for these). They're a matched pair: the pool package defines the grid-power sensors that the EV package also leans on.

Before you paste, swap these for your own entities:

  • sensor.shellyem3_485519dbd509_channel_a_power — your grid clamp/CT power sensor. My Shelly EM3 reports negative = exporting, positive = importing; check yours and flip the sign in the two grid template sensors if it's the other way round.
  • bf14c6a120bf70874e3cbw — your charger's LocalTuya device id.
  • select.portable_ev_charger_charge_state / number.portable_ev_charger_set_16a — the charger's on/off + amps entities (they come from the device template below).
  • sensor.ev_charger_power — a live reading of the car's draw. I use a separate DIN meter upstream because it's more accurate, but you can point this straight at the charger's own Power sensor (DP109) from the template.
  • sensor.ev9_ev_battery_level / binary_sensor.ev9_ev_battery_plug / number.ev9_ac_charging_limit — your car integration's SoC, plug-in, and charge-limit entities.
  • switch.pool_power_board_pool_filter_pump — your pool filter pump switch.
  • The tariff windows (00:00–06:00 off-peak, 16:00–20:00 weekday peak) — change to match your plan.

Both packages are off by default — flip input_boolean.ev_charge_optimise and input_boolean.pool_solar_auto on once you've checked the entities. Everything else is a tunable input_number slider, so you can tweak thresholds live without editing YAML.

packages/ev_charging.yaml

# EV Optimised Charging — Time-of-Use tariff
# Charge on CHEAP energy only: super off-peak (00:00-06:00) or the manual override at max amps,
# or daytime SOLAR surplus with the current modulated 6-15A to self-consume. Never peak/shoulder grid.
# CHARGER = a Tuya portable EVSE via LocalTuya. On/off = the Charge State select; amps = DP114.
# Grid signal via sensor.grid_import_power / grid_export_power (defined in pool_solar.yaml).
# OFF by default -> flip input_boolean.ev_charge_optimise ON. Manual charge any time: ev_charge_now ON.

input_boolean:
  ev_charge_optimise:
    name: EV Optimised Charging
    icon: mdi:ev-station
  ev_charge_now:
    name: EV Charge Now (manual override)
    icon: mdi:flash
  ev_charge_to_100:
    name: EV Charge to 100% (trip)
    icon: mdi:map-marker-distance

input_number:
  ev_solar_surplus_w:
    name: EV - start solar charge above export
    min: 400
    max: 7500
    step: 100
    initial: 1400
    unit_of_measurement: W
    icon: mdi:solar-power
  ev_solar_stop_import_w:
    name: EV - stop solar charge above import
    min: 100
    max: 3000
    step: 100
    initial: 500
    unit_of_measurement: W
    icon: mdi:transmission-tower-import
  ev_charge_min_cycle_min:
    name: EV - min minutes before switching off
    min: 2
    max: 30
    step: 1
    initial: 5
    unit_of_measurement: min
    icon: mdi:timer-cog-outline
  ev_charge_min_amps:
    name: EV - min charge amps
    min: 6
    max: 10
    step: 1
    initial: 6
    unit_of_measurement: A
    icon: mdi:current-ac
  ev_charge_max_amps:
    # HARD CAP 15: this charger's DP114 glitches at 16 (device ignores it and holds a low current
    # ~6A); 15 is the highest value it actually delivers. Do NOT raise to 16.
    name: EV - max charge amps
    min: 8
    max: 15
    step: 1
    initial: 15
    unit_of_measurement: A
    icon: mdi:current-ac
  ev_charge_voltage:
    name: EV - charge voltage (amp calc)
    min: 220
    max: 250
    step: 1
    initial: 240
    unit_of_measurement: V
    icon: mdi:sine-wave

input_datetime:
  ev_charge_last_change:
    name: EV - last charge on/off
    has_date: true
    has_time: true

template:
  - binary_sensor:
      - name: EV Super Off Peak Window
        unique_id: ev_super_off_peak_window
        icon: mdi:clock-check-outline
        state: "{{ today_at('00:00') <= now() < today_at('06:00') }}"
      - name: EV Below Target
        unique_id: ev_below_target
        icon: mdi:battery-charging
        state: >-
          {{ states('sensor.ev9_ev_battery_level') | float(100)
             < states('number.ev9_ac_charging_limit') | float(80) }}
        availability: "{{ states('sensor.ev9_ev_battery_level') not in ['unknown','unavailable'] }}"
  - sensor:
      # Live target charge current the controller would set (handy on the dashboard).
      - name: EV Target Charge Amps
        unique_id: ev_target_charge_amps
        unit_of_measurement: A
        icon: mdi:ev-station
        state: >-
          {% set ev = states('sensor.ev_charger_power') | float(0) %}
          {% set exp = states('sensor.grid_export_power') | float(0) %}
          {% set imp = states('sensor.grid_import_power') | float(0) %}
          {% set v  = states('input_number.ev_charge_voltage') | float(240) %}
          {% set mn = states('input_number.ev_charge_min_amps') | int(6) %}
          {% set mx = states('input_number.ev_charge_max_amps') | int(15) %}
          {% set cheap = is_state('input_boolean.ev_charge_now','on')
                         or is_state('binary_sensor.ev_super_off_peak_window','on') %}
          {% if cheap %}{{ mx }}
          {% else %}{{ [ [ ((ev + exp - imp) / v) | round(0) | int, mx ] | min, mn ] | max }}{% endif %}

automation:
  # Desired-state controller: decides on/off AND the charge current. Only acts while optimise is ON.
  - id: ev_charge_controller
    alias: EV Charge - Optimised Controller (amp-modulated)
    mode: restart
    triggers:
      - trigger: time_pattern
        minutes: "/2"
      - trigger: state
        entity_id:
          - input_boolean.ev_charge_optimise
          - input_boolean.ev_charge_now
          - binary_sensor.ev9_ev_battery_plug
          - binary_sensor.ev_super_off_peak_window
          - binary_sensor.ev_below_target
      - trigger: numeric_state
        entity_id: sensor.grid_export_power
        above: input_number.ev_solar_surplus_w
        for: {minutes: 3}
      - trigger: numeric_state
        entity_id: sensor.grid_import_power
        above: input_number.ev_solar_stop_import_w
        for: {minutes: 2}
    conditions:
      - condition: state
        entity_id: input_boolean.ev_charge_optimise
        state: "on"
    variables:
      dev_id: "bf14c6a120bf70874e3cbw"          # <-- your LocalTuya device id
      ev_power: "{{ states('sensor.ev_charger_power') | float(0) }}"
      export_w: "{{ states('sensor.grid_export_power') | float(0) }}"
      import_w: "{{ states('sensor.grid_import_power') | float(0) }}"
      volts: "{{ states('input_number.ev_charge_voltage') | float(240) }}"
      min_a: "{{ states('input_number.ev_charge_min_amps') | int(6) }}"
      max_a: "{{ states('input_number.ev_charge_max_amps') | int(15) }}"
      surplus_w: "{{ states('input_number.ev_solar_surplus_w') | float(1400) }}"
      stop_w: "{{ states('input_number.ev_solar_stop_import_w') | float(500) }}"
      charging_now: "{{ ev_power > 200 }}"
      plugged: "{{ is_state('binary_sensor.ev9_ev_battery_plug','on') }}"
      below_target: "{{ is_state('binary_sensor.ev_below_target','on') }}"
      in_offpeak: "{{ is_state('binary_sensor.ev_super_off_peak_window','on') }}"
      manual: "{{ is_state('input_boolean.ev_charge_now','on') }}"
      day: "{{ is_state('sun.sun','above_horizon') }}"
      in_peak: "{{ now().weekday() < 5 and 16 <= now().hour < 20 }}"
      cheap: "{{ manual or in_offpeak }}"
      # power available to the car = its current draw + what we export - what we import
      avail_w: "{{ ev_power + export_w - import_w }}"
      # raw (unclamped) amps the surplus could sustain; solar_amps = that clamped to [min,max].
      raw_amps: "{{ (avail_w / volts) | round(1) }}"
      solar_amps: "{{ [ [ raw_amps | round(0) | int, max_a ] | min, min_a ] | max }}"
      target_amps: "{{ max_a if cheap else solar_amps }}"
      solar_start: "{{ day and (not charging_now) and export_w >= surplus_w }}"
      # KEEP charging while we can still RAMP DOWN (raw_amps above the floor), OR we're already at the floor
      # with import within tolerance. A passing cloud reduces the amps instead of killing the session; only
      # sustained low-solar at the floor drops it (and that path waits out the min-cycle below).
      solar_keep: "{{ day and charging_now and (raw_amps > min_a or import_w < stop_w) }}"
      want_on: "{{ plugged and below_target and (cheap or solar_start or solar_keep) }}"
      cur_a: "{{ (ev_power / volts) | round(0) | int }}"
      # tz-safe elapsed seconds since last on/off. NEVER do now() - as_datetime(state) -- naive vs aware.
      since_change: "{{ as_timestamp(now()) - as_timestamp(states('input_datetime.ev_charge_last_change'), 0) }}"
      min_cycle_s: "{{ states('input_number.ev_charge_min_cycle_min') | float(5) * 60 }}"
    actions:
      - choose:
          # START (set amps first, then open the session)
          - conditions: "{{ want_on and not charging_now }}"
            sequence:
              - action: localtuya.set_dp
                data: {device_id: "{{ dev_id }}", dp: 114, value: "{{ target_amps | int }}"}
              - action: select.select_option
                target: {entity_id: select.portable_ev_charger_charge_state}
                data: {option: "Open charging"}
              - action: input_datetime.set_datetime
                target: {entity_id: input_datetime.ev_charge_last_change}
                data: {datetime: "{{ now().isoformat() }}"}
          # (Ongoing amp modulation lives in the fast "Solar Amp Modulator" automation below, every 15s.)
          # STOP. Respects the min-cycle for solar dips; bypasses it (stops now) only when night /
          # unplugged / done / in the peak window -- so we never buy peak or shoulder grid.
          - conditions: >-
              {{ (not want_on) and charging_now
                 and ( since_change > min_cycle_s or not day or (not plugged) or (not below_target) or in_peak ) }}
            sequence:
              - action: select.select_option
                target: {entity_id: select.portable_ev_charger_charge_state}
                data: {option: "Close charging"}
              - action: input_datetime.set_datetime
                target: {entity_id: input_datetime.ev_charge_last_change}
                data: {datetime: "{{ now().isoformat() }}"}

  # Fast solar amp modulator: while charging on SOLAR, re-compute the target every 15s and nudge DP114
  # to it (1A steps = the device's finest granularity). Tracks a swinging surplus far tighter than the
  # 2-min main loop. Proportional off the FAST grid signal (the charger's own meter lags). Skips cheap windows.
  - id: ev_amp_modulator
    alias: EV Charge - Solar Amp Modulator (fast)
    mode: single
    max_exceeded: silent
    triggers:
      - trigger: time_pattern
        seconds: "/15"
    conditions:
      - condition: state
        entity_id: input_boolean.ev_charge_optimise
        state: "on"
      - condition: numeric_state
        entity_id: sensor.ev_charger_power
        above: 200
      - condition: state
        entity_id: binary_sensor.ev_super_off_peak_window
        state: "off"
      - condition: state
        entity_id: input_boolean.ev_charge_now
        state: "off"
    variables:
      ev_power: "{{ states('sensor.ev_charger_power') | float(0) }}"
      export_w: "{{ states('sensor.grid_export_power') | float(0) }}"
      import_w: "{{ states('sensor.grid_import_power') | float(0) }}"
      volts: "{{ states('input_number.ev_charge_voltage') | float(240) }}"
      min_a: "{{ states('input_number.ev_charge_min_amps') | int(6) }}"
      max_a: "{{ states('input_number.ev_charge_max_amps') | int(15) }}"
      cur_set: "{{ states('number.portable_ev_charger_set_16a') | int(0) }}"
      # nudge the current setpoint by the net grid (fast Shelly signal). ~120W rounding deadband stops hunting.
      base: "{{ cur_set if cur_set >= min_a else (ev_power / volts) | round(0) | int }}"
      net_amps: "{{ ((export_w - import_w) / volts) | round(0) | int }}"
      target: "{{ [ [ base + net_amps, max_a ] | min, min_a ] | max }}"
    actions:
      - condition: template
        value_template: "{{ target != cur_set }}"
      - action: localtuya.set_dp
        data: {device_id: "bf14c6a120bf70874e3cbw", dp: 114, value: "{{ target }}"}

  # Unplug -> close the session and clear the manual override.
  - id: ev_charge_unplugged
    alias: EV Charge - Unplugged Cleanup
    mode: single
    triggers:
      - trigger: state
        entity_id: binary_sensor.ev9_ev_battery_plug
        to: "off"
    actions:
      - action: select.select_option
        target: {entity_id: select.portable_ev_charger_charge_state}
        data: {option: "Close charging"}
      - if: "{{ is_state('input_boolean.ev_charge_now','on') }}"
        then:
          - action: input_boolean.turn_off
            target: {entity_id: input_boolean.ev_charge_now}

  # Hold the car's AC charge limit at 80% (daily) or 100% (trip). Only while optimise is ON.
  - id: ev_charge_set_limit
    alias: EV Charge - Set AC Limit (80 daily / 100 trip)
    mode: single
    triggers:
      - trigger: state
        entity_id: input_boolean.ev_charge_to_100
      - trigger: state
        entity_id: input_boolean.ev_charge_optimise
        to: "on"
      - trigger: homeassistant
        event: start
    conditions:
      - condition: state
        entity_id: input_boolean.ev_charge_optimise
        state: "on"
    actions:
      - action: number.set_value
        target: {entity_id: number.ev9_ac_charging_limit}
        data:
          value: "{{ 100 if is_state('input_boolean.ev_charge_to_100','on') else 80 }}"

packages/pool_solar.yaml

# Pool solar / runtime-target management
# Hit a DAILY filter-runtime TARGET, split across the day:
#   * DAYTIME: run on free solar surplus, but ONLY when the EV won't use it (EV done, or already at
#     max amps with export to spare). Yields to the EV. Caps at the daily target.
#   * SUPER OFF-PEAK (00:00-06:00): make up whatever runtime the day fell short.
# Runtime counter RESETS AT 06:00 (not midnight), so the "pool day" runs 06:00->06:00 and the counter
# carries through midnight into the makeup window -- no cross-midnight arithmetic.
# OFF by default -> flip input_boolean.pool_solar_auto ON.

template:
  - sensor:
      # Grid signal (also used by the EV package). Shelly EM3 chA: NEG = export, POS = import.
      - name: Grid Import Power
        unique_id: grid_import_power
        unit_of_measurement: W
        device_class: power
        state: >-
          {{ [ states('sensor.shellyem3_485519dbd509_channel_a_power') | float(0), 0 ] | max | round(0) }}
        availability: "{{ states('sensor.shellyem3_485519dbd509_channel_a_power') not in ['unknown','unavailable'] }}"
      - name: Grid Export Power
        unique_id: grid_export_power
        unit_of_measurement: W
        device_class: power
        state: >-
          {{ [ -( states('sensor.shellyem3_485519dbd509_channel_a_power') | float(0) ), 0 ] | max | round(0) }}
        availability: "{{ states('sensor.shellyem3_485519dbd509_channel_a_power') not in ['unknown','unavailable'] }}"
      - name: Pool Runtime Remaining
        unique_id: pool_runtime_remaining
        unit_of_measurement: min
        icon: mdi:timer-sand
        state: >-
          {{ [ states('input_number.pool_daily_runtime_target') | float(0)
               - states('input_number.pool_runtime_today') | float(0), 0 ] | max | round(0) }}

input_boolean:
  pool_solar_auto:
    name: Pool Solar Auto (runtime target)
    icon: mdi:pool

input_number:
  pool_daily_runtime_target:
    name: Pool - daily runtime target
    min: 0
    max: 480
    step: 5
    initial: 100
    unit_of_measurement: min
    icon: mdi:target
  pool_runtime_today:
    name: Pool - runtime today
    min: 0
    max: 1440
    step: 1
    initial: 0
    unit_of_measurement: min
    icon: mdi:timer-outline
  pool_surplus_run_w:
    name: Pool - run on export above
    min: 400
    max: 4000
    step: 100
    initial: 1200
    unit_of_measurement: W
    icon: mdi:transmission-tower-export
  pool_stop_import_w:
    name: Pool - stop on import above
    min: 100
    max: 2000
    step: 50
    initial: 300
    unit_of_measurement: W
    icon: mdi:transmission-tower-import
  pool_min_cycle_min:
    name: Pool - min minutes between switches
    min: 2
    max: 30
    step: 1
    initial: 10
    unit_of_measurement: min
    icon: mdi:timer-cog-outline

automation:
  # 1) Runtime tracker: +1 min for every minute the filter runs.
  - id: pool_runtime_tracker
    alias: Pool - Runtime Tracker (+1/min)
    mode: single
    triggers:
      - trigger: time_pattern
        minutes: "/1"
    conditions:
      - condition: state
        entity_id: switch.pool_power_board_pool_filter_pump
        state: "on"
    actions:
      - action: input_number.set_value
        target: {entity_id: input_number.pool_runtime_today}
        data:
          value: "{{ [ states('input_number.pool_runtime_today') | float(0) + 1, 1440 ] | min }}"

  # 2) Reset at 06:00 -- start of a new pool-day (the 00:00-06:00 makeup belongs to the PREVIOUS day).
  - id: pool_runtime_reset
    alias: Pool - Runtime Reset (06:00)
    mode: single
    triggers:
      - trigger: time
        at: "06:00:00"
    actions:
      - action: input_number.set_value
        target: {entity_id: input_number.pool_runtime_today}
        data: {value: 0}

  # 3) Controller: daytime solar (EV-priority) + super-off-peak makeup, capped at target.
  - id: pool_solar_controller
    alias: Pool - Solar Runtime Controller
    mode: restart
    triggers:
      - trigger: time_pattern
        minutes: "/2"
      - trigger: state
        entity_id:
          - input_boolean.pool_solar_auto
          - switch.pool_power_board_pool_filter_pump
          - binary_sensor.ev_super_off_peak_window
      - trigger: numeric_state
        entity_id: sensor.grid_export_power
        above: input_number.pool_surplus_run_w
        for: {minutes: 3}
      - trigger: numeric_state
        entity_id: sensor.grid_import_power
        above: input_number.pool_stop_import_w
        for: {minutes: 2}
    conditions:
      - condition: state
        entity_id: input_boolean.pool_solar_auto
        state: "on"
    variables:
      runtime: "{{ states('input_number.pool_runtime_today') | float(0) }}"
      target: "{{ states('input_number.pool_daily_runtime_target') | float(100) }}"
      need_more: "{{ runtime < target }}"
      export_w: "{{ states('sensor.grid_export_power') | float(0) }}"
      import_w: "{{ states('sensor.grid_import_power') | float(0) }}"
      surplus_w: "{{ states('input_number.pool_surplus_run_w') | float(1200) }}"
      stop_w: "{{ states('input_number.pool_stop_import_w') | float(300) }}"
      pool_on: "{{ is_state('switch.pool_power_board_pool_filter_pump','on') }}"
      in_offpeak: "{{ is_state('binary_sensor.ev_super_off_peak_window','on') }}"
      day: "{{ is_state('sun.sun','above_horizon') }}"
      ev_charging: "{{ states('sensor.ev_charger_power') | float(0) > 200 }}"
      # EV is genuinely DONE (won't charge): unplugged, or SoC at/above its limit.
      ev_done: "{{ (not is_state('binary_sensor.ev9_ev_battery_plug','on')) or (not is_state('binary_sensor.ev_below_target','on')) }}"
      # EV is ACTIVELY charging AND already pinned at max amps (can't absorb any more surplus).
      ev_at_max: "{{ ev_charging and (states('number.portable_ev_charger_set_16a') | int(0) >= states('input_number.ev_charge_max_amps') | int(15)) }}"
      # EV PRIORITY: pool may take surplus ONLY if the EV won't (done) or is already maxed. An idle-but-
      # plugged EV that's below target gets first dibs -> pool must NOT run (else it starves the EV).
      ev_ok: "{{ ev_done or ev_at_max }}"
      surplus_ok: "{{ (export_w >= surplus_w) if not pool_on else (import_w < stop_w) }}"
      want_day: "{{ day and need_more and ev_ok and surplus_ok }}"
      want_makeup: "{{ in_offpeak and need_more }}"
      want_on: "{{ want_day or want_makeup }}"
      since_change: "{{ as_timestamp(now()) - as_timestamp(states.switch.pool_power_board_pool_filter_pump.last_changed, 0) }}"
      min_cycle_s: "{{ states('input_number.pool_min_cycle_min') | float(10) * 60 }}"
    actions:
      - choose:
          - conditions: "{{ want_on and not pool_on }}"
            sequence:
              - action: switch.turn_on
                target: {entity_id: switch.pool_power_board_pool_filter_pump}
          # STOP. Normally waits out the min-cycle, but YIELDS IMMEDIATELY when the EV needs the surplus
          # (priority) or we'd start importing.
          - conditions: >-
              {{ (not want_on) and pool_on
                 and ( since_change > min_cycle_s or (not ev_ok) or import_w >= stop_w ) }}
            sequence:
              - action: switch.turn_off
                target: {entity_id: switch.pool_power_board_pool_filter_pump}

(The salt cell follows the filter with a separate one-liner automation, and the "on/off is a select, not a switch" quirk is handled by the device template below.)


The LocalTuya device template

For the Kolanky 15A / Tuya "AC charging control box" (category qccdz, product id 7bvgooyjhiua1yyq). Drop into custom_components/localtuya/templates/, restart HA, and pick it from "Import template" when adding the device. Fixes the voltage/current scaling and exposes the actual working controls.

# LocalTuya device template — Tuya AC EV Charging Control Box (category "qccdz")
# Product ID 7bvgooyjhiua1yyq | model "AC charging control box" | protocol 3.4/3.5
# On/off = Charge State select (DP124, Open/Close charging; its state always reads
# "Wait for operation" even while charging). Amps = Charge Current (DP114, 6-15 only;
# 16 is ignored by the firmware). Voltage/Current scaling fixed vs auto-detect.

- select:
    id: "124"
    friendly_name: "Charge State"
    platform: select
    entity_category: config
    select_options:
      OpenCharging: "Open charging"
      CloseCharging: "Close charging"
      WaitOperation: "Wait for operation"

- number:
    id: "114"
    friendly_name: "Charge Current"
    platform: number
    entity_category: config
    min_value: 6
    max_value: 16
    step_size: 1
    scaling: 1
    unit_of_measurement: "A"

- select:
    id: "14"
    friendly_name: "Work Mode"
    platform: select
    entity_category: config
    select_options:
      charge_now: "Charge now"
      charge_pct: "To percent"
      charge_energy: "To energy"
      charge_schedule: "Scheduled"

- switch:
    id: "18"
    friendly_name: "Charger Switch"
    platform: switch
    entity_category: None
    restore_on_reconnect: false
    is_passive_entity: false

- button:
    id: "16"
    friendly_name: "Clear Energy"
    platform: button
    entity_category: config

- number:
    id: "118"
    friendly_name: "Delay Time"
    platform: number
    entity_category: config
    min_value: 0
    max_value: 15
    step_size: 1
    scaling: 1
    unit_of_measurement: "h"

- number:
    id: "119"
    friendly_name: "Timer"
    platform: number
    entity_category: config
    min_value: 0
    max_value: 15
    step_size: 1
    scaling: 1
    unit_of_measurement: "h"

- sensor:
    id: "102"
    friendly_name: "Voltage"
    platform: sensor
    device_class: voltage
    state_class: measurement
    unit_of_measurement: "V"
    scaling: 1          # FIX: was 0.1 (showed 22 V); raw value is volts
    entity_category: None

- sensor:
    id: "105"
    friendly_name: "Current"
    platform: sensor
    device_class: current
    state_class: measurement
    unit_of_measurement: "A"
    scaling: 0.1        # FIX: was device_class voltage / 0.01 (showed 1 V); raw is deci-amps
    entity_category: None

- sensor:
    id: "109"
    friendly_name: "Power"
    platform: sensor
    device_class: power
    state_class: measurement
    unit_of_measurement: "W"
    scaling: 100        # raw ~0.1 kW units; on-board meter, approximate
    entity_category: None

- sensor:
    id: "110"
    friendly_name: "Temperature"
    platform: sensor
    device_class: temperature
    state_class: measurement
    unit_of_measurement: "°C"
    scaling: 0.1
    entity_category: None

- sensor:
    id: "15"
    friendly_name: "Energy Balance"
    platform: sensor
    device_class: energy
    state_class: total
    unit_of_measurement: "kWh"
    scaling: 0.001
    entity_category: None

- sensor:
    id: "25"
    friendly_name: "Session Energy"
    platform: sensor
    device_class: energy
    state_class: total
    unit_of_measurement: "kWh"
    scaling: 0.01
    entity_category: None

- sensor:
    id: "3"
    friendly_name: "Work State"
    platform: sensor
    entity_category: diagnostic

- sensor:
    id: "101"
    friendly_name: "Device State"
    platform: sensor
    entity_category: diagnostic

- binary_sensor:
    id: "10"
    friendly_name: "Fault"
    platform: binary_sensor
    device_class: problem
    state_on: "1"
    entity_category: diagnostic

- sensor:
    id: "113"
    friendly_name: "Max Set Ampere"
    platform: sensor
    entity_category: diagnostic

- sensor:
    id: "108"
    friendly_name: "Phase Flag"
    platform: sensor
    entity_category: diagnostic

- select:
    id: "27"
    friendly_name: "Online State"
    platform: select
    entity_category: diagnostic
    select_options:
      online: "Online"
      offline: "Offline"

A word of warning on that Charge Current slider: the firmware happily accepts 16 and then completely ignores it, holding a low current instead. Set your real-world max to 15. And whatever your charger's rated at, don't ask it to pull more than your circuit and plug can handle — a 15A portable unit charging continuously is already living near the edge.

Get one of these — and a working automation — each week.

The builds, the bugs, and the YAML. No fluff.

Don't fancy the DIY?

Get Dycom Automation to do it properly.

Reading YAML isn't everyone's idea of a good night in. Dycom Automation — the grown-up, properly-insured version of all this — will design, install and tune your whole smart home for you. Same ruthless automations, none of the swearing.

Get it done by Dycom Automation